Compare commits
@@ -0,0 +1,16 @@
|
|||||||
|
# Git Safety
|
||||||
|
|
||||||
|
## Staging rules
|
||||||
|
|
||||||
|
- **Stage files by name** — never use `git add -A` or `git add .`
|
||||||
|
- Verify no secrets, saves, or binary blobs are staged
|
||||||
|
- Skip files in `.gitignore`
|
||||||
|
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||||
|
|
||||||
|
## Commit conventions
|
||||||
|
|
||||||
|
Use conventional commits: `<type>(<scope>): <summary>`
|
||||||
|
|
||||||
|
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||||
|
|
||||||
|
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Local Services
|
||||||
|
|
||||||
|
Endpoints are also preconfigured in `db/connectors/config.json`.
|
||||||
|
|
||||||
|
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||||
|
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||||
|
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||||
|
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Project Structure (detailed)
|
||||||
|
|
||||||
|
```
|
||||||
|
client/ # Godot 4 client
|
||||||
|
server/ # Rust/bevy_ecs simulation server
|
||||||
|
tooling/ # Build tools, scripts, asset pipelines
|
||||||
|
tests/ # Integration and end-to-end tests
|
||||||
|
.config/ # Configuration files (linters, formatters, CI)
|
||||||
|
.cache/ # Local caches for testing/linting (gitignored)
|
||||||
|
docs/
|
||||||
|
discussions/ # Discussion rounds (archived here when complete)
|
||||||
|
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||||
|
architecture/ # Technical architecture documents
|
||||||
|
design/ # Game design documents
|
||||||
|
diagrams/ # d2 source + PNG renders
|
||||||
|
sprints/ # Sprint briefings per team
|
||||||
|
workshops/ # Workshop briefs and outputs
|
||||||
|
db/
|
||||||
|
schema.sql # Database schema
|
||||||
|
connectors/ # Connector scripts for SQLite and Qdrant
|
||||||
|
config.json # Endpoint configuration
|
||||||
|
ticket # Ticket CLI
|
||||||
|
sqlite_connector.py # SQLite mini MCP
|
||||||
|
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||||
|
.claude/
|
||||||
|
agents/ # Agent personality files
|
||||||
|
skills/ # Skill definitions
|
||||||
|
rules/ # Auto-loaded instruction modules
|
||||||
|
decisions/ # Decision domain files (source of truth)
|
||||||
|
README.md # Domain index — use this to find specific D-records
|
||||||
|
architecture.md # Architecture decisions
|
||||||
|
perception.md # Perception and information system decisions
|
||||||
|
content.md # Content and narrative decisions
|
||||||
|
scope.md # Scope and feature decisions
|
||||||
|
process.md # Process and workflow decisions
|
||||||
|
questions.md # Open questions (Q-NNN)
|
||||||
|
rejected.md # Rejected proposals (R-NNN)
|
||||||
|
DECISIONS.md # Redirect to decisions/ directory
|
||||||
|
TEAM.md # Team roster and roles
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Gitea Access (tea CLI)
|
||||||
|
|
||||||
|
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||||
|
|
||||||
|
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List open PRs
|
||||||
|
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||||
|
|
||||||
|
# View a PR with comments
|
||||||
|
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||||
|
|
||||||
|
# Post a comment on a PR (or issue)
|
||||||
|
tooling/tea-comment <NUMBER> "comment body"
|
||||||
|
|
||||||
|
# Approve a PR
|
||||||
|
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||||
|
|
||||||
|
# List issues
|
||||||
|
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- **For comments, use `tooling/tea-comment <number> "body"`** — handles temp files and cleanup automatically. Works with multi-line strings.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||||
|
|
||||||
|
Always provide all required flags to ensure non-interactive execution:
|
||||||
|
```bash
|
||||||
|
tea pr create \
|
||||||
|
--repo jpmschweitzer/settled-reach \
|
||||||
|
--login schweitz \
|
||||||
|
--title "feat(scope): short description" \
|
||||||
|
--description "PR body here" \
|
||||||
|
--base main \
|
||||||
|
--head branch-name
|
||||||
|
```
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Team Patterns
|
||||||
|
|
||||||
|
## Model selection
|
||||||
|
|
||||||
|
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||||
|
sprint planning, large reviews), switch to extended context on-demand:
|
||||||
|
|
||||||
|
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||||
|
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||||
|
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
|
||||||
|
|
||||||
|
## Large content pushes
|
||||||
|
|
||||||
|
When producing many files (wiki pages, content batches, bulk docs):
|
||||||
|
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||||
|
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||||
|
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||||
|
|
||||||
|
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"Bash(make)",
|
"Bash(make)",
|
||||||
|
|
||||||
"Bash(tea *)",
|
"Bash(tea *)",
|
||||||
|
"Bash(tooling/tea-comment *)",
|
||||||
|
|
||||||
"Bash(chmod *)",
|
"Bash(chmod *)",
|
||||||
"Bash(ls *)",
|
"Bash(ls *)",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -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)"
|
||||||
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
|
|||||||
|
|
||||||
# Search Docs Skill
|
# Search Docs Skill
|
||||||
|
|
||||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
Semantic search across project documents. Endpoints are in
|
||||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
and workflows.
|
||||||
|
|
||||||
## Advanced Commands
|
## Advanced Commands
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
|
|||||||
|
|
||||||
## Staging Rules
|
## Staging Rules
|
||||||
|
|
||||||
- Stage files by name — never use `git add -A` or `git add .`
|
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
|
||||||
- Verify no secrets, saves, or binary blobs are staged
|
These apply to ALL git operations, not just this skill.
|
||||||
- Skip files in `.gitignore`
|
|
||||||
- The `.claude/` directory IS tracked — skills belong in the repo
|
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ Report which tickets were moved to review. Skip tickets that are
|
|||||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||||
`in_progress` → `review`).
|
`in_progress` → `review`).
|
||||||
|
|
||||||
|
### 9. Next steps
|
||||||
|
|
||||||
|
If a sprint team is active (you are the team lead), do NOT shut down
|
||||||
|
agents after pushing. The team should remain alive for PR review and
|
||||||
|
potential comment fixes.
|
||||||
|
|
||||||
|
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
|
||||||
|
|
||||||
## Arguments
|
## Arguments
|
||||||
|
|
||||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||||
|
|||||||
@@ -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:
|
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||||
2. Read source files directly from the branch:
|
2. Read source files directly from the branch worktree (see below).
|
||||||
`git show origin/<branch>:<path>`
|
|
||||||
|
|
||||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||||
raw diff to reviewers — cleaner context, better reviews. Read files with
|
raw diff to reviewers — cleaner context, better reviews.
|
||||||
`git show origin/<branch>:<path>` and include them in the prompt.
|
|
||||||
|
|
||||||
**IMPORTANT — agent tool access:** Not all reviewer agents have Bash access.
|
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
||||||
Agents that CAN read from branches themselves: **Hoshe, Tyre, Araminta**.
|
worktrees. Each team branch is checked out at:
|
||||||
Agents that CANNOT (no Bash tool): **Paula, Miri, Ozzie, Gestalt, Gore, Nigel**.
|
|
||||||
|
|
||||||
For agents without Bash, you MUST read the source files yourself (via
|
```
|
||||||
`git show origin/<branch>:<path>`) and **paste the file contents directly
|
/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||||
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
|
For example, the `copy` branch lives at:
|
||||||
`decisions/*.md` files these agents need for context.
|
```
|
||||||
|
/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
|
### 4. Spawn reviewers in parallel
|
||||||
|
|
||||||
@@ -153,19 +174,11 @@ After presenting results to the user, post the review as a PR comment.
|
|||||||
|
|
||||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
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.**
|
Post using the `tea-comment` wrapper (handles temp files and cleanup):
|
||||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tooling/tea-comment <PR_NUMBER> "review markdown here"
|
||||||
```
|
```
|
||||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
|
||||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
|
||||||
|
|
||||||
# Step 2: Post to Gitea (separate Bash call)
|
|
||||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
|
||||||
directory is gitignored and exists in the repo root for this purpose.
|
|
||||||
|
|
||||||
## 7. Merging approved PRs
|
## 7. Merging approved PRs
|
||||||
|
|
||||||
@@ -182,6 +195,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
|||||||
Gitea does **not** auto-close PRs when you push a local merge — always close
|
Gitea does **not** auto-close PRs when you push a local merge — always close
|
||||||
manually with `tea pr close` after pushing.
|
manually with `tea pr close` after pushing.
|
||||||
|
|
||||||
|
### 8. Post-review team actions
|
||||||
|
|
||||||
|
If a sprint team is active and you are the team lead, handle the
|
||||||
|
review outcome:
|
||||||
|
|
||||||
|
**CHANGES_REQUESTED:**
|
||||||
|
The sprint-start lifecycle (step 9c) handles dispatching review
|
||||||
|
comments to agents. After presenting results, remind the lead:
|
||||||
|
"Review requested changes. Create tasks from the warnings/critical
|
||||||
|
issues and dispatch to idle agents, then re-push and re-review."
|
||||||
|
|
||||||
|
**APPROVED:**
|
||||||
|
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||||
|
presenting results, remind the lead: "Review approved. Proceed with
|
||||||
|
team shutdown per sprint-start step 9c."
|
||||||
|
|
||||||
## Tips from practice
|
## Tips from practice
|
||||||
|
|
||||||
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
||||||
|
|||||||
@@ -2,11 +2,20 @@
|
|||||||
|
|
||||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
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`)
|
## Code reviews (`server`, `client`, `ci`)
|
||||||
|
|
||||||
**Hoshe (Code Quality)**
|
**Hoshe (Code Quality)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `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
|
- Correctness and bug risks
|
||||||
- Error handling gaps
|
- Error handling gaps
|
||||||
- Test coverage (are new features tested?)
|
- Test coverage (are new features tested?)
|
||||||
@@ -16,44 +25,43 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
|||||||
|
|
||||||
**Tyre (Architecture)**
|
**Tyre (Architecture)**
|
||||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||||
- Prompt: Include source code and commit log. Tell Tyre to read the relevant
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
`decisions/*.md` files first, then review for:
|
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
|
||||||
|
first, then review for:
|
||||||
- Architectural consistency with project decisions
|
- Architectural consistency with project decisions
|
||||||
- API/interface design quality
|
- API/interface design quality
|
||||||
- Dependency and coupling concerns
|
- Dependency and coupling concerns
|
||||||
- Scalability implications
|
- Scalability implications
|
||||||
- Whether the change respects non-negotiable baselines (D-010, D-012)
|
- 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`)
|
## Copy reviews (`copy`)
|
||||||
|
|
||||||
**Hoshe (QA)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `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)
|
- Formatting consistency (markdown, file naming, frontmatter)
|
||||||
- Broken references or links
|
- Broken references or links
|
||||||
- Spelling and grammar
|
- Spelling and grammar
|
||||||
- File organization and structure
|
- File organization and structure
|
||||||
- Missing or orphaned files
|
- Missing or orphaned files
|
||||||
|
|
||||||
**Paula (Narrative Depth)** — NO BASH ACCESS
|
**Paula (Narrative Depth)**
|
||||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||||
- Paula cannot read from branches. You must paste file contents and decision
|
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||||
files directly into the prompt.
|
list of relevant `decisions/*.md` files to read. Tell Paula to read all
|
||||||
- Prompt: Include full text of changed files, commit log, and relevant
|
files from the worktree using the Read tool, then review for:
|
||||||
`decisions/*.md` content. Ask Paula to review for:
|
|
||||||
- Narrative quality and character voice consistency
|
- Narrative quality and character voice consistency
|
||||||
- Whether dialogue and monologue feel authentic to the characters
|
- Whether dialogue and monologue feel authentic to the characters
|
||||||
- Consequences and stakes — do choices carry weight?
|
- Consequences and stakes — do choices carry weight?
|
||||||
- Political and interpersonal depth
|
- Political and interpersonal depth
|
||||||
- Emotional resonance — does the text make you feel something?
|
- Emotional resonance — does the text make you feel something?
|
||||||
|
|
||||||
**Miri (World Consistency)** — NO BASH ACCESS
|
**Miri (World Consistency)**
|
||||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||||
- Miri cannot read from branches. You must paste file contents and decision
|
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||||
files directly into the prompt.
|
list of relevant `decisions/*.md` files to read. Tell Miri to read all
|
||||||
- Prompt: Include full text of changed files, commit log, and relevant
|
files from the worktree using the Read tool, then review for:
|
||||||
`decisions/*.md` content. Ask Miri to review for:
|
|
||||||
- Lore accuracy — do facts match established setting?
|
- Lore accuracy — do facts match established setting?
|
||||||
- Internal consistency across files
|
- Internal consistency across files
|
||||||
- IP originality — nothing should read as a copy from another franchise
|
- 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)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `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
|
- File format and naming conventions
|
||||||
- Asset organization and directory structure
|
- Asset organization and directory structure
|
||||||
- Missing or broken references in scene/resource files
|
- Missing or broken references in scene/resource files
|
||||||
- Import settings consistency
|
- Import settings consistency
|
||||||
|
|
||||||
**Araminta (Art Direction)** — HAS BASH ACCESS
|
**Araminta (Art Direction)**
|
||||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||||
- Prompt: Include the changed files and commit log. Tell Araminta to read
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
the style guide and relevant design docs first, then review for:
|
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
|
- Visual consistency with the established style guide
|
||||||
- Color palette adherence
|
- Color palette adherence
|
||||||
- UI pattern consistency (diegetic-first, clarity over beauty)
|
- 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)**
|
**Hoshe (QA)**
|
||||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
- `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
|
- File format and naming conventions
|
||||||
- Audio asset organization and directory structure
|
- Audio asset organization and directory structure
|
||||||
- Missing or broken references
|
- Missing or broken references
|
||||||
- Import/bus configuration consistency
|
- Import/bus configuration consistency
|
||||||
|
|
||||||
**Ozzie (Player Experience)** — NO BASH ACCESS
|
**Ozzie (Player Experience)**
|
||||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||||
- Ozzie cannot read from branches. You must paste file contents directly
|
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||||
into the prompt.
|
Tell Ozzie to read all files from the worktree using the Read tool, then
|
||||||
- Prompt: Include full text of changed files and commit log. Ask Ozzie to
|
|
||||||
review for:
|
review for:
|
||||||
- Emotional impact — does the audio enhance the moment?
|
- Emotional impact — does the audio enhance the moment?
|
||||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||||
|
|||||||
@@ -325,3 +325,57 @@ Output to the user:
|
|||||||
You are now the team lead. Agents work autonomously — monitor via
|
You are now the team lead. Agents work autonomously — monitor via
|
||||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||||
they arise.
|
they arise.
|
||||||
|
|
||||||
|
**When all tasks complete:** Do NOT shut down agents. The team stays
|
||||||
|
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
|
||||||
|
|
||||||
|
### 9. Post-work lifecycle
|
||||||
|
|
||||||
|
When all tasks are complete (TaskList shows all completed):
|
||||||
|
|
||||||
|
#### 9a. Commit and push
|
||||||
|
|
||||||
|
Run `/git-commit` to commit all changes, then `/pr-push` to create or
|
||||||
|
update the PR. Do NOT shut down agents — the team stays alive for review.
|
||||||
|
|
||||||
|
#### 9b. Review
|
||||||
|
|
||||||
|
Run `/pr-review` to spawn temporary reviewers. Wait for results.
|
||||||
|
|
||||||
|
#### 9c. Handle review outcome
|
||||||
|
|
||||||
|
**If CHANGES_REQUESTED:**
|
||||||
|
|
||||||
|
1. Parse the review comment table (from the Gitea PR comment or the
|
||||||
|
review output). Extract each warning/critical issue with:
|
||||||
|
- File path and approximate line
|
||||||
|
- Severity (critical / warning / suggestion)
|
||||||
|
- Description
|
||||||
|
|
||||||
|
2. Create a task per warning/critical issue:
|
||||||
|
```
|
||||||
|
TaskCreate(
|
||||||
|
subject: "Review: {short description}",
|
||||||
|
description: "{full issue description from review table, including
|
||||||
|
file path, severity, and reviewer name}",
|
||||||
|
activeForm: "Fixing review comment: {short description}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
Skip suggestion-severity items unless they are trivial (1-line fixes).
|
||||||
|
|
||||||
|
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||||
|
them to check TaskList for new review-fix tasks. Agents claim and
|
||||||
|
work tasks as usual.
|
||||||
|
|
||||||
|
4. After all review-fix tasks are complete, re-run `/git-commit` then
|
||||||
|
`/pr-push` to update the PR. Then re-run `/pr-review`.
|
||||||
|
|
||||||
|
5. Repeat this loop until review returns APPROVED.
|
||||||
|
|
||||||
|
**If APPROVED:**
|
||||||
|
|
||||||
|
1. Send `shutdown_request` to all sprint agents.
|
||||||
|
2. Wait for all `shutdown_response` confirmations.
|
||||||
|
3. Call `TeamDelete` to clean up.
|
||||||
|
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
|
||||||
|
merge on main."
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: sprint-status
|
||||||
|
description: >
|
||||||
|
Sprint health check and cleanup sweep. Lists all tickets in the active
|
||||||
|
sprint grouped by status, detects bookkeeping issues (stale tickets,
|
||||||
|
orphan PRs, done-but-open PRs, unassigned work), and shows open work
|
||||||
|
by team. Use when checking sprint progress, before sprint close, or
|
||||||
|
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||||
|
sweep", "what's open", "sprint health".
|
||||||
|
user-invocable: true
|
||||||
|
allowed-tools: Task, Read, Grep, Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sprint Status
|
||||||
|
|
||||||
|
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||||
|
|
||||||
|
When this skill is invoked, spawn a subagent using the Task tool:
|
||||||
|
|
||||||
|
```
|
||||||
|
Task(
|
||||||
|
subagent_type: "general-purpose",
|
||||||
|
model: "haiku",
|
||||||
|
prompt: "Run /sprint-status. Read the skill at
|
||||||
|
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||||
|
(below the --- separator), then execute it.",
|
||||||
|
description: "Sprint status report"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Present the subagent's output to the user verbatim. Do NOT run the
|
||||||
|
workflow yourself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The remainder of this file is the subagent's reference for executing
|
||||||
|
the workflow.
|
||||||
|
|
||||||
|
## Step 1 — Gather data
|
||||||
|
|
||||||
|
Run these two commands in parallel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
db/connectors/sprint sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||||
|
```
|
||||||
|
|
||||||
|
The `sweep` command returns JSON with:
|
||||||
|
- `sprint` — id, name, goal
|
||||||
|
- `progress` — total, done, pct
|
||||||
|
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
|
||||||
|
- `by_team` — per-team counts
|
||||||
|
- `issues` — bookkeeping problems with suggested fix commands
|
||||||
|
|
||||||
|
The `tea pr list` returns open PRs as `#N title` lines.
|
||||||
|
|
||||||
|
## Step 2 — Cross-reference PRs with tickets
|
||||||
|
|
||||||
|
Parse PR head branches from the `tea pr list` output. Known team branches:
|
||||||
|
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||||
|
|
||||||
|
Detect additional issues:
|
||||||
|
|
||||||
|
- **done_team_open_pr**: A team's tickets are all done but an open PR
|
||||||
|
still exists for that team branch.
|
||||||
|
- **orphan_pr**: An open PR exists on a branch that has no tickets in
|
||||||
|
the active sprint.
|
||||||
|
|
||||||
|
Add these to the issues list from step 1.
|
||||||
|
|
||||||
|
## Step 3 — Format output
|
||||||
|
|
||||||
|
Read `references/output-template.md` for the exact format spec.
|
||||||
|
|
||||||
|
Render the report using data from steps 1-2. Key rules:
|
||||||
|
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
|
||||||
|
- Sort tickets within sections by team then ticket ID
|
||||||
|
- Empty sections: show header with "(0)" and "(none)" — no empty table
|
||||||
|
- Bookkeeping Issues: two-column table (Issue, Fix)
|
||||||
|
- Open Work by Team: summary table at the bottom
|
||||||
|
- Issue type labels: `stale_backlog` → "Stale backlog",
|
||||||
|
`unassigned_in_progress` → "Unassigned in_progress",
|
||||||
|
`assigned_but_done` → "Assigned but done",
|
||||||
|
`done_team_open_pr` → "Done team with open PR",
|
||||||
|
`orphan_pr` → "Orphan PR"
|
||||||
|
|
||||||
|
## Step 4 — Suggest actions
|
||||||
|
|
||||||
|
After the formatted report, if there are bookkeeping issues, add a
|
||||||
|
"Suggested fixes" section with the fix command for each issue. Group
|
||||||
|
by issue type for readability.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Sprint Status Output Template
|
||||||
|
|
||||||
|
## Sprint {N}: {Theme} — Status Report
|
||||||
|
|
||||||
|
**Goal:** {goal}
|
||||||
|
**Status:** {status} | {done}/{total} tickets ({pct}%)
|
||||||
|
**Open PRs:** {count} ({branches})
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Completed ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Assigned |
|
||||||
|
|---|------|-------|----------|
|
||||||
|
| #{id} | {team} | {title} | {assigned} |
|
||||||
|
|
||||||
|
### In Review ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | PR |
|
||||||
|
|---|------|-------|----|
|
||||||
|
| #{id} | {team} | {title} | #{pr} |
|
||||||
|
|
||||||
|
### In Progress ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Assigned | Note |
|
||||||
|
|---|------|-------|----------|------|
|
||||||
|
| #{id} | {team} | {title} | {assigned} | |
|
||||||
|
|
||||||
|
### Blocked ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Blocked by |
|
||||||
|
|---|------|-------|------------|
|
||||||
|
| #{id} | {team} | {title} | #{ids} |
|
||||||
|
|
||||||
|
### Backlog ({count})
|
||||||
|
|
||||||
|
| # | Team | Title | Note |
|
||||||
|
|---|------|-------|----|
|
||||||
|
| #{id} | {team} | {title} | not started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bookkeeping Issues
|
||||||
|
|
||||||
|
| Issue | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| {type}: {detail} | `{command}` |
|
||||||
|
|
||||||
|
### Open Work by Team
|
||||||
|
|
||||||
|
| Team | Backlog | In Progress | Review | Blocked | Done |
|
||||||
|
|------|---------|-------------|--------|---------|------|
|
||||||
|
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||||
|
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||||
@@ -10,9 +10,8 @@ allowed-tools: Bash, Read, Grep, Glob
|
|||||||
|
|
||||||
# Ticket Skill
|
# Ticket Skill
|
||||||
|
|
||||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
|
||||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
section. This skill covers the full command reference.
|
||||||
This skill covers the full command reference.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,10 @@ When all Round N tasks are complete:
|
|||||||
|
|
||||||
Wrap-up sequence:
|
Wrap-up sequence:
|
||||||
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
|
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
|
||||||
2. If SI is present, SI creates tickets from decided items
|
2. Qatux creates or updates diagrams (via `/d2-diagram`) for any new D-records produced by the workshop
|
||||||
3. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
|
3. If SI is present, SI creates tickets from decided items
|
||||||
4. TeamDelete to clean up
|
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
|
## Agent Type Reference
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ renderer/output/*.png
|
|||||||
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
||||||
db/commonwealth.db*
|
db/commonwealth.db*
|
||||||
|
|
||||||
|
# Frame0 ID mapping files (ephemeral, per-machine)
|
||||||
|
*.idmap.json
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -6,6 +6,86 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||||
|
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||||
|
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||||
|
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||||
|
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||||
|
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
|
||||||
|
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
|
||||||
|
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||||
|
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||||
|
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||||
|
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||||
|
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
|
||||||
|
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
|
||||||
|
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
|
||||||
|
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
|
||||||
|
- Sprint 16 closed (8/8 done)
|
||||||
|
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
|
||||||
|
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
|
||||||
|
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
|
||||||
|
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
|
||||||
|
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
|
||||||
|
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
|
||||||
|
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||||
|
- DialogueResponse cross-language fixture for GDScript testing
|
||||||
|
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||||
|
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
|
||||||
|
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
|
||||||
|
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
|
||||||
|
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
|
||||||
|
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
|
||||||
|
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
|
||||||
|
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
|
||||||
|
- Weighted selection fallback replaced with unreachable!() — dead code removed
|
||||||
|
- assert!(false) → panic!() in serialization tests (clippy)
|
||||||
|
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||||
|
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||||
|
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||||
|
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||||
|
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||||
|
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||||
|
|
||||||
|
## [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
|
## [v0.1.14] — 2026-02-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
# The Settled Reach
|
# The Settled Reach
|
||||||
|
|
||||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
|
||||||
|
|
||||||
**Official Title:** The Settled Reach (D-021)
|
**Official Title:** The Settled Reach
|
||||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
**Repository name:** settled-reach
|
||||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
client/ # Godot 4 client (D-020)
|
client/ # Godot 4 client
|
||||||
server/ # Rust/bevy_ecs simulation server (D-020)
|
server/ # Rust/bevy_ecs simulation server
|
||||||
tooling/ # Build tools, scripts, asset pipelines
|
tooling/ # Build tools, scripts, asset pipelines
|
||||||
tests/ # Integration and end-to-end tests
|
tests/ # Integration and end-to-end tests
|
||||||
.config/ # Configuration files (linters, formatters, CI)
|
docs/ # Architecture, design, briefings, sprints, workshops
|
||||||
.cache/ # Local caches for testing/linting (gitignored)
|
db/ # Schema + connector scripts (ticket CLI, SQLite, Qdrant)
|
||||||
docs/
|
.claude/ # Agents, skills, rules
|
||||||
discussions/ # Discussion rounds (all rounds archived here per D-022)
|
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
|
||||||
architecture/ # Technical architecture documents
|
|
||||||
design/ # Game design documents
|
|
||||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
|
||||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
|
||||||
db/
|
|
||||||
schema.sql # Database schema
|
|
||||||
connectors/ # Connector scripts for SQLite and Qdrant
|
|
||||||
config.json # Endpoint configuration
|
|
||||||
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
|
|
||||||
sqlite_connector.py # SQLite mini MCP
|
|
||||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
|
||||||
.claude/
|
|
||||||
agents/ # Agent personality files
|
|
||||||
skills/ # Skill definitions
|
|
||||||
decisions/ # Decision domain files (source of truth)
|
|
||||||
README.md # Domain index and query examples
|
|
||||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
|
||||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
|
||||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
|
||||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
|
||||||
process.md # D-004, D-021, D-022
|
|
||||||
questions.md # Q-001 through Q-011
|
|
||||||
rejected.md # R-001 through R-010
|
|
||||||
DECISIONS.md # Redirect to decisions/ directory
|
|
||||||
TEAM.md # Team roster and roles
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Full annotated tree: `.claude/rules/project-structure.md`
|
||||||
|
|
||||||
## DevOps
|
## DevOps
|
||||||
|
|
||||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||||
@@ -53,15 +29,11 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
|||||||
|
|
||||||
### Worktree boundaries
|
### Worktree boundaries
|
||||||
|
|
||||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) has its own worktree. The worktree root IS the git root.
|
||||||
|
|
||||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
- **All work must remain within the git root** unless explicitly instructed otherwise.
|
||||||
|
- All file paths are relative to the worktree root (e.g. `server/src/bridge/types.rs`).
|
||||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
- Do not navigate to or access sibling worktrees (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||||
|
|
||||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
|
||||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
|
||||||
- Do not navigate above the git root unless explicitly instructed.
|
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
@@ -73,109 +45,23 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
|||||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||||
|
|
||||||
### Ticket and database access
|
### CLI tools
|
||||||
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
|
|
||||||
```bash
|
|
||||||
db/connectors/ticket list --sprint 2 --team server
|
|
||||||
db/connectors/ticket show 78
|
|
||||||
db/connectors/ticket sprint --active
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sprint CLI
|
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
|
||||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
|
||||||
```bash
|
|
||||||
db/connectors/sprint status # Current sprint progress
|
|
||||||
db/connectors/sprint status --team server # Team-scoped view
|
|
||||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
|
||||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
|
||||||
db/connectors/sprint start # Activate a planned sprint
|
|
||||||
db/connectors/sprint stop # Complete an active sprint
|
|
||||||
```
|
|
||||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
|
||||||
|
|
||||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
| Tool | Command | Full reference |
|
||||||
```bash
|
|------|---------|----------------|
|
||||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
| Tickets | `db/connectors/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||||
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
| Sprints | `db/connectors/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||||
```
|
| SQL queries | `db/connectors/sqlite-query "SELECT ..."` | — |
|
||||||
|
| SQL writes | `db/connectors/sqlite-exec "UPDATE ..."` | — |
|
||||||
### Qdrant / document search
|
| Doc search | `db/connectors/qdrant-search "query"` | `/docs-search` skill |
|
||||||
```bash
|
| Doc index | `db/connectors/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||||
db/connectors/qdrant-search "asymmetric information design"
|
|
||||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
|
||||||
db/connectors/qdrant-health
|
|
||||||
db/connectors/qdrant-count
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gitea access (tea CLI)
|
|
||||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
|
||||||
|
|
||||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List open PRs
|
|
||||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
|
||||||
|
|
||||||
# View a PR with comments
|
|
||||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
|
||||||
|
|
||||||
# Post a comment on a PR (or issue)
|
|
||||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
|
|
||||||
|
|
||||||
# Approve a PR
|
|
||||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
|
||||||
|
|
||||||
# List issues
|
|
||||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
### File conventions
|
||||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
- 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
|
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||||
- Briefings: one per agent, updated after decision-producing rounds
|
- Briefings: one per agent, updated after decision-producing rounds
|
||||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||||
|
|
||||||
### Commit conventions
|
|
||||||
Use conventional commits with project-specific scopes:
|
|
||||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
|
||||||
|
|
||||||
### Pull requests
|
|
||||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
|
||||||
|
|
||||||
Always provide all required flags to ensure non-interactive execution:
|
|
||||||
```bash
|
|
||||||
tea pr create \
|
|
||||||
--repo jpmschweitzer/settled-reach \
|
|
||||||
--login schweitz \
|
|
||||||
--title "feat(scope): short description" \
|
|
||||||
--description "PR body here" \
|
|
||||||
--base main \
|
|
||||||
--head branch-name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Large content pushes (team pattern)
|
|
||||||
When producing many files (wiki pages, content batches, bulk docs):
|
|
||||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
|
||||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
|
||||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
|
||||||
|
|
||||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
|
||||||
|
|
||||||
### Local services
|
|
||||||
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
|
||||||
- Qdrant: `http://tower-of-joy:6333/`
|
|
||||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
|
||||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
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 \
|
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||||
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
|
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 pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||||
@@ -28,6 +28,7 @@ help:
|
|||||||
@echo " make ci Run full CI pipeline locally"
|
@echo " make ci Run full CI pipeline locally"
|
||||||
@echo " make ci-client Run client CI checks"
|
@echo " make ci-client Run client CI checks"
|
||||||
@echo " make ci-server Run server 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 " make clean Remove build artifacts and caches"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make db-backup Backup shared database to git (main only)"
|
@echo " make db-backup Backup shared database to git (main only)"
|
||||||
@@ -82,7 +83,16 @@ setup-hooks:
|
|||||||
|
|
||||||
# --- Build ---
|
# --- 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:
|
build-server:
|
||||||
cd server && cargo build
|
cd server && cargo build
|
||||||
@@ -94,7 +104,7 @@ build-client:
|
|||||||
# --- Run ---
|
# --- Run ---
|
||||||
|
|
||||||
server:
|
server:
|
||||||
cd server && cargo run
|
cd server && cargo run --bin settled-reach-server
|
||||||
|
|
||||||
client:
|
client:
|
||||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||||
@@ -103,7 +113,7 @@ client:
|
|||||||
game: stop build
|
game: stop build
|
||||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||||
@echo "Starting server..."
|
@echo "Starting server..."
|
||||||
@cd server && cargo run &
|
@cd server && cargo run --bin settled-reach-server &
|
||||||
@sleep 2
|
@sleep 2
|
||||||
@echo "Starting client..."
|
@echo "Starting client..."
|
||||||
@SR_LIVE=1 $(GODOT) --path client
|
@SR_LIVE=1 $(GODOT) --path client
|
||||||
@@ -290,7 +300,7 @@ perf-baseline:
|
|||||||
|
|
||||||
debug-schedule:
|
debug-schedule:
|
||||||
@echo "Dumping bevy_ecs schedule graph..."
|
@echo "Dumping bevy_ecs schedule graph..."
|
||||||
@cd server && cargo run -- --dump-schedule
|
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
|
||||||
|
|
||||||
content-ron:
|
content-ron:
|
||||||
cd tooling/content-converter && cargo build --release
|
cd tooling/content-converter && cargo build --release
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
importer="oggvorbisstr"
|
importer="oggvorbisstr"
|
||||||
type="AudioStreamOggVorbis"
|
type="AudioStreamOggVorbis"
|
||||||
uid="uid://caeuztw9ah5h5l"
|
uid="uid://cow7symyvpmal"
|
||||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|||||||
@@ -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={}
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -43,5 +43,5 @@ passive_opacity: 0.9
|
|||||||
# ENTRY TIMING
|
# ENTRY TIMING
|
||||||
# All entry types share the same lifetime and fade duration.
|
# All entry types share the same lifetime and fade duration.
|
||||||
# ============================================================
|
# ============================================================
|
||||||
entry_lifetime_seconds: 15.0
|
entry_lifetime_seconds: 45.0
|
||||||
entry_fade_seconds: 3.0
|
entry_fade_seconds: 5.0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,7 +44,7 @@ const FILTER_CUTOFF_DEFAULT := 20500.0
|
|||||||
# --- D-073: Zone crossfade constants ---
|
# --- D-073: Zone crossfade constants ---
|
||||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||||
|
|
||||||
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
|
# 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) —
|
# 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
|
# 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
|
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||||
@@ -112,15 +112,15 @@ func _setup_buses() -> void:
|
|||||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||||
|
|
||||||
func _scan_registry() -> void:
|
func _scan_registry() -> void:
|
||||||
_scan_dir("res://audio/")
|
_scan_dir("res://assets/audio/")
|
||||||
print("AudioManager: %d assets registered" % _registry.size())
|
print("AudioManager: %d assets registered" % _registry.size())
|
||||||
|
|
||||||
|
|
||||||
func _scan_dir(path: String) -> void:
|
func _scan_dir(path: String) -> void:
|
||||||
var dir := DirAccess.open(path)
|
var dir := DirAccess.open(path)
|
||||||
if dir == null:
|
if dir == null:
|
||||||
if path == "res://audio/":
|
if path == "res://assets/audio/":
|
||||||
print("AudioManager: res://audio/ not found — all play methods no-op")
|
print("AudioManager: res://assets/audio/ not found — all play methods no-op")
|
||||||
return
|
return
|
||||||
dir.list_dir_begin()
|
dir.list_dir_begin()
|
||||||
var file_name := dir.get_next()
|
var file_name := dir.get_next()
|
||||||
@@ -197,7 +197,7 @@ func stop_all_loops() -> void:
|
|||||||
|
|
||||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||||
# Maps server-sent sound event_type strings to audio asset keys.
|
# Maps server-sent sound event_type strings to audio asset keys.
|
||||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
# 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).
|
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
|
|||||||
# refined when the server assigns explicit player entity IDs).
|
# refined when the server assigns explicit player entity IDs).
|
||||||
var player_entity_id: int = 1
|
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)
|
# v4 fields (#404/#405)
|
||||||
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
||||||
|
|
||||||
@@ -236,16 +240,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
|||||||
medium_sound_events = []
|
medium_sound_events = []
|
||||||
close_sound_events = []
|
close_sound_events = []
|
||||||
|
|
||||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
# D-073 (#529): O(1) zone_id lookup. Build coord→tile dict from member visible_tiles
|
||||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
# (populated above from either "tiles" test-mode key or "visible_tiles" live key).
|
||||||
# positional indexing — use the same tile iteration below instead.
|
# Must use the member var, not snapshot.visible_tiles, so test mode is covered.
|
||||||
current_zone_id = ""
|
var _tile_by_coord: Dictionary = {}
|
||||||
var _px := int(player_position.x)
|
for vtile in visible_tiles:
|
||||||
var _py := int(player_position.y)
|
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||||
for _ztile in visible_tiles:
|
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
||||||
current_zone_id = _ztile.get("zone_id", "")
|
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||||
break
|
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||||
|
|
||||||
# v2: visible_tiles with visibility sectors
|
# v2: visible_tiles with visibility sectors
|
||||||
# Derives visible_positions when not explicitly provided (real server mode)
|
# Derives visible_positions when not explicitly provided (real server mode)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://rqfw0ycyb4c6
|
||||||
@@ -91,14 +91,19 @@ const FACING_INDICATOR_SIZE: float = 6.0
|
|||||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||||
|
|
||||||
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
||||||
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
|
# Raised from D-076 default (640px) to 1200px for readability.
|
||||||
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
||||||
# two columns of text comfortably, leaves world game visible alongside.
|
# two columns of text comfortably, leaves world game visible alongside.
|
||||||
const DIALOGUE_MAX_WIDTH: int = 640
|
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||||
|
|
||||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
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
|
# #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_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var _last_dialogue_tick: int = -1
|
|||||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
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 _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 _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 _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
|
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||||
|
|
||||||
@@ -35,13 +35,10 @@ const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before Listenin
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
print("The Settled Reach — client initialized")
|
print("The Settled Reach — client initialized")
|
||||||
|
|
||||||
# Disable camera smoothing during init. Camera2D's position_smoothing
|
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
||||||
# lerps an internal smoothed_camera_pos toward global_position each frame.
|
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
|
||||||
# That smoothed position initializes at (0,0) — the Camera2D's default in
|
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
|
||||||
# the .tscn. Even after we set global_position to the player coords,
|
# would conflict because we'd be setting global_position to the target every frame.
|
||||||
# smoothing causes the viewport to still show (0,0) on the first rendered
|
|
||||||
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
|
|
||||||
# uses global_position directly. Re-enabled in _process() after anchor.
|
|
||||||
camera.position_smoothing_enabled = false
|
camera.position_smoothing_enabled = false
|
||||||
|
|
||||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||||
@@ -50,7 +47,7 @@ func _ready() -> void:
|
|||||||
# Camera anchor: snap to player position before the first frame renders.
|
# Camera anchor: snap to player position before the first frame renders.
|
||||||
# In test mode poll_snapshot() returns synchronously — position is set
|
# In test mode poll_snapshot() returns synchronously — position is set
|
||||||
# immediately. In live mode the snapshot isn't available yet — _process
|
# 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()
|
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||||
if first_snapshot != null:
|
if first_snapshot != null:
|
||||||
GameState.apply_snapshot(first_snapshot)
|
GameState.apply_snapshot(first_snapshot)
|
||||||
@@ -70,7 +67,7 @@ func _ready() -> void:
|
|||||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
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
|
# Main game loop: poll snapshot, apply state, flush input
|
||||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||||
if snapshot != null:
|
if snapshot != null:
|
||||||
@@ -159,23 +156,18 @@ func _process(_delta: float) -> void:
|
|||||||
_consume_conversation_ended()
|
_consume_conversation_ended()
|
||||||
_consume_dialogue_response()
|
_consume_dialogue_response()
|
||||||
|
|
||||||
# Track camera to player position every frame (D-015: locked, no panning)
|
# 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:
|
if _camera_anchored:
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
var target := GameState.player_position * Constants.TILE_SIZE
|
||||||
|
|
||||||
# Re-enable smoothing after the first anchored frame. The frame that just
|
|
||||||
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
|
||||||
# turn smoothing back on and sync its internal state so subsequent frames
|
|
||||||
# get smooth camera tracking during gameplay.
|
|
||||||
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
|
||||||
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
|
||||||
# re-enable block in the same _process() call undoing the snap.
|
|
||||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
|
||||||
if _teleport_in_progress:
|
if _teleport_in_progress:
|
||||||
|
camera.global_position = target
|
||||||
_teleport_in_progress = false
|
_teleport_in_progress = false
|
||||||
else:
|
else:
|
||||||
camera.position_smoothing_enabled = true
|
var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta)
|
||||||
camera.reset_smoothing()
|
camera.global_position = camera.global_position.lerp(target, weight)
|
||||||
|
|
||||||
# Send queued input to simulation
|
# Send queued input to simulation
|
||||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||||
@@ -250,22 +242,16 @@ func _play_close_sound_events() -> void:
|
|||||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||||
# enters the cognitive delay recognition queue for the first time.
|
# enters the cognitive delay recognition queue for the first time.
|
||||||
# "The chime marks the character's attention shifting" (D-067).
|
# "The chime marks the character's attention shifting" (D-067).
|
||||||
# Entities that complete recognition (leave pending_recognitions) are removed
|
# IDs persist for the session — one chime per entity, no re-trigger on
|
||||||
# from _known_recognition_ids so they can chime again if re-encountered.
|
# fog oscillation or server re-send. Cleared on room change (teleport).
|
||||||
func _play_recognition_chimes() -> void:
|
func _play_recognition_chimes() -> void:
|
||||||
var active_ids: Dictionary = {}
|
|
||||||
for rec in GameState.pending_recognitions:
|
for rec in GameState.pending_recognitions:
|
||||||
if not rec is Dictionary or not rec.has("entity_id"):
|
if not rec is Dictionary or not rec.has("entity_id"):
|
||||||
continue
|
continue
|
||||||
var eid: int = rec.entity_id
|
var eid: int = rec.entity_id
|
||||||
active_ids[eid] = true
|
|
||||||
if not _known_recognition_ids.has(eid):
|
if not _known_recognition_ids.has(eid):
|
||||||
_known_recognition_ids[eid] = true
|
_known_recognition_ids[eid] = true
|
||||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||||
# Expire IDs no longer in the recognition queue
|
|
||||||
for eid in _known_recognition_ids.keys():
|
|
||||||
if not active_ids.has(eid):
|
|
||||||
_known_recognition_ids.erase(eid)
|
|
||||||
|
|
||||||
|
|
||||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||||
@@ -358,11 +344,16 @@ func _consume_conversation_ended() -> void:
|
|||||||
|
|
||||||
|
|
||||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
# #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:
|
func _consume_dialogue_response() -> void:
|
||||||
if GameState.dialogue_response == null or not dialogue_box:
|
if GameState.dialogue_response == null or not dialogue_box:
|
||||||
return
|
return
|
||||||
var dr: Dictionary = GameState.dialogue_response
|
var dr: Dictionary = GameState.dialogue_response
|
||||||
dialogue_box.append_dialogue_response(_last_dialogue_npc_name, dr.get("text", ""))
|
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
|
GameState.dialogue_response = null
|
||||||
|
|
||||||
|
|
||||||
@@ -434,11 +425,8 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
|||||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||||
func _teleport_transition() -> void:
|
func _teleport_transition() -> void:
|
||||||
# Snap camera: disable smoothing, force re-anchor.
|
# Set teleport flag — the camera tracking block in _process() will snap
|
||||||
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
# to the player's new position this frame (no lerp). Flag clears after snap.
|
||||||
# re-enable block at the bottom of _process() doesn't undo the snap.
|
|
||||||
camera.position_smoothing_enabled = false
|
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
|
||||||
_camera_anchored = true
|
_camera_anchored = true
|
||||||
_teleport_in_progress = true
|
_teleport_in_progress = true
|
||||||
|
|
||||||
@@ -446,6 +434,7 @@ func _teleport_transition() -> void:
|
|||||||
GameState.current_monologue = null
|
GameState.current_monologue = null
|
||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
GameState.dialogue_active = false
|
GameState.dialogue_active = false
|
||||||
|
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
dialogue_box.hide_dialogue()
|
dialogue_box.hide_dialogue()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class_name Protocol
|
|||||||
|
|
||||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||||
## Reject snapshots where version != this value.
|
## Reject snapshots where version != this value.
|
||||||
const PROTOCOL_VERSION: int = 9
|
const PROTOCOL_VERSION: int = 13
|
||||||
|
|
||||||
|
|
||||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class_name EntityRenderer
|
|||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
# Entity renderer — manages entity sprites under the Entities node
|
# Entity renderer — manages entity sprites under the Entities node
|
||||||
# Creates/updates/removes ColorRect children based on entity data
|
# Creates/updates/removes Sprite2D children based on entity data
|
||||||
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
||||||
#
|
#
|
||||||
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
||||||
@@ -11,10 +11,15 @@ extends Node2D
|
|||||||
#
|
#
|
||||||
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
||||||
# color from RelationshipState via the knowledge graph.
|
# color from RelationshipState via the knowledge graph.
|
||||||
|
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
|
||||||
|
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
|
||||||
|
|
||||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||||
const ENTITY_SIZE: int = 24
|
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
|
||||||
const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile
|
const ENTITY_WIDTH: int = 24
|
||||||
|
const ENTITY_HEIGHT: int = 32
|
||||||
|
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
|
||||||
|
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
|
||||||
|
|
||||||
# Lerp speed — framerate-independent exponential smoothing.
|
# Lerp speed — framerate-independent exponential smoothing.
|
||||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||||
@@ -24,7 +29,8 @@ const LERP_SPEED: float = 12.0
|
|||||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
|
||||||
|
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
|
||||||
|
|
||||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||||
const COLOR_FADE_DURATION: float = 0.5
|
const COLOR_FADE_DURATION: float = 0.5
|
||||||
@@ -45,7 +51,7 @@ func _process(delta: float) -> void:
|
|||||||
if not node.position.is_equal_approx(target):
|
if not node.position.is_equal_approx(target):
|
||||||
node.position = node.position.lerp(target, weight)
|
node.position = node.position.lerp(target, weight)
|
||||||
|
|
||||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
|
||||||
var finished_ids: Array = []
|
var finished_ids: Array = []
|
||||||
for entity_id in _entity_tweens.keys():
|
for entity_id in _entity_tweens.keys():
|
||||||
if not entity_nodes.has(entity_id):
|
if not entity_nodes.has(entity_id):
|
||||||
@@ -54,8 +60,9 @@ func _process(delta: float) -> void:
|
|||||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||||
tween_data.elapsed += delta
|
tween_data.elapsed += delta
|
||||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
|
||||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
if node_s:
|
||||||
|
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
|
||||||
if t >= 1.0:
|
if t >= 1.0:
|
||||||
finished_ids.append(entity_id)
|
finished_ids.append(entity_id)
|
||||||
for eid in finished_ids:
|
for eid in finished_ids:
|
||||||
@@ -89,15 +96,26 @@ func update_entities(entities: Array) -> void:
|
|||||||
for entity_id in ids_to_remove:
|
for entity_id in ids_to_remove:
|
||||||
_remove_entity_node(entity_id)
|
_remove_entity_node(entity_id)
|
||||||
|
|
||||||
# Create a new entity node with D-033 color and optional facing indicator
|
|
||||||
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)
|
|
||||||
|
|
||||||
# D-033 color by relationship (#521)
|
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
|
||||||
entity_node.color = _color_for_kind(entity_data)
|
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||||
|
var entity_node := Sprite2D.new()
|
||||||
|
entity_node.name = "Entity_" + str(entity_id)
|
||||||
|
# centered=false: top-left origin aligns with tile grid.
|
||||||
|
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
|
||||||
|
entity_node.centered = false
|
||||||
|
entity_node.scale = Vector2(0.5, 0.5)
|
||||||
|
|
||||||
|
# Load sprite for current facing direction
|
||||||
|
var direction := _entity_direction(entity_id, entity_data)
|
||||||
|
_entity_facing[entity_id] = direction
|
||||||
|
var tex := _load_sprite_texture(direction)
|
||||||
|
if tex == null:
|
||||||
|
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
|
||||||
|
entity_node.texture = tex
|
||||||
|
|
||||||
|
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
|
||||||
|
entity_node.self_modulate = _color_for_kind(entity_data)
|
||||||
|
|
||||||
add_child(entity_node)
|
add_child(entity_node)
|
||||||
entity_nodes[entity_id] = entity_node
|
entity_nodes[entity_id] = entity_node
|
||||||
@@ -110,61 +128,66 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
|||||||
# Snap to initial position (no lerp on first appearance)
|
# Snap to initial position (no lerp on first appearance)
|
||||||
if entity_data.has("x") and entity_data.has("y"):
|
if entity_data.has("x") and entity_data.has("y"):
|
||||||
var target := Vector2(
|
var target := Vector2(
|
||||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
|
||||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
entity_node.position = target
|
entity_node.position = target
|
||||||
_entity_targets[entity_id] = target
|
_entity_targets[entity_id] = target
|
||||||
|
|
||||||
_update_entity_node(entity_id, entity_data)
|
_update_entity_node(entity_id, entity_data)
|
||||||
|
|
||||||
# Update an existing entity node (target position, visibility dimming, facing)
|
|
||||||
|
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
|
||||||
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||||
if not entity_nodes.has(entity_id):
|
if not entity_nodes.has(entity_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
var entity_node = entity_nodes[entity_id]
|
var node = entity_nodes[entity_id]
|
||||||
|
|
||||||
# Update target position — the lerp in _process() will smoothly move there.
|
# Update target position — the lerp in _process() will smoothly move there.
|
||||||
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
||||||
if entity_data.has("x") and entity_data.has("y"):
|
if entity_data.has("x") and entity_data.has("y"):
|
||||||
_entity_targets[entity_id] = Vector2(
|
_entity_targets[entity_id] = Vector2(
|
||||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
|
||||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
|
|
||||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
# #540: Update sprite texture when facing direction changes
|
||||||
|
var new_dir := _entity_direction(entity_id, entity_data)
|
||||||
|
if new_dir != _entity_facing.get(entity_id, ""):
|
||||||
|
_entity_facing[entity_id] = new_dir
|
||||||
|
var new_tex := _load_sprite_texture(new_dir)
|
||||||
|
if new_tex != null:
|
||||||
|
(node as Sprite2D).texture = new_tex
|
||||||
|
# null: keep previous texture rather than going invisible mid-game
|
||||||
|
|
||||||
|
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
|
||||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
|
||||||
if new_rel != old_rel:
|
|
||||||
_entity_relationships[entity_id] = new_rel
|
_entity_relationships[entity_id] = new_rel
|
||||||
var new_color := _color_for_kind(entity_data)
|
|
||||||
_entity_tweens[entity_id] = {
|
_entity_tweens[entity_id] = {
|
||||||
"from": entity_node.color,
|
"from": (node as Sprite2D).self_modulate,
|
||||||
"target": new_color,
|
"target": _color_for_kind(entity_data),
|
||||||
"elapsed": 0.0,
|
"elapsed": 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
# D-015: Peripheral vision dimming via modulate.a.
|
||||||
# are compositionally independent — both can change simultaneously without
|
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
|
||||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
|
||||||
|
|
||||||
# v2: Peripheral vision dimming (D-015)
|
|
||||||
# null visibility (v1 backward compat) defaults to full alpha
|
|
||||||
var visibility: Variant = entity_data.get("visibility")
|
var visibility: Variant = entity_data.get("visibility")
|
||||||
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
|
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
|
||||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
if not is_equal_approx(node.modulate.a, target_alpha):
|
||||||
entity_node.modulate.a = target_alpha
|
node.modulate.a = target_alpha
|
||||||
|
|
||||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||||
if entity_id == GameState.player_entity_id:
|
if entity_id == GameState.player_entity_id:
|
||||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
var indicator = node.get_node_or_null("FacingIndicator")
|
||||||
if indicator != null:
|
if indicator != null:
|
||||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||||
# Rotate from North basis: add PI/2 to convert.
|
# Rotate from North basis: add PI/2 to convert.
|
||||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||||
|
|
||||||
|
|
||||||
# Remove an entity node
|
# Remove an entity node
|
||||||
func _remove_entity_node(entity_id: int) -> void:
|
func _remove_entity_node(entity_id: int) -> void:
|
||||||
if not entity_nodes.has(entity_id):
|
if not entity_nodes.has(entity_id):
|
||||||
@@ -176,13 +199,49 @@ func _remove_entity_node(entity_id: int) -> void:
|
|||||||
_entity_targets.erase(entity_id)
|
_entity_targets.erase(entity_id)
|
||||||
_entity_relationships.erase(entity_id)
|
_entity_relationships.erase(entity_id)
|
||||||
_entity_tweens.erase(entity_id)
|
_entity_tweens.erase(entity_id)
|
||||||
|
_entity_facing.erase(entity_id)
|
||||||
|
|
||||||
|
|
||||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||||
return Constants.color_for_entity_kind(entity_data)
|
return Constants.color_for_entity_kind(entity_data)
|
||||||
|
|
||||||
# Add a facing direction indicator triangle to the player entity
|
|
||||||
func _add_facing_indicator(parent_node: Control) -> void:
|
# #540: Map entity to current 4-direction sprite key.
|
||||||
|
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
|
||||||
|
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
|
||||||
|
if entity_id == GameState.player_entity_id:
|
||||||
|
return _octant_to_direction(GameState.player_facing)
|
||||||
|
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
|
||||||
|
return "south"
|
||||||
|
|
||||||
|
|
||||||
|
# Map 8-direction octant string to nearest 4-direction sprite key.
|
||||||
|
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
|
||||||
|
static func _octant_to_direction(octant: String) -> String:
|
||||||
|
match octant:
|
||||||
|
"North", "Northwest": return "north"
|
||||||
|
"Northeast", "East": return "east"
|
||||||
|
"Southeast", "South": return "south"
|
||||||
|
"Southwest", "West": return "west"
|
||||||
|
_:
|
||||||
|
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
|
||||||
|
return "south"
|
||||||
|
|
||||||
|
|
||||||
|
# Load the sprite texture for the given 4-direction key.
|
||||||
|
# Falls back to null with a push_warning if the asset is missing.
|
||||||
|
static func _load_sprite_texture(direction: String) -> Texture2D:
|
||||||
|
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||||
|
if ResourceLoader.exists(path):
|
||||||
|
return load(path) as Texture2D
|
||||||
|
push_warning("EntityRenderer: sprite not found: %s" % path)
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
# Add a facing direction indicator triangle to the player entity.
|
||||||
|
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
|
||||||
|
func _add_facing_indicator(parent_node: Node2D) -> void:
|
||||||
var indicator := Polygon2D.new()
|
var indicator := Polygon2D.new()
|
||||||
indicator.name = "FacingIndicator"
|
indicator.name = "FacingIndicator"
|
||||||
var s := Constants.FACING_INDICATOR_SIZE
|
var s := Constants.FACING_INDICATOR_SIZE
|
||||||
@@ -194,6 +253,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
|||||||
Vector2(s * 0.6, -offset + s * 0.4),
|
Vector2(s * 0.6, -offset + s * 0.4),
|
||||||
])
|
])
|
||||||
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
||||||
# Position at center of parent ColorRect — rotation around this point
|
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
|
||||||
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
# Indicator rotates around this point to track player facing direction.
|
||||||
|
indicator.position = Vector2(32.0, 32.0)
|
||||||
parent_node.add_child(indicator)
|
parent_node.add_child(indicator)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cb10ir0idsr0g
|
||||||
@@ -12,6 +12,7 @@ extends TileMapLayer
|
|||||||
# (4,0) = reset_plate — amber (#502)
|
# (4,0) = reset_plate — amber (#502)
|
||||||
|
|
||||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
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 }
|
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
|
# Update tiles from snapshot data
|
||||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
# 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:
|
func update_tiles(tiles: Array) -> void:
|
||||||
if not _initialized:
|
if not _initialized:
|
||||||
return
|
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"):
|
if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"):
|
||||||
continue
|
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
|
var tile_type_str: String = tile_data.type
|
||||||
if not TILE_TYPE_MAP.has(tile_type_str):
|
if not TILE_TYPE_MAP.has(tile_type_str):
|
||||||
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func _draw() -> void:
|
|||||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
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 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 box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||||
var line_count := max(left_lines.size(), right_lines.size())
|
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
|
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||||
|
|
||||||
# Background
|
# Background
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c55ow14m345vv
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://duvqpvheku2j2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d30by4d62g7ts
|
||||||
@@ -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()
|
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 ---
|
# --- Camera behavior across frames ---
|
||||||
|
|
||||||
func test_camera_tracks_player_after_process() -> void:
|
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)
|
assert_that(camera.global_position).is_equal(expected)
|
||||||
|
|
||||||
|
|
||||||
func test_camera_smoothing_reenabled_after_process() -> void:
|
func test_camera_lerps_toward_player_movement() -> void:
|
||||||
# After the first anchored frame, smoothing should be back on for gameplay.
|
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
|
||||||
var scene := load("res://scenes/main.tscn")
|
# After one 16ms frame the camera should be partway between old and new position.
|
||||||
_instance = scene.instantiate()
|
|
||||||
auto_free(_instance)
|
|
||||||
add_child(_instance)
|
|
||||||
|
|
||||||
_instance._process(0.016)
|
|
||||||
|
|
||||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
|
||||||
assert_that(camera.position_smoothing_enabled).is_true()
|
|
||||||
|
|
||||||
|
|
||||||
func test_camera_follows_player_movement() -> void:
|
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := load("res://scenes/main.tscn")
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
|
|
||||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
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
|
# Move player north via SimBridge test mode
|
||||||
SimBridge._test_input_queue.append("MoveNorth")
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
_instance._process(0.016)
|
_instance._process(0.016)
|
||||||
|
|
||||||
# Camera should have moved with the player
|
var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288)
|
||||||
assert_that(camera.global_position.y < initial_pos.y).is_true()
|
# Camera should have moved north (lower y) but NOT reached the target yet
|
||||||
assert_that(camera.global_position).is_equal(
|
assert_that(camera.global_position.y).is_less(initial_pos.y)
|
||||||
GameState.player_position * Constants.TILE_SIZE)
|
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)
|
renderer.update_entities(entity)
|
||||||
var node = renderer.entity_nodes[10]
|
var node = renderer.entity_nodes[10]
|
||||||
var expected := Vector2(
|
var expected := Vector2(
|
||||||
floorf(8.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
|
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
assert_that(node.position).override_failure_message(
|
assert_that(node.position).override_failure_message(
|
||||||
"Entity should snap to position on first appear (no lerp)"
|
"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)
|
renderer._process(0.016)
|
||||||
var after_pos: Vector2 = node.position
|
var after_pos: Vector2 = node.position
|
||||||
var target := Vector2(
|
var target := Vector2(
|
||||||
floorf(6.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
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
# Position should have moved toward target (x increased)
|
# Position should have moved toward target (x increased)
|
||||||
assert_that(after_pos.x > start_pos.x).override_failure_message(
|
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}}]
|
"kind": {"variant": "Npc", "data": null}}]
|
||||||
renderer.update_entities(entity_moved)
|
renderer.update_entities(entity_moved)
|
||||||
var target := Vector2(
|
var target := Vector2(
|
||||||
floorf(7.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
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
||||||
for i in 20:
|
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
|
var small_progress: float = small_node.position.x - small_start
|
||||||
# Reset position for large delta test
|
# Reset position for large delta test
|
||||||
small_node.position = Vector2(
|
small_node.position = Vector2(
|
||||||
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
|
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
)
|
)
|
||||||
# Large delta step
|
# Large delta step
|
||||||
var large_start: float = small_node.position.x
|
var large_start: float = small_node.position.x
|
||||||
|
|||||||
@@ -135,43 +135,43 @@ func test_npc_uses_relationship_color_unknown() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
# Unknown -> teal (both Phase 1 and Phase 2 produce the same result)
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_npc_uses_relationship_color_friendly() -> void:
|
func test_npc_uses_relationship_color_friendly() -> void:
|
||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
var entities: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
push_warning("TestColorShift: entity renderer not yet using relationship for color — awaiting #521")
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_FRIENDLY)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_npc_uses_relationship_color_poi() -> void:
|
func test_npc_uses_relationship_color_poi() -> void:
|
||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
var entities: Array = [_make_entity(2, "Npc", "PersonOfInterest")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_POI)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_POI)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_npc_uses_relationship_color_hostile() -> void:
|
func test_npc_uses_relationship_color_hostile() -> void:
|
||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
var entities: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_player_color_ignores_relationship() -> void:
|
func test_player_color_ignores_relationship() -> void:
|
||||||
@@ -179,8 +179,8 @@ func test_player_color_ignores_relationship() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
var entities: Array = [_make_entity(1, "Player", "Hostile")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[1] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[1] as Sprite2D
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_object_color_ignores_relationship() -> void:
|
func test_object_color_ignores_relationship() -> void:
|
||||||
@@ -188,8 +188,8 @@ func test_object_color_ignores_relationship() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
var entities: Array = [_make_entity(3, "Object", "Friendly")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[3] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[3] as Sprite2D
|
||||||
assert_that(node.color).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
@@ -204,20 +204,20 @@ func test_color_shift_not_instant() -> void:
|
|||||||
var renderer: Node2D = _make_entity_renderer()
|
var renderer: Node2D = _make_entity_renderer()
|
||||||
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
var entities_before: Array = [_make_entity(2, "Npc", "Friendly")]
|
||||||
renderer.update_entities(entities_before)
|
renderer.update_entities(entities_before)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
if not _entity_uses_relationship(renderer):
|
if not _entity_uses_relationship(renderer):
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
# Change relationship to Hostile
|
# Change relationship to Hostile
|
||||||
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
var entities_after: Array = [_make_entity(2, "Npc", "Hostile")]
|
||||||
renderer.update_entities(entities_after)
|
renderer.update_entities(entities_after)
|
||||||
# Immediately after update, color should NOT yet be the target
|
# Immediately after update, self_modulate should NOT yet be the target
|
||||||
var color_after_immediate: Color = node.color
|
var color_after_immediate: Color = node.self_modulate
|
||||||
if not _renderer_has_tween_support(renderer):
|
if not _renderer_has_tween_support(renderer):
|
||||||
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
push_warning("TestColorShift: tween on relationship change not implemented yet — awaiting #521")
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
return
|
return
|
||||||
# The color should NOT be exactly the target yet (tween in progress)
|
# The self_modulate should NOT be exactly the target yet (tween in progress)
|
||||||
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
assert_that(color_after_immediate != Constants.ENTITY_COLOR_HOSTILE).is_true()
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
@@ -236,8 +236,8 @@ func test_color_shift_reaches_target() -> void:
|
|||||||
while elapsed < 1.5:
|
while elapsed < 1.5:
|
||||||
renderer._process(1.0 / 60.0)
|
renderer._process(1.0 / 60.0)
|
||||||
elapsed += 1.0 / 60.0
|
elapsed += 1.0 / 60.0
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
func test_color_shift_mid_transition_retrigger() -> void:
|
func test_color_shift_mid_transition_retrigger() -> void:
|
||||||
@@ -263,8 +263,8 @@ func test_color_shift_mid_transition_retrigger() -> void:
|
|||||||
while elapsed < 1.0:
|
while elapsed < 1.0:
|
||||||
renderer._process(1.0 / 60.0)
|
renderer._process(1.0 / 60.0)
|
||||||
elapsed += 1.0 / 60.0
|
elapsed += 1.0 / 60.0
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
assert_that(node.color.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
assert_that(node.self_modulate.is_equal_approx(Constants.ENTITY_COLOR_HOSTILE)).is_true()
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
@@ -275,10 +275,10 @@ func test_color_shift_same_relationship_no_tween() -> void:
|
|||||||
return
|
return
|
||||||
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
var entities: Array = [_make_entity(2, "Npc", "Unknown")]
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var node: ColorRect = renderer.entity_nodes[2] as ColorRect
|
var node: Sprite2D = renderer.entity_nodes[2] as Sprite2D
|
||||||
var color_first: Color = node.color
|
var color_first: Color = node.self_modulate
|
||||||
renderer.update_entities(entities)
|
renderer.update_entities(entities)
|
||||||
var color_second: Color = node.color
|
var color_second: Color = node.self_modulate
|
||||||
assert_that(color_first).is_equal(color_second)
|
assert_that(color_first).is_equal(color_second)
|
||||||
renderer.queue_free()
|
renderer.queue_free()
|
||||||
|
|
||||||
@@ -354,10 +354,12 @@ func _entity_uses_relationship(renderer: Node2D) -> bool:
|
|||||||
renderer.update_entities([friendly, hostile])
|
renderer.update_entities([friendly, hostile])
|
||||||
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
if not renderer.entity_nodes.has(10) or not renderer.entity_nodes.has(11):
|
||||||
return false
|
return false
|
||||||
var f_node: ColorRect = renderer.entity_nodes[10] as ColorRect
|
var f_node: Sprite2D = renderer.entity_nodes[10] as Sprite2D
|
||||||
var h_node: ColorRect = renderer.entity_nodes[11] as ColorRect
|
var h_node: Sprite2D = renderer.entity_nodes[11] as Sprite2D
|
||||||
var f_color: Color = f_node.color
|
if not f_node or not h_node:
|
||||||
var h_color: Color = h_node.color
|
return false
|
||||||
|
var f_color: Color = f_node.self_modulate
|
||||||
|
var h_color: Color = h_node.self_modulate
|
||||||
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
var uses_rel: bool = not f_color.is_equal_approx(h_color)
|
||||||
renderer.update_entities([])
|
renderer.update_entities([])
|
||||||
return uses_rel
|
return uses_rel
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://qv808kduvlbk
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmalit26eiwlh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://6q82kiijx7ml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8ng0ubdfumht
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bn4a46egt2uol
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
## Sprint 15 — Smooth camera movement tests (#117)
|
||||||
|
## Validates exponential lerp, teleport snap, and configurable smoothing.
|
||||||
|
## Spec: D-015 (camera locked, fixed-north), #117 (interpolated tracking).
|
||||||
|
class_name TestSmoothCameraSprint15
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.current_tick = 0
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.current_monologue = null
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
if _instance and is_instance_valid(_instance):
|
||||||
|
_instance.queue_free()
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
|
||||||
|
# --- Configurable smoothing constant ---
|
||||||
|
|
||||||
|
func test_camera_smoothing_speed_constant_defined() -> void:
|
||||||
|
# #117: CAMERA_SMOOTHING_SPEED must be declared in Constants (configurable).
|
||||||
|
assert_that(Constants.CAMERA_SMOOTHING_SPEED > 0.0).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_smoothing_speed_constant_reasonable() -> void:
|
||||||
|
# #117: Speed should produce smooth-but-responsive feel (2.0–20.0 range).
|
||||||
|
assert_that(
|
||||||
|
Constants.CAMERA_SMOOTHING_SPEED >= 2.0 and Constants.CAMERA_SMOOTHING_SPEED <= 20.0
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Manual lerp, no Godot built-in smoothing ---
|
||||||
|
|
||||||
|
func test_godot_smoothing_disabled_at_ready() -> void:
|
||||||
|
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.position_smoothing_enabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||||
|
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.position_smoothing_enabled).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Interpolated tracking (no snap) ---
|
||||||
|
|
||||||
|
func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||||
|
# #117: When player moves, camera should lerp (not snap) to new position.
|
||||||
|
# After 1 frame at ~60fps, camera should be partway there — not at target.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
var start_y := camera.global_position.y # anchored at player (10,10) → 320px
|
||||||
|
|
||||||
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# Player moved to (10,9) → target_y = 288. Camera should be between 288 and 320.
|
||||||
|
var target_y: float = GameState.player_position.y * Constants.TILE_SIZE
|
||||||
|
assert_that(camera.global_position.y < start_y).is_true()
|
||||||
|
assert_that(camera.global_position.y > target_y).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||||
|
# #117: After enough frames the camera should be within 1px of target.
|
||||||
|
# At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
|
_instance._process(0.016) # trigger the move, get new player position
|
||||||
|
|
||||||
|
var target := GameState.player_position * Constants.TILE_SIZE
|
||||||
|
|
||||||
|
# Run 120 frames (~2s at 60fps) — converges within 1px for any speed ≥ 2.0
|
||||||
|
for i in range(120):
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
var dist := camera.global_position.distance_to(target)
|
||||||
|
assert_that(dist < 1.0).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_stationary_player_no_drift() -> void:
|
||||||
|
# #117: When player is stationary, camera should not drift (lerp to same point).
|
||||||
|
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
|
||||||
|
|
||||||
|
# Run several frames with no movement
|
||||||
|
for i in range(10):
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# Camera should still be at anchored position (target = same point).
|
||||||
|
# Use distance check — lerp toward same point may introduce float rounding.
|
||||||
|
assert_that(camera.global_position.distance_to(initial_pos) < 0.01).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Teleport snap ---
|
||||||
|
|
||||||
|
func test_teleport_snaps_camera_immediately() -> void:
|
||||||
|
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
|
||||||
|
# Manually displace camera, set the flag, call _process — camera should snap to target.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
|
||||||
|
# Displace camera from its anchored position
|
||||||
|
camera.global_position = Vector2(0, 0)
|
||||||
|
# Set teleport flag — next _process() should snap to player target
|
||||||
|
_instance._camera_anchored = true
|
||||||
|
_instance._teleport_in_progress = true
|
||||||
|
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# Camera must now be exactly at player position (snapshot puts player at 10,10 → 320,320)
|
||||||
|
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||||
|
assert_that(camera.global_position).is_equal(expected)
|
||||||
|
|
||||||
|
|
||||||
|
func test_teleport_flag_cleared_after_snap() -> void:
|
||||||
|
# #117: _teleport_in_progress must be false after the snap frame.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
_instance._camera_anchored = true
|
||||||
|
_instance._teleport_in_progress = true
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
assert_that(_instance._teleport_in_progress).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_camera_resumes_lerp_after_teleport() -> void:
|
||||||
|
# #117: Frame after teleport snap must resume lerp (not continue snapping).
|
||||||
|
# After teleport flag clears, any position delta produces lerp movement.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
# Frame 1: teleport snap — camera displaced, flag set, expect snap
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
camera.global_position = Vector2(0, 0)
|
||||||
|
_instance._camera_anchored = true
|
||||||
|
_instance._teleport_in_progress = true
|
||||||
|
_instance._process(0.016)
|
||||||
|
# After snap: camera at player position (10,10) = (320, 320)
|
||||||
|
var post_snap_y := camera.global_position.y
|
||||||
|
|
||||||
|
# Frame 2: player moves north — camera should lerp, not snap
|
||||||
|
SimBridge._test_input_queue.append("MoveNorth")
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
var new_target_y: float = GameState.player_position.y * Constants.TILE_SIZE
|
||||||
|
# Camera must be between snap position and new target (lerping, not snapping)
|
||||||
|
assert_that(camera.global_position.y < post_snap_y).is_true()
|
||||||
|
assert_that(camera.global_position.y > new_target_y).is_true()
|
||||||
|
# Teleport flag must not be re-set by normal movement
|
||||||
|
assert_that(_instance._teleport_in_progress).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# --- D-015: Fixed-north camera ---
|
||||||
|
|
||||||
|
func test_camera_no_rotation() -> void:
|
||||||
|
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||||
|
assert_that(camera.rotation).is_equal(0.0)
|
||||||
|
|
||||||
|
_instance._process(0.016)
|
||||||
|
assert_that(camera.rotation).is_equal(0.0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://s15smoothcam1
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
## Sprint 16 #543: GameState.current_zone_id population tests.
|
||||||
|
## Verifies that apply_snapshot() correctly extracts zone_id from the player's
|
||||||
|
## current tile after the O(N) → O(1) refactor. Behavior must be identical
|
||||||
|
## before and after the change (net-zero behavioral change per ticket spec).
|
||||||
|
## Spec refs: D-073, #543, #529.
|
||||||
|
class_name TestSnapshotZoneId
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.current_zone_id = ""
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.visibility_sectors = {}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Happy path (S16-Z01) ------------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_populated_when_player_on_zone_tile() -> void:
|
||||||
|
# S16-Z01: Player at (5,5), visible_tiles has zone_id "zone_alpha" at (5,5).
|
||||||
|
# D-073: current_zone_id must be set to the server-authoritative zone_id.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 3, "y": 3, "z": 0, "type": "floor", "zone_id": "zone_foyer"},
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
{"x": 7, "y": 7, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"current_zone_id must match zone_id at player tile (5,5)"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
|
assert_that(GameState.current_zone_id.is_empty()).override_failure_message(
|
||||||
|
"current_zone_id must be non-empty when player is on a zone-tagged tile"
|
||||||
|
).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Missing zone_id field (S16-Z02) -------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_empty_when_tile_lacks_zone_field() -> void:
|
||||||
|
# S16-Z02: Player's tile exists but has no zone_id key → empty string.
|
||||||
|
# Server may send tiles without zone_id when tile is unzoned.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Tile without zone_id field → current_zone_id must default to empty string"
|
||||||
|
).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Player off-tile (S16-Z03) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_empty_when_player_not_on_any_tile() -> void:
|
||||||
|
# S16-Z03: Player at (99,99) but no tile at that position → empty string.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 99.0, "y": 99.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Player at (99,99) with no matching tile → current_zone_id must be empty"
|
||||||
|
).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Empty tiles (S16-Z04) -----------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_empty_on_empty_visible_tiles() -> void:
|
||||||
|
# S16-Z04: No visible tiles at all → empty string, no crash.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Empty visible_tiles → current_zone_id must be empty string (no crash)"
|
||||||
|
).is_equal("")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Correct tile selected among many (S16-Z05) --------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_selects_correct_tile_among_many() -> void:
|
||||||
|
# S16-Z05: 10x10 tile grid, player at (6,4). Only zone_6_4 must be selected.
|
||||||
|
# Tests that the lookup doesn't accidentally match a neighboring tile.
|
||||||
|
var tiles: Array = []
|
||||||
|
for tx in range(10):
|
||||||
|
for ty in range(10):
|
||||||
|
tiles.append({"x": tx, "y": ty, "z": 0, "type": "floor",
|
||||||
|
"zone_id": "zone_%d_%d" % [tx, ty]})
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 6.0, "y": 4.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": tiles,
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Player at (6,4) must get zone_6_4, not a neighboring tile"
|
||||||
|
).is_equal("zone_6_4")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Zone transition (S16-Z06) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_updates_on_zone_transition() -> void:
|
||||||
|
# S16-Z06: Player moves from zone_alpha (5,5) to zone_beta (6,5).
|
||||||
|
# current_zone_id must update on each snapshot.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 2,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 6.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"After moving to (6,5), current_zone_id must update to zone_beta"
|
||||||
|
).is_equal("zone_beta")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Regression: visible_positions unaffected (S16-Z07) -----------------------
|
||||||
|
|
||||||
|
func test_visible_positions_unaffected_by_zone_id_refactor() -> void:
|
||||||
|
# S16-Z07: The O(1) refactor must not break visible_positions population.
|
||||||
|
# Both zone_id and visible_positions derive from the same visible_tiles loop —
|
||||||
|
# verify both are correctly populated after a single apply_snapshot().
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha", "visibility": "Forward"},
|
||||||
|
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_beta", "visibility": "Peripheral"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||||
|
"visible_positions must still contain (5,5) after zone_id refactor"
|
||||||
|
).is_true()
|
||||||
|
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"current_zone_id must be zone_alpha after same apply_snapshot call"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fractional player position (S16-Z08) --------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_uses_int_truncation_of_player_position() -> void:
|
||||||
|
# S16-Z08: Player at (5.7, 5.9) → int(5.7)=5, int(5.9)=5 → matches tile (5,5).
|
||||||
|
# Server sends player coords as floats; zone lookup must truncate to tile index.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.7, "y": 5.9, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"visible_tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Player at (5.7,5.9) must match tile (5,5) — int() truncation applies"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Test-mode tiles key (S16-Z09) --------------------------------------------
|
||||||
|
|
||||||
|
func test_zone_id_works_with_test_mode_tiles_key() -> void:
|
||||||
|
# S16-Z09: Test mode sends "tiles" key, not "visible_tiles".
|
||||||
|
# The O(1) refactor uses member visible_tiles (covers both paths).
|
||||||
|
# Regression guard: if _tile_by_coord is moved into the snapshot.visible_tiles
|
||||||
|
# block only, this test fails — catching the exact regression Tyre flagged.
|
||||||
|
GameState.apply_snapshot({
|
||||||
|
"tick": 1,
|
||||||
|
"entities": [
|
||||||
|
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||||
|
],
|
||||||
|
"tiles": [
|
||||||
|
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "zone_alpha"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert_that(GameState.current_zone_id).override_failure_message(
|
||||||
|
"Test-mode 'tiles' key must populate zone_id via member visible_tiles"
|
||||||
|
).is_equal("zone_alpha")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://tp8wpp6t8tvx
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
## Sprint 16 #540: Sprite integration tests.
|
||||||
|
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
|
||||||
|
## bounding box, sprite asset existence from #541, and fog shader independence.
|
||||||
|
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
|
||||||
|
class_name TestSpriteIntegration
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_entities = []
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers -------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _make_entity_renderer() -> Node2D:
|
||||||
|
var renderer = Node2D.new()
|
||||||
|
renderer.set_script(EntityRendererScript)
|
||||||
|
add_child(renderer)
|
||||||
|
return renderer
|
||||||
|
|
||||||
|
|
||||||
|
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
|
||||||
|
|
||||||
|
func test_entity_footprint_matches_d044_spec() -> void:
|
||||||
|
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
|
||||||
|
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
|
||||||
|
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
|
||||||
|
"D-044: ENTITY_WIDTH must be 24px"
|
||||||
|
).is_equal(24)
|
||||||
|
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
|
||||||
|
"D-044: ENTITY_HEIGHT must be 32px"
|
||||||
|
).is_equal(32)
|
||||||
|
|
||||||
|
|
||||||
|
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
|
||||||
|
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
|
||||||
|
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
|
||||||
|
# range (2 sim tiles) accurate with the tilted perspective.
|
||||||
|
var tile_2x: int = Constants.TILE_SIZE * 2
|
||||||
|
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
|
||||||
|
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
|
||||||
|
EntityRenderer.ENTITY_WIDTH, tile_2x]
|
||||||
|
).is_true()
|
||||||
|
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
|
||||||
|
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
|
||||||
|
EntityRenderer.ENTITY_HEIGHT, tile_2x]
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_entity_width_fits_within_single_tile() -> void:
|
||||||
|
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
|
||||||
|
# Ensures horizontal centering offset is positive and entity doesn't overflow.
|
||||||
|
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
|
||||||
|
"Entity width must be less than TILE_SIZE for centered layout"
|
||||||
|
).is_true()
|
||||||
|
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
|
||||||
|
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Pixel position (S16-S04) -------------------------------------------------
|
||||||
|
|
||||||
|
func test_entity_pixel_position_at_tile_3_7() -> void:
|
||||||
|
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
|
||||||
|
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
|
||||||
|
"kind": {"variant": "Npc", "data": null}}]
|
||||||
|
renderer.update_entities(entity)
|
||||||
|
var node = renderer.entity_nodes[10]
|
||||||
|
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||||
|
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||||
|
assert_that(node.position.x).override_failure_message(
|
||||||
|
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
|
||||||
|
).is_equal_approx(expected_x, 0.1)
|
||||||
|
assert_that(node.position.y).override_failure_message(
|
||||||
|
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
|
||||||
|
).is_equal_approx(expected_y, 0.1)
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
|
||||||
|
|
||||||
|
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
|
||||||
|
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
|
||||||
|
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
|
||||||
|
# With tilted sprites, south-facing entity must visually overlap northern.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entities := [
|
||||||
|
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
]
|
||||||
|
renderer.update_entities(entities)
|
||||||
|
var north_node = renderer.entity_nodes[20]
|
||||||
|
var south_node = renderer.entity_nodes[21]
|
||||||
|
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
|
||||||
|
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
|
||||||
|
).is_true()
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_z_sort_y_position_difference_equals_tile_size() -> void:
|
||||||
|
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
|
||||||
|
# Verifies position calculation is consistent for adjacent tiles.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entities := [
|
||||||
|
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
]
|
||||||
|
renderer.update_entities(entities)
|
||||||
|
var node3 = renderer.entity_nodes[30]
|
||||||
|
var node4 = renderer.entity_nodes[31]
|
||||||
|
var delta_y := node4.position.y - node3.position.y
|
||||||
|
assert_that(delta_y).override_failure_message(
|
||||||
|
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
|
||||||
|
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_z_sort_same_y_different_x_no_y_difference() -> void:
|
||||||
|
# S16-S07: Two entities at same y but different x → same pixel.y.
|
||||||
|
# Horizontal position must not affect y-sort order.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
var entities := [
|
||||||
|
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||||
|
]
|
||||||
|
renderer.update_entities(entities)
|
||||||
|
var left_node = renderer.entity_nodes[40]
|
||||||
|
var right_node = renderer.entity_nodes[41]
|
||||||
|
assert_that(left_node.position.y).override_failure_message(
|
||||||
|
"Entities at same y-tile must have same pixel.y regardless of x"
|
||||||
|
).is_equal_approx(right_node.position.y, 0.1)
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
|
||||||
|
|
||||||
|
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||||
|
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
|
||||||
|
# entity_renderer.gd must be able to load these paths.
|
||||||
|
for direction in ["north", "east", "south", "west"]:
|
||||||
|
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||||
|
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||||
|
"NPC sprite missing: %s" % path
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||||
|
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
|
||||||
|
for direction in ["north", "east", "south", "west"]:
|
||||||
|
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
|
||||||
|
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||||
|
"Wall sprite missing: %s" % path
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
|
||||||
|
|
||||||
|
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
|
||||||
|
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
|
||||||
|
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
|
||||||
|
# the art-direction tilt baked into sprites.
|
||||||
|
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
|
||||||
|
"fog_shader.gd must load correctly — must not be affected by sprite changes"
|
||||||
|
).is_true()
|
||||||
|
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||||
|
"fog.gdshader must exist — fog is screen-space and sprite-independent"
|
||||||
|
).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
|
||||||
|
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
|
||||||
|
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
|
||||||
|
var fog = get_node_or_null("/root/FogState")
|
||||||
|
if fog == null:
|
||||||
|
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
|
||||||
|
return
|
||||||
|
# Provide visibility data but no entity renderer context
|
||||||
|
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||||
|
GameState.visibility_sectors = {
|
||||||
|
Vector2i(5, 5): "Forward",
|
||||||
|
Vector2i(6, 5): "Peripheral",
|
||||||
|
}
|
||||||
|
if fog.has_method("update_from_state"):
|
||||||
|
fog.update_from_state()
|
||||||
|
assert_that(fog.visibility_texture).override_failure_message(
|
||||||
|
"FogState visibility_texture must be populated independently of sprite state"
|
||||||
|
).is_not_null()
|
||||||
|
GameState.visible_positions.clear()
|
||||||
|
GameState.visibility_sectors.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
|
||||||
|
|
||||||
|
func test_octant_north_maps_to_north() -> void:
|
||||||
|
# S16-S12: "North" → "north"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
|
||||||
|
|
||||||
|
func test_octant_northwest_maps_to_north() -> void:
|
||||||
|
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
|
||||||
|
|
||||||
|
func test_octant_northeast_maps_to_east() -> void:
|
||||||
|
# S16-S14: "Northeast" → "east"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
|
||||||
|
|
||||||
|
func test_octant_east_maps_to_east() -> void:
|
||||||
|
# S16-S15: "East" → "east"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
|
||||||
|
|
||||||
|
func test_octant_southeast_maps_to_south() -> void:
|
||||||
|
# S16-S16: "Southeast" → "south"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
|
||||||
|
|
||||||
|
func test_octant_south_maps_to_south() -> void:
|
||||||
|
# S16-S17: "South" → "south"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
|
||||||
|
|
||||||
|
func test_octant_southwest_maps_to_west() -> void:
|
||||||
|
# S16-S18: "Southwest" → "west"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
|
||||||
|
|
||||||
|
func test_octant_west_maps_to_west() -> void:
|
||||||
|
# S16-S19: "West" → "west"
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
|
||||||
|
|
||||||
|
func test_octant_unknown_string_falls_back_to_south() -> void:
|
||||||
|
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
|
||||||
|
|
||||||
|
func test_octant_empty_string_falls_back_to_south() -> void:
|
||||||
|
# S16-S21: Empty string → "south" fallback
|
||||||
|
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
|
||||||
|
|
||||||
|
func test_entity_direction_npc_always_south() -> void:
|
||||||
|
# S16-S22: NPC entity → always "south" regardless of any data field.
|
||||||
|
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
# entity_id 99 is not the player
|
||||||
|
var dir := renderer._entity_direction(99, {"entity_id": 99,
|
||||||
|
"kind": {"variant": "Npc", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"NPC entity must always return 'south'"
|
||||||
|
).is_equal("south")
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
func test_entity_direction_player_uses_player_facing() -> void:
|
||||||
|
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_facing = "North"
|
||||||
|
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||||
|
"kind": {"variant": "Player", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"Player entity with player_facing='North' must return 'north'"
|
||||||
|
).is_equal("north")
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
func test_entity_direction_player_facing_east() -> void:
|
||||||
|
# S16-S24: Player facing "East" → "east"
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_facing = "East"
|
||||||
|
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||||
|
"kind": {"variant": "Player", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"Player entity with player_facing='East' must return 'east'"
|
||||||
|
).is_equal("east")
|
||||||
|
renderer.queue_free()
|
||||||
|
|
||||||
|
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
|
||||||
|
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
|
||||||
|
# Diagonal octants map to one of the four cardinal sprite sets.
|
||||||
|
var renderer := _make_entity_renderer()
|
||||||
|
GameState.player_entity_id = 1
|
||||||
|
GameState.player_facing = "Northwest"
|
||||||
|
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||||
|
"kind": {"variant": "Player", "data": null}})
|
||||||
|
assert_that(dir).override_failure_message(
|
||||||
|
"Player entity with player_facing='Northwest' must return 'north'"
|
||||||
|
).is_equal("north")
|
||||||
|
renderer.queue_free()
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
## Sprint 15 — Basic UI framework validation tests (#74)
|
||||||
|
## Validates HUD structure, z-layer hierarchy, insert_active control,
|
||||||
|
## and monologue display wiring per D-049, D-056, D-057, D-061, OQ-07.
|
||||||
|
class_name TestUIFrameworkSprint15
|
||||||
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func before_test() -> void:
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.current_tick = 0
|
||||||
|
GameState.player_position = Vector2.ZERO
|
||||||
|
GameState.visible_entities = []
|
||||||
|
GameState.visible_tiles = []
|
||||||
|
GameState.visible_positions = {}
|
||||||
|
GameState.current_monologue = null
|
||||||
|
GameState.current_dialogue = null
|
||||||
|
GameState.insert_active = true
|
||||||
|
|
||||||
|
|
||||||
|
func after_test() -> void:
|
||||||
|
if _instance and is_instance_valid(_instance):
|
||||||
|
_instance.queue_free()
|
||||||
|
_instance = null
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-076: Layout constants
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_dialogue_max_width_set() -> void:
|
||||||
|
# DIALOGUE_MAX_WIDTH = 1200px (supersedes D-076 640px default per Tyre review).
|
||||||
|
assert_that(Constants.DIALOGUE_MAX_WIDTH).is_equal(1200)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-049: Z-layer scene hierarchy
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||||
|
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
||||||
|
# Constants.CANVAS_INSERT must match.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var insert_overlay: CanvasLayer = _instance.get_node("InsertOverlay")
|
||||||
|
assert_that(insert_overlay).is_not_null()
|
||||||
|
assert_that(insert_overlay.layer).is_equal(Constants.CANVAS_INSERT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_layer_is_canvas_layer_20() -> void:
|
||||||
|
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var ui_layer: CanvasLayer = _instance.get_node("UILayer")
|
||||||
|
assert_that(ui_layer).is_not_null()
|
||||||
|
assert_that(ui_layer.layer).is_equal(Constants.CANVAS_UI)
|
||||||
|
|
||||||
|
|
||||||
|
func test_modal_layer_is_canvas_layer_30() -> void:
|
||||||
|
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var modal_layer: CanvasLayer = _instance.get_node("ModalLayer")
|
||||||
|
assert_that(modal_layer).is_not_null()
|
||||||
|
assert_that(modal_layer.layer).is_equal(Constants.CANVAS_MODAL)
|
||||||
|
|
||||||
|
|
||||||
|
func test_ui_layer_above_insert_overlay() -> void:
|
||||||
|
# D-049: UILayer (20) must render above InsertOverlay (10).
|
||||||
|
assert_that(Constants.CANVAS_UI).is_greater(Constants.CANVAS_INSERT)
|
||||||
|
|
||||||
|
|
||||||
|
func test_modal_layer_above_ui_layer() -> void:
|
||||||
|
# D-049: ModalLayer (30) must render above UILayer (20).
|
||||||
|
assert_that(Constants.CANVAS_MODAL).is_greater(Constants.CANVAS_UI)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# D-049: Required nodes exist in correct layers
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_monologue_display_exists_in_ui_layer() -> void:
|
||||||
|
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/MonologueDisplay")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||||
|
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/StanceIndicator")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||||
|
# D-013: Minimap/insert placeholder must be in UILayer (not implemented yet).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/Minimap")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_hud_exists_in_ui_layer() -> void:
|
||||||
|
# D-049: HUD must be in UILayer.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||||
|
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/InteractionList")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||||
|
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/DialogueBox")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_world_radial_exists_in_insert_overlay() -> void:
|
||||||
|
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("InsertOverlay/WorldRadial")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_cursor_renderer_exists_in_ui_layer() -> void:
|
||||||
|
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("UILayer/CursorRenderer")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# OQ-07 / D-056: insert_active controls z-layer 6 visibility
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_gamestate_insert_active_defaults_true() -> void:
|
||||||
|
# OQ-07: v0.1 characters all have inserts — default true.
|
||||||
|
assert_that(GameState.insert_active).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_active_propagates_on_process() -> void:
|
||||||
|
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
||||||
|
# the next _process() call must propagate the state to z-layer-6 nodes.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
# Inject a snapshot with insert_active = false
|
||||||
|
var snap := SimBridge._test_snapshot()
|
||||||
|
snap["insert_active"] = false
|
||||||
|
GameState.apply_snapshot(snap)
|
||||||
|
assert_that(GameState.insert_active).is_false()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_active_true_from_snapshot() -> void:
|
||||||
|
# OQ-07: Snapshot with insert_active=true keeps GameState in default-on state.
|
||||||
|
var snap := SimBridge._test_snapshot()
|
||||||
|
snap["insert_active"] = true
|
||||||
|
GameState.apply_snapshot(snap)
|
||||||
|
assert_that(GameState.insert_active).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
func test_insert_active_missing_field_defaults_true() -> void:
|
||||||
|
# OQ-07: Old servers without insert_active field must not disable the insert.
|
||||||
|
var snap := SimBridge._test_snapshot()
|
||||||
|
snap.erase("insert_active")
|
||||||
|
GameState.apply_snapshot(snap)
|
||||||
|
assert_that(GameState.insert_active).is_true()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# #241 stub: follow_target_id for entity sprite system
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_follow_target_id_stub_exists() -> void:
|
||||||
|
# #72 / #241: follow_target_id stub must exist on GameState with default -1.
|
||||||
|
# Populated by server ticket #241 (Follow verb) when it lands.
|
||||||
|
assert_that(GameState.follow_target_id).is_equal(-1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_follow_target_id_is_negative_one_by_default() -> void:
|
||||||
|
# #72: -1 means "not following" — client #72 checks this for entity highlight.
|
||||||
|
SimBridge.reset_test_state()
|
||||||
|
GameState.apply_snapshot(SimBridge._test_snapshot())
|
||||||
|
# Server doesn't send follow_target_id yet — must stay -1 after snapshot
|
||||||
|
assert_that(GameState.follow_target_id).is_equal(-1)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Monologue display wiring (#414)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||||
|
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
||||||
|
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
# Tick 1 snapshot has a monologue (SimBridge test mode)
|
||||||
|
# _ready() consumes tick 0 (no monologue). _process() here gets tick 1.
|
||||||
|
_instance._process(0.016)
|
||||||
|
|
||||||
|
# If monologue_display received it, current_monologue is cleared (consume-once)
|
||||||
|
assert_that(GameState.current_monologue).is_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Regression: Sprint 14 integration proofs (D-030 regression markers)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_fog_group_exists_in_world() -> void:
|
||||||
|
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("World/FogGroup")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_floor_tiles_in_fog_group() -> void:
|
||||||
|
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("World/FogGroup/FloorTiles")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
func test_entities_in_ysort_group() -> void:
|
||||||
|
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
assert_that(_instance.get_node_or_null("World/FogGroup/YSortGroup/Entities")).is_not_null()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# #71: Tilemap z-filter — FloorTiles only renders floor level 0
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func test_tile_renderer_skips_nonzero_z() -> void:
|
||||||
|
# #71: Tiles with z != 0 must be filtered out by update_tiles().
|
||||||
|
var scene := load("res://scenes/main.tscn")
|
||||||
|
_instance = scene.instantiate()
|
||||||
|
auto_free(_instance)
|
||||||
|
add_child(_instance)
|
||||||
|
|
||||||
|
var tile_renderer: TileMapLayer = _instance.get_node("World/FogGroup/FloorTiles")
|
||||||
|
assert_that(tile_renderer).is_not_null()
|
||||||
|
|
||||||
|
# Feed tiles at z=0 and z=1
|
||||||
|
var tiles: Array = [
|
||||||
|
{"x": 0, "y": 0, "z": 0, "type": "floor"},
|
||||||
|
{"x": 1, "y": 0, "z": 1, "type": "floor"},
|
||||||
|
{"x": 2, "y": 0, "z": 0, "type": "wall"},
|
||||||
|
{"x": 3, "y": 0, "z": 2, "type": "door"},
|
||||||
|
]
|
||||||
|
tile_renderer.update_tiles(tiles)
|
||||||
|
|
||||||
|
# z=0 tiles should be present
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(0, 0))).is_not_equal(-1)
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(2, 0))).is_not_equal(-1)
|
||||||
|
# z=1 and z=2 tiles should NOT be present (-1 = no cell)
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1)
|
||||||
|
assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://s15uiframe001
|
||||||
@@ -243,6 +243,7 @@ func _close() -> void:
|
|||||||
_active = false
|
_active = false
|
||||||
visible = false
|
visible = false
|
||||||
if _line_edit:
|
if _line_edit:
|
||||||
|
_line_edit.release_focus()
|
||||||
_line_edit.queue_free()
|
_line_edit.queue_free()
|
||||||
_line_edit = null
|
_line_edit = null
|
||||||
|
|
||||||
@@ -344,7 +345,7 @@ func _save_report(description: String) -> void:
|
|||||||
else:
|
else:
|
||||||
push_error("BugReport: failed to write %s" % seed_path)
|
push_error("BugReport: failed to write %s" % seed_path)
|
||||||
|
|
||||||
push_warning("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
|
print("BugReport: saved %d/6 files to %s (ring: %d ticks)" % [
|
||||||
files_saved, base_path, _input_count])
|
files_saved, base_path, _input_count])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c5xgtnkp0butl
|
||||||
@@ -21,12 +21,18 @@ signal unpause_requested # D-061: auto-unpause
|
|||||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||||
|
|
||||||
# -- Log state --
|
# -- Log state --
|
||||||
# Entry format: {speaker, target, text, is_passive, pinned, timestamp_msec}
|
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
|
||||||
|
# Entry format (entity-anchored): {speaker_id: int, target_id: int, text, is_passive, pinned, timestamp_msec}
|
||||||
# pinned entries do not expire (active conversation lines, Araminta review).
|
# pinned entries do not expire (active conversation lines, Araminta review).
|
||||||
var _log_entries: Array[Dictionary] = []
|
var _log_entries: Array[Dictionary] = []
|
||||||
var _in_player_conversation: bool = false
|
var _in_player_conversation: bool = false
|
||||||
var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1)
|
var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode rebuild (Hoshe #1)
|
||||||
|
|
||||||
|
# Entity ID → {name: String, color_index: int}
|
||||||
|
# Populated from server events; drives retroactive re-render when NPC names resolve.
|
||||||
|
var _entity_display: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
# -- Option state --
|
# -- Option state --
|
||||||
var _option_controls: Array[Control] = []
|
var _option_controls: Array[Control] = []
|
||||||
var _option_response_ids: Array[String] = []
|
var _option_response_ids: Array[String] = []
|
||||||
@@ -35,7 +41,6 @@ var _option_is_confrontation: Array[bool] = []
|
|||||||
var _npc_name: String = ""
|
var _npc_name: String = ""
|
||||||
|
|
||||||
# -- UI state --
|
# -- UI state --
|
||||||
var _is_showing: bool = false
|
|
||||||
var _active_tween: Tween = null
|
var _active_tween: Tween = null
|
||||||
var _beat_tween: Tween = null # D-063: confrontation beat delay
|
var _beat_tween: Tween = null # D-063: confrontation beat delay
|
||||||
|
|
||||||
@@ -45,8 +50,8 @@ var _npc_colors: Array[Color] = []
|
|||||||
var _arrow_color: Color = Color("#8890a0")
|
var _arrow_color: Color = Color("#8890a0")
|
||||||
var _speech_color: Color = Color("#c8d0e0")
|
var _speech_color: Color = Color("#c8d0e0")
|
||||||
var _passive_opacity: float = 0.9
|
var _passive_opacity: float = 0.9
|
||||||
var _entry_lifetime: float = 15.0
|
var _entry_lifetime: float = 45.0
|
||||||
var _entry_fade: float = 3.0
|
var _entry_fade: float = 5.0
|
||||||
|
|
||||||
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||||
|
|
||||||
@@ -71,9 +76,10 @@ const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
panel.modulate.a = 0.0
|
# Panel is always visible as a permanent insert UI element (D-061).
|
||||||
visible = false
|
# Content fades in/out but the panel frame stays on screen.
|
||||||
_is_showing = false
|
visible = true
|
||||||
|
panel.modulate.a = 1.0
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_load_theme()
|
_load_theme()
|
||||||
_update_layout()
|
_update_layout()
|
||||||
@@ -88,11 +94,21 @@ func _process(_delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if not _is_showing:
|
|
||||||
return
|
|
||||||
if not _in_player_conversation:
|
if not _in_player_conversation:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Number keys 1-3 select dialogue options
|
||||||
|
if event is InputEventKey and event.pressed:
|
||||||
|
var key_index := -1
|
||||||
|
if event.keycode == KEY_1: key_index = 0
|
||||||
|
elif event.keycode == KEY_2: key_index = 1
|
||||||
|
elif event.keycode == KEY_3: key_index = 2
|
||||||
|
if key_index >= 0 and key_index < _option_controls.size():
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
_on_option_pressed(key_index)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
# D-064: WASD during active player dialogue → walk-away
|
# D-064: WASD during active player dialogue → walk-away
|
||||||
if event is InputEventKey and event.pressed:
|
if event is InputEventKey and event.pressed:
|
||||||
for action in _WALK_AWAY_ACTIONS:
|
for action in _WALK_AWAY_ACTIONS:
|
||||||
@@ -153,7 +169,7 @@ func _load_theme() -> void:
|
|||||||
func _update_layout() -> void:
|
func _update_layout() -> void:
|
||||||
var vp := get_viewport_rect().size
|
var vp := get_viewport_rect().size
|
||||||
var max_h := vp.y * MAX_HEIGHT_RATIO
|
var max_h := vp.y * MAX_HEIGHT_RATIO
|
||||||
var w := minf(MAX_WIDTH_PX, vp.x * 0.65)
|
var w := minf(MAX_WIDTH_PX, vp.x * 0.85)
|
||||||
panel.offset_left = -w / 2.0
|
panel.offset_left = -w / 2.0
|
||||||
panel.offset_right = w / 2.0
|
panel.offset_right = w / 2.0
|
||||||
panel.offset_top = -max_h
|
panel.offset_top = -max_h
|
||||||
@@ -180,13 +196,59 @@ func append_line(speaker: String, target: String, text: String, is_passive: bool
|
|||||||
|
|
||||||
|
|
||||||
## Append an overheard conversation event (D-078).
|
## Append an overheard conversation event (D-078).
|
||||||
|
## Stores entity IDs for retroactive name resolution when NPC display names change.
|
||||||
func append_conversation_event(event: Dictionary) -> void:
|
func append_conversation_event(event: Dictionary) -> void:
|
||||||
var speaker: String = event.get("speaker_name", "?")
|
|
||||||
var target: String = event.get("target_name", "?")
|
|
||||||
var text: String = event.get("occluded_line", "")
|
var text: String = event.get("occluded_line", "")
|
||||||
if text.is_empty():
|
if text.is_empty():
|
||||||
return
|
return
|
||||||
append_line(speaker, target, text, true)
|
|
||||||
|
var speaker_id: int = event.get("speaker_id", -1)
|
||||||
|
var target_id: int = event.get("target_id", -1)
|
||||||
|
var speaker_name: String = event.get("speaker_name", "?")
|
||||||
|
var target_name: String = event.get("target_name", "?")
|
||||||
|
var speaker_color_index: int = event.get("speaker_color_index", -1)
|
||||||
|
var target_color_index: int = event.get("target_color_index", -1)
|
||||||
|
|
||||||
|
# Update entity display registry — set dirty if a known name changed (retroactive update)
|
||||||
|
if speaker_id >= 0:
|
||||||
|
var prev: Dictionary = _entity_display.get(speaker_id, {})
|
||||||
|
_entity_display[speaker_id] = {"name": speaker_name, "color_index": speaker_color_index}
|
||||||
|
if prev.has("name") and prev.get("name", "") != speaker_name:
|
||||||
|
_log_dirty = true
|
||||||
|
if target_id >= 0:
|
||||||
|
var prev: Dictionary = _entity_display.get(target_id, {})
|
||||||
|
_entity_display[target_id] = {"name": target_name, "color_index": target_color_index}
|
||||||
|
if prev.has("name") and prev.get("name", "") != target_name:
|
||||||
|
_log_dirty = true
|
||||||
|
|
||||||
|
var entry: Dictionary = {
|
||||||
|
"text": text,
|
||||||
|
"is_passive": true,
|
||||||
|
"pinned": false,
|
||||||
|
"timestamp_msec": Time.get_ticks_msec(),
|
||||||
|
}
|
||||||
|
if speaker_id >= 0:
|
||||||
|
entry["speaker_id"] = speaker_id
|
||||||
|
entry["target_id"] = target_id
|
||||||
|
else:
|
||||||
|
# Fallback: no entity IDs on wire, store raw names for legacy rendering
|
||||||
|
entry["speaker"] = speaker_name
|
||||||
|
entry["target"] = target_name
|
||||||
|
|
||||||
|
_log_entries.append(entry)
|
||||||
|
_log_dirty = true
|
||||||
|
_ensure_visible()
|
||||||
|
|
||||||
|
|
||||||
|
## Update entity display name and color index — called when Talk responses arrive.
|
||||||
|
## Triggers retroactive log re-render if the entity's displayed name has changed.
|
||||||
|
func update_entity_display(entity_id: int, name: String, color_index: int) -> void:
|
||||||
|
if entity_id < 0:
|
||||||
|
return
|
||||||
|
var prev: Dictionary = _entity_display.get(entity_id, {})
|
||||||
|
_entity_display[entity_id] = {"name": name, "color_index": color_index}
|
||||||
|
if prev.has("name") and prev.get("name", "") != name:
|
||||||
|
_log_dirty = true
|
||||||
|
|
||||||
|
|
||||||
## Handle conversation_ended — no-op currently (entries expire via timeout).
|
## Handle conversation_ended — no-op currently (entries expire via timeout).
|
||||||
@@ -234,7 +296,8 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
|||||||
|
|
||||||
|
|
||||||
## End active player conversation — clears options but preserves log.
|
## End active player conversation — clears options but preserves log.
|
||||||
## D-064: dialogue_active held until fade completes.
|
## D-064: dialogue_active cleared immediately so WASD resumes.
|
||||||
|
## Log entries remain visible and expire via timeout (cosmetic only).
|
||||||
func _end_player_conversation() -> void:
|
func _end_player_conversation() -> void:
|
||||||
_in_player_conversation = false
|
_in_player_conversation = false
|
||||||
_clear_options()
|
_clear_options()
|
||||||
@@ -254,32 +317,21 @@ func _end_player_conversation() -> void:
|
|||||||
# D-061: unpause — signal to main.gd for input recording (#507)
|
# D-061: unpause — signal to main.gd for input recording (#507)
|
||||||
unpause_requested.emit()
|
unpause_requested.emit()
|
||||||
|
|
||||||
# D-064: dialogue_active stays true — cleared after fade completes in hide callback.
|
# D-064: unblock movement immediately — log entries stay visible but don't block input.
|
||||||
# If log entries still exist, panel stays visible and entries expire via timeout.
|
GameState.dialogue_active = false
|
||||||
# If no entries, hide immediately with fade.
|
|
||||||
|
# If no entries remain, hide the panel with fade.
|
||||||
if _log_entries.is_empty():
|
if _log_entries.is_empty():
|
||||||
hide_dialogue()
|
hide_dialogue()
|
||||||
|
|
||||||
|
|
||||||
## Hide the entire panel with fade.
|
## End active dialogue state. Panel stays visible (permanent insert UI element).
|
||||||
func hide_dialogue() -> void:
|
func hide_dialogue() -> void:
|
||||||
if not _is_showing:
|
|
||||||
return
|
|
||||||
|
|
||||||
if _in_player_conversation:
|
if _in_player_conversation:
|
||||||
_end_player_conversation()
|
_end_player_conversation()
|
||||||
return # _end_player_conversation may call hide_dialogue if log is empty
|
return # _end_player_conversation may call hide_dialogue if log is empty
|
||||||
|
|
||||||
_is_showing = false
|
GameState.dialogue_active = false
|
||||||
|
|
||||||
if _active_tween and _active_tween.is_valid():
|
|
||||||
_active_tween.kill()
|
|
||||||
_active_tween = create_tween()
|
|
||||||
_active_tween.tween_property(panel, "modulate:a", 0.0, FADE_OUT)
|
|
||||||
_active_tween.tween_callback(func():
|
|
||||||
visible = false
|
|
||||||
GameState.dialogue_active = false # D-064: unblock movement after fade completes
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func is_dialogue_active() -> bool:
|
func is_dialogue_active() -> bool:
|
||||||
@@ -302,18 +354,15 @@ func _show_options(options: Array) -> void:
|
|||||||
var raw_text: String = opt.get("text", "")
|
var raw_text: String = opt.get("text", "")
|
||||||
var is_confrontation: bool = opt.get("confrontation", false)
|
var is_confrontation: bool = opt.get("confrontation", false)
|
||||||
|
|
||||||
var label := RichTextLabel.new()
|
var label := Label.new()
|
||||||
label.bbcode_enabled = true
|
label.add_theme_font_size_override("font_size", 14)
|
||||||
label.fit_content = true
|
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||||
label.scroll_active = false
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
|
||||||
|
|
||||||
if is_confrontation:
|
var numbered_text := "%d. %s" % [i + 1, raw_text]
|
||||||
label.text = "[i]%s[/i]" % raw_text
|
label.text = numbered_text
|
||||||
else:
|
|
||||||
label.text = raw_text
|
|
||||||
|
|
||||||
var idx := i
|
var idx := i
|
||||||
label.gui_input.connect(func(event: InputEvent):
|
label.gui_input.connect(func(event: InputEvent):
|
||||||
@@ -407,15 +456,42 @@ func _rebuild_log() -> void:
|
|||||||
## Hoshe #2: escape BBCode brackets in server-sourced strings.
|
## Hoshe #2: escape BBCode brackets in server-sourced strings.
|
||||||
## Araminta: passive lines get ┃ prefix + desaturated colours.
|
## Araminta: passive lines get ┃ prefix + desaturated colours.
|
||||||
## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You).
|
## Non-blocking: 1-on-1 player dialogue simplifies to "Speaker:" (no → You).
|
||||||
|
## Entity-anchored entries resolve display name and color from _entity_display.
|
||||||
func _format_entry(entry: Dictionary, alpha: float) -> String:
|
func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||||
var speaker: String = _escape_bbcode(entry.speaker)
|
var speaker: String
|
||||||
var target: String = _escape_bbcode(entry.target)
|
var target: String
|
||||||
|
var speaker_color: Color
|
||||||
|
var target_color: Color
|
||||||
|
var involves_player: bool
|
||||||
|
|
||||||
|
if entry.has("speaker_id"):
|
||||||
|
# Entity-anchored entry: resolve from _entity_display registry
|
||||||
|
var sp_data: Dictionary = _entity_display.get(entry["speaker_id"], {})
|
||||||
|
var tg_data: Dictionary = _entity_display.get(entry.get("target_id", -1), {})
|
||||||
|
speaker = _escape_bbcode(sp_data.get("name", "?"))
|
||||||
|
target = _escape_bbcode(tg_data.get("name", "?"))
|
||||||
|
var sp_ci: int = sp_data.get("color_index", -1)
|
||||||
|
var tg_ci: int = tg_data.get("color_index", -1)
|
||||||
|
if sp_ci >= 0 and not _npc_colors.is_empty():
|
||||||
|
speaker_color = _enforce_contrast(_npc_colors[sp_ci % _npc_colors.size()])
|
||||||
|
else:
|
||||||
|
speaker_color = _color_for_name(sp_data.get("name", "?"))
|
||||||
|
if tg_ci >= 0 and not _npc_colors.is_empty():
|
||||||
|
target_color = _enforce_contrast(_npc_colors[tg_ci % _npc_colors.size()])
|
||||||
|
else:
|
||||||
|
target_color = _color_for_name(tg_data.get("name", "?"))
|
||||||
|
involves_player = false # Overheard entries never involve the player directly
|
||||||
|
else:
|
||||||
|
# Legacy string-keyed entry (player dialogue, backward compat)
|
||||||
|
speaker = _escape_bbcode(entry.get("speaker", "?"))
|
||||||
|
target = _escape_bbcode(entry.get("target", "?"))
|
||||||
|
speaker_color = _color_for_name(entry.get("speaker", "?"))
|
||||||
|
target_color = _color_for_name(entry.get("target", "?"))
|
||||||
|
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
|
||||||
|
|
||||||
var text: String = _escape_bbcode(entry.text)
|
var text: String = _escape_bbcode(entry.text)
|
||||||
var is_passive: bool = entry.is_passive
|
var is_passive: bool = entry.is_passive
|
||||||
|
|
||||||
var speaker_color := _color_for_name(entry.speaker)
|
|
||||||
var target_color := _color_for_name(entry.target)
|
|
||||||
|
|
||||||
# Desaturate passive name colours (Araminta review)
|
# Desaturate passive name colours (Araminta review)
|
||||||
if is_passive:
|
if is_passive:
|
||||||
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
|
speaker_color = _desaturate(speaker_color, PASSIVE_DESATURATION)
|
||||||
@@ -429,7 +505,6 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
|||||||
var prefix := PASSIVE_GLYPH if is_passive else ""
|
var prefix := PASSIVE_GLYPH if is_passive else ""
|
||||||
|
|
||||||
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
|
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
|
||||||
var involves_player := entry.speaker == PLAYER_NAME or entry.target == PLAYER_NAME
|
|
||||||
if involves_player and not is_passive:
|
if involves_player and not is_passive:
|
||||||
# Just "Speaker: text" or "You: text"
|
# Just "Speaker: text" or "You: text"
|
||||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||||
@@ -532,16 +607,9 @@ func _expire_entries() -> void:
|
|||||||
|
|
||||||
# -- Visibility --
|
# -- Visibility --
|
||||||
|
|
||||||
## Ensure panel is visible (fade in if needed).
|
## No-op — panel is always visible as a permanent insert UI element.
|
||||||
func _ensure_visible() -> void:
|
func _ensure_visible() -> void:
|
||||||
if _is_showing:
|
pass
|
||||||
return
|
|
||||||
visible = true
|
|
||||||
_is_showing = true
|
|
||||||
if _active_tween and _active_tween.is_valid():
|
|
||||||
_active_tween.kill()
|
|
||||||
_active_tween = create_tween()
|
|
||||||
_active_tween.tween_property(panel, "modulate:a", 1.0, FADE_IN)
|
|
||||||
|
|
||||||
|
|
||||||
func _clear_options() -> void:
|
func _clear_options() -> void:
|
||||||
@@ -555,11 +623,11 @@ func _clear_options() -> void:
|
|||||||
|
|
||||||
|
|
||||||
# Hover callbacks
|
# Hover callbacks
|
||||||
static func _make_hover_on(label: RichTextLabel) -> Callable:
|
static func _make_hover_on(label: Control) -> Callable:
|
||||||
return func():
|
return func():
|
||||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_HOVER)
|
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_HOVER)
|
||||||
|
|
||||||
|
|
||||||
static func _make_hover_off(label: RichTextLabel) -> Callable:
|
static func _make_hover_off(label: Control) -> Callable:
|
||||||
return func():
|
return func():
|
||||||
label.add_theme_color_override("default_color", Constants.INSERT_COLOR_TEXT)
|
label.add_theme_color_override("font_color", Constants.INSERT_COLOR_TEXT)
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ const LABEL_GAP := 2
|
|||||||
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
|
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
|
||||||
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
|
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
|
||||||
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
|
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
|
||||||
|
const ENTITY_OFFSET := Vector2(0, -12) # nudge above entity sprite center
|
||||||
|
|
||||||
var _showing: bool = false
|
var _showing: bool = false
|
||||||
var _insert_active: bool = true
|
var _insert_active: bool = true
|
||||||
var _current_target_id: int = -1
|
var _current_target_id: int = -1
|
||||||
|
var _entity_world_pos: Vector2 = Vector2.ZERO # cached world tile position of target
|
||||||
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
|
||||||
var _selected_index: int = 0
|
var _selected_index: int = 0
|
||||||
var _active_tween: Tween = null
|
var _active_tween: Tween = null
|
||||||
@@ -37,6 +39,21 @@ func _ready() -> void:
|
|||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if _showing:
|
||||||
|
_update_screen_position()
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
if not _showing or _verb_labels.is_empty():
|
||||||
|
return
|
||||||
|
var pad := 6.0
|
||||||
|
var bg_rect := Rect2(-pad, -pad, size.x + pad * 2, size.y + pad * 2)
|
||||||
|
draw_rect(bg_rect, INSERT_BG)
|
||||||
|
draw_rect(bg_rect, Constants.IMPLANT_TEXT_DIM * Color(1, 1, 1, 0.3), false, 1.0)
|
||||||
|
|
||||||
|
|
||||||
func update_from_state() -> void:
|
func update_from_state() -> void:
|
||||||
# D-055: Sprint stance suppresses interaction list
|
# D-055: Sprint stance suppresses interaction list
|
||||||
if GameState.player_stance == "Sprint":
|
if GameState.player_stance == "Sprint":
|
||||||
@@ -70,6 +87,7 @@ func update_from_state() -> void:
|
|||||||
_current_target_id = entity_id
|
_current_target_id = entity_id
|
||||||
_verb_items = sorted
|
_verb_items = sorted
|
||||||
_selected_index = 0
|
_selected_index = 0
|
||||||
|
_cache_entity_position()
|
||||||
_rebuild_labels()
|
_rebuild_labels()
|
||||||
_show()
|
_show()
|
||||||
|
|
||||||
@@ -84,15 +102,46 @@ func _rebuild_labels() -> void:
|
|||||||
for i in range(_verb_items.size()):
|
for i in range(_verb_items.size()):
|
||||||
var verb: Dictionary = _verb_items[i]
|
var verb: Dictionary = _verb_items[i]
|
||||||
var lbl := Label.new()
|
var lbl := Label.new()
|
||||||
lbl.text = verb.get("label", "")
|
lbl.text = " %s " % verb.get("label", "")
|
||||||
lbl.add_theme_font_size_override("font_size", 14)
|
lbl.add_theme_font_size_override("font_size", 14)
|
||||||
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
lbl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||||
|
var idx := i
|
||||||
|
lbl.gui_input.connect(func(event: InputEvent):
|
||||||
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
_select_and_interact(idx)
|
||||||
|
)
|
||||||
|
lbl.mouse_entered.connect(func(): _hover_index(idx))
|
||||||
|
lbl.mouse_exited.connect(func(): _unhover_index(idx))
|
||||||
_vbox.add_child(lbl)
|
_vbox.add_child(lbl)
|
||||||
_verb_labels.append(lbl)
|
_verb_labels.append(lbl)
|
||||||
|
|
||||||
|
|
||||||
|
## Cache the target entity's world tile position from GameState.visible_entities.
|
||||||
|
func _cache_entity_position() -> void:
|
||||||
|
for entity in GameState.visible_entities:
|
||||||
|
if entity.get("entity_id") == _current_target_id:
|
||||||
|
_entity_world_pos = Vector2(entity.x, entity.y)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
## Convert entity world position to screen coords and reposition this Control.
|
||||||
|
## Runs every frame while showing so the list tracks the entity as the camera moves.
|
||||||
|
func _update_screen_position() -> void:
|
||||||
|
var camera := get_viewport().get_camera_2d()
|
||||||
|
if camera == null:
|
||||||
|
return
|
||||||
|
var viewport_size := get_viewport_rect().size
|
||||||
|
var cam_center := camera.get_screen_center_position()
|
||||||
|
var zoom: Vector2 = camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM
|
||||||
|
var world_px := _entity_world_pos * Constants.TILE_SIZE
|
||||||
|
var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0
|
||||||
|
# Anchor above the entity, centered horizontally
|
||||||
|
position = screen_pos + ENTITY_OFFSET * zoom - Vector2(size.x / 2.0, size.y)
|
||||||
|
|
||||||
|
|
||||||
func _show() -> void:
|
func _show() -> void:
|
||||||
if _showing:
|
if _showing:
|
||||||
return
|
return
|
||||||
@@ -161,6 +210,29 @@ func get_verb_labels() -> Array:
|
|||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
func _hover_index(idx: int) -> void:
|
||||||
|
_selected_index = idx
|
||||||
|
_update_label_colors()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhover_index(_idx: int) -> void:
|
||||||
|
pass # keep last hover highlighted
|
||||||
|
|
||||||
|
|
||||||
|
func _select_and_interact(idx: int) -> void:
|
||||||
|
if idx < 0 or idx >= _verb_items.size():
|
||||||
|
return
|
||||||
|
_selected_index = idx
|
||||||
|
verb_selected.emit(_verb_items[idx].get("kind", ""), _current_target_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _update_label_colors() -> void:
|
||||||
|
for i in range(_verb_labels.size()):
|
||||||
|
if is_instance_valid(_verb_labels[i]):
|
||||||
|
_verb_labels[i].add_theme_color_override(
|
||||||
|
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||||
|
|
||||||
|
|
||||||
func set_insert_active(active: bool) -> void:
|
func set_insert_active(active: bool) -> void:
|
||||||
_insert_active = active
|
_insert_active = active
|
||||||
if not active and _showing:
|
if not active and _showing:
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func _show_line(text: String, duration: float, priority: int, is_urgent: bool, l
|
|||||||
node = line_node,
|
node = line_node,
|
||||||
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
||||||
priority = priority,
|
priority = priority,
|
||||||
tween = null as Tween,
|
tween = null,
|
||||||
}
|
}
|
||||||
_visible.append(slot)
|
_visible.append(slot)
|
||||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cpjq8yfsnpr5m
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
|
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
|
||||||
[node name="WorldRadial" type="Control"]
|
[node name="WorldRadial" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 0
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
|
|||||||