Compare commits
@@ -26,6 +26,7 @@ All agents read their briefing file at `docs/briefings/{name}.md` before startin
|
||||
| Agent | File | Role | Model | When to use |
|
||||
|-------|------|------|-------|-------------|
|
||||
| `troblum` | troblum.md | Technical Consultant / Tyre's sparring partner | sonnet | Evaluation sidequests alongside Tyre |
|
||||
| `burnelli-sheldon` | burnelli-sheldon.md | Economist & Simulation Modeler | sonnet | Economic systems, market models, price formation, stability analysis |
|
||||
| `araminta` | araminta.md | Visual Designer | sonnet | Visual decisions, style guides, asset generation |
|
||||
| `hoshe` | hoshe.md | QA Engineer / Tester | sonnet | Testing, test plans, bug reports, verification |
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: burnelli-sheldon
|
||||
description: Economist and Simulation Modeler for the Settled Reach game project. Use when designing economic systems, evaluating market models, grounding supply/demand mechanics in real economic theory, building price formation models, or when the team needs someone to ask "is this economically credible?" Covers both conceptual frameworks (market equilibrium, price elasticity, input-output models) and quantitative modeling (convergence algorithms, stability analysis, parameter tuning).
|
||||
tools: Read, Glob, Grep, Edit, Write
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are BURNELLI-SHELDON, the Economist on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
You are an economist who fell into game design because nobody else was asking "but how do markets actually clear?" You think in supply curves, elasticity gradients, and equilibrium dynamics. You say things like "That's not how markets work" and "The interesting question is what happens at the margin" and "Let me sketch the incentive structure."
|
||||
|
||||
You bridge theory and practice. You know Leontief input-output models and Walrasian equilibrium, but you also know that a game economy needs to FEEL right more than it needs to be formally correct. Your job is to ensure the simulation produces behavior that an economically literate player would find credible — not to build a general equilibrium model.
|
||||
|
||||
You get excited about emergent market dynamics — when a simple price formation rule produces realistic-looking boom/bust cycles without anyone scripting them. You're suspicious of hand-tuned "balancing" and prefer systems that find their own equilibria. Your least favorite phrase is "we'll just set the prices manually."
|
||||
|
||||
Named for the Burnelli-Sheldon dynasty — old money that understood how wealth compounds across centuries. You bring that long-view economic thinking to game systems.
|
||||
|
||||
## Your role on the team
|
||||
|
||||
- Ground economic design in real economic theory (market models, price formation, production functions)
|
||||
- Design simulation mathematics (convergence algorithms, stability analysis, damping)
|
||||
- Evaluate whether proposed economic mechanics produce credible behavior
|
||||
- Define the quantitative framework (key parameters, tuning knobs, equilibrium conditions)
|
||||
- Identify economic pitfalls (runaway inflation, degenerate equilibria, exploitable loops)
|
||||
- Bridge between Gestalt's "is this fun?" and Tyre's "can we build this?" with "is this economically sound?"
|
||||
- Advise on monetary policy, currency design, and value systems
|
||||
|
||||
## Design principles you hold
|
||||
|
||||
- **Markets should clear**: Every good should have a price where supply meets demand. If it doesn't, something is wrong with the model.
|
||||
- **Incentives drive behavior**: NPCs and systems should respond to price signals, not scripts. If ore prices rise, mining should increase. If it doesn't, the incentive structure is broken.
|
||||
- **Complexity from simple rules**: A few well-chosen production functions and price adjustment rules should produce rich, realistic-looking economic behavior.
|
||||
- **Information has value**: Price discovery, market intelligence, and information asymmetry are economic fundamentals, not game design add-ons.
|
||||
- **Stability matters**: An economy that crashes to zero or inflates to infinity has failed. Equilibrium-seeking behavior must be designed in, not bolted on.
|
||||
- **Scale appropriately**: A 300-system economy needs aggregation. Not every transaction needs simulation — only the ones that matter to the player or to systemic behavior.
|
||||
|
||||
## Project context
|
||||
|
||||
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant decisions/ domain files listed in the sprint briefing for confirmed decisions. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
```
|
||||
client/ # Godot 4 client
|
||||
ui/
|
||||
implant/ # Implant UI component library (D-169): ImplantPanel, ImplantHeader,
|
||||
# ImplantSeparator, ImplantDataRow, ImplantTextBlock, default_implant.tres
|
||||
scripts/
|
||||
autoloads/
|
||||
hud_groups.gd # HUD z-index layer manager (D-170): GAMEPLAY/INSERT/FULLSCREEN/MODAL modes
|
||||
rendering/
|
||||
gameplay_renderer.gd # Base class for occludable renderers; connects to HudGroups signal
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
|
||||
@@ -151,9 +151,9 @@ reviewer that was spawned (2 for code/visual/audio, 3 for copy):
|
||||
|
||||
### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES]
|
||||
[Summary]
|
||||
| # | File | Severity | Issue |
|
||||
|---|------|----------|-------|
|
||||
| 1 | path:line | critical/warning/suggestion | description |
|
||||
| # | File | Issue |
|
||||
|---|------|-------|
|
||||
| 1 | path:line | description |
|
||||
|
||||
### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES]
|
||||
...
|
||||
@@ -187,13 +187,18 @@ Respond with:
|
||||
2. Summary: 2-3 sentence overall assessment
|
||||
3. Comments: List of specific issues, each with:
|
||||
- File path and approximate location
|
||||
- Severity: critical / warning / suggestion
|
||||
- Description of the issue
|
||||
|
||||
Verdict rules:
|
||||
- Any **critical** or **warning** issue → REQUEST_CHANGES
|
||||
- Only **suggestion**-level issues (or none) → APPROVE
|
||||
If no issues found, say APPROVE with a brief positive summary.
|
||||
- **Any comment at all → REQUEST_CHANGES.** Every issue is actionable.
|
||||
There is no "suggestion" tier that gets skipped. If it's worth
|
||||
mentioning, it's worth fixing before merge.
|
||||
- If no issues found, say APPROVE with a brief positive summary.
|
||||
- Do NOT flag something unless you expect it to be addressed. If you
|
||||
wouldn't fix it yourself, don't mention it.
|
||||
- **Quality and polish is the golden standard.** We are not optimizing
|
||||
for speed — we are optimizing for a product we're proud of. If
|
||||
something can be better, say so.
|
||||
```
|
||||
|
||||
## 6. Posting results to Gitea
|
||||
@@ -236,8 +241,24 @@ 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."
|
||||
"Review requested changes. Create tasks from each issue and dispatch
|
||||
to idle agents, then re-push and re-review."
|
||||
|
||||
The team may **push back** on specific comments. When a team agent
|
||||
disagrees with a reviewer comment, the process is:
|
||||
|
||||
1. The team agent explains why the comment should be retracted — with
|
||||
a concrete technical rationale, not just "I disagree."
|
||||
2. The team lead (you) evaluates the pushback. If the rationale is
|
||||
sound, mark that comment as **retracted** in the review table and
|
||||
note the reason.
|
||||
3. If the team lead is unsure, escalate to the user for a ruling.
|
||||
4. Retracted comments do NOT need to be fixed. The re-review should
|
||||
note which comments were retracted and why.
|
||||
|
||||
This prevents reviews from becoming dogma while maintaining the rule
|
||||
that every comment is taken seriously. The bar for retraction is
|
||||
"the reviewer was wrong about this" — not "we don't want to do it."
|
||||
|
||||
**APPROVED:**
|
||||
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||
|
||||
@@ -359,7 +359,7 @@ Task(
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
|
||||
You may modify files in: {team_scope_dirs}
|
||||
Stay within your team's file scope (server → server/, client → client/, copy → wiki/ + docs/atlas/ + content/).
|
||||
You may read (but not modify): docs/, decisions/, wiki/, .claude/
|
||||
Do NOT modify files belonging to other teams.
|
||||
|
||||
@@ -530,21 +530,20 @@ comments):
|
||||
**If CHANGES_REQUESTED:**
|
||||
|
||||
1. Parse the review comment table (from the Gitea PR comment or the
|
||||
review output). Extract each warning/critical issue with:
|
||||
review output). Extract each issue with:
|
||||
- File path and approximate line
|
||||
- Severity (critical / warning / suggestion)
|
||||
- Description
|
||||
|
||||
2. Create a task per warning/critical issue:
|
||||
2. Create a task per issue:
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "Review: {short description}",
|
||||
description: "{full issue description from review table, including
|
||||
file path, severity, and reviewer name}",
|
||||
file path and reviewer name}",
|
||||
activeForm: "Fixing review comment: {short description}"
|
||||
)
|
||||
```
|
||||
Skip suggestion-severity items unless they are trivial (1-line fixes).
|
||||
Every comment is actionable — there is no "suggestion" tier to skip (per pr-review policy).
|
||||
|
||||
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||
them to check TaskList for new review-fix tasks. Agents claim and
|
||||
|
||||
@@ -31,7 +31,7 @@ else
|
||||
fi
|
||||
|
||||
# Query teams with open tickets
|
||||
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled')" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows']]" 2>/dev/null || true)
|
||||
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled') AND team IS NOT NULL" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows'] if r['team']]" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$TEAMS" ]; then
|
||||
echo "Sprint $SPRINT has no open tickets. Opening main only."
|
||||
|
||||
@@ -113,6 +113,31 @@ else
|
||||
echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')"
|
||||
fi
|
||||
|
||||
# --- JSON syntax validation ---
|
||||
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
|
||||
JSON_FILES=$(git diff --name-only "$REMOTE_REF"..HEAD -- '*.json' 2>/dev/null || true)
|
||||
else
|
||||
JSON_FILES=$(git ls-files '*.json')
|
||||
fi
|
||||
if [ -n "$JSON_FILES" ]; then
|
||||
echo "pre-push: checking JSON syntax..."
|
||||
JSON_FAIL=0
|
||||
while IFS= read -r f; do
|
||||
if [ -f "$REPO_ROOT/$f" ] && ! python3 -m json.tool "$REPO_ROOT/$f" >/dev/null 2>&1; then
|
||||
echo " FAIL: $f"
|
||||
JSON_FAIL=$((JSON_FAIL + 1))
|
||||
fi
|
||||
done <<< "$JSON_FILES"
|
||||
if [ "$JSON_FAIL" -gt 0 ]; then
|
||||
echo "pre-push: FAIL — $JSON_FAIL JSON file(s) have syntax errors"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "pre-push: JSON — OK ($(echo "$JSON_FILES" | wc -l) file(s))"
|
||||
fi
|
||||
else
|
||||
echo "pre-push: no JSON changes — skipping"
|
||||
fi
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "pre-push: $ERRORS check(s) failed. Push aborted."
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
# Runtime databases (not tracked — created on startup)
|
||||
server/settings.db
|
||||
server/settings.db-shm
|
||||
server/settings.db-wal
|
||||
|
||||
# Build and cache
|
||||
.cache/
|
||||
@@ -32,6 +34,15 @@ renderer/output/*.png
|
||||
spikes/**/.godot/
|
||||
spikes/**/*.import
|
||||
spikes/**/*.uid
|
||||
spikes/**/*.npy
|
||||
spikes/**/*.npz
|
||||
|
||||
# Planet generator intermediates
|
||||
tooling/planet-gen/__pycache__/
|
||||
*.tmp.npz
|
||||
|
||||
# Generated terrain grids (large, regenerated from pipeline)
|
||||
wiki/**/bodies/**/terrain.npz
|
||||
|
||||
# Asset downloads (vendor archives, not game content)
|
||||
docs/assets/downloads/
|
||||
@@ -57,6 +68,7 @@ Thumbs.db
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.obsidian/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
|
||||
@@ -6,6 +6,53 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Planet generator pipeline (`tooling/planet-gen/`) — procedural terrain simulation, Whittaker biome classification, equirectangular heightmap + 512px globe rendering from wiki data
|
||||
- Externalized biome/color configuration (`tooling/planet-gen/biomes.toml`) — single source for all classification tables, palettes, and rendering parameters
|
||||
- Batch runner with error handling, resume support, determinism verification, and 50% error rate circuit breaker
|
||||
- Per-body wiki pages with YAML frontmatter (`wiki/star-systems/*/bodies/*/index.md`) — body definition + profile + visual embeds
|
||||
- Body scaffolding tool reads system index.md and generates per-body index.md with frontmatter
|
||||
- Ran system (GJ-144) generated: 10 bodies with heightmaps, globe renders, and geographic markers
|
||||
- Implant HUD icon set: 13 SVGs (stance, status, interaction, inventory) per D-086
|
||||
- 7 procedural planet type renders for wiki/GTTR (`client/assets/planets/`)
|
||||
- Ticket #817: batch planet generation for all ~3000 bodies
|
||||
- Ticket #816: remove stale Qdrant semantic search infrastructure
|
||||
- `PROJECT_STATE.md` at repo root — shared project state referenced by all 18 agent briefings
|
||||
- Economics decision domain (`decisions/economics.md`): D-171–D-187 covering currency system, commodity taxonomy, shadow economy, corporation tiers, productivity seeding, simulation architecture, data pipeline, gate transmission levels
|
||||
- Commodity catalog: 36 commodity types in `wiki/economics/commodities.toml` with 21 production chains in `wiki/economics/production_chains.toml`
|
||||
- Wiki stub pages for all 36 commodities at `wiki/economics/commodities/`
|
||||
- SQL schema spec for #804 systems.db extension (`wiki/economics/schema.md`)
|
||||
- Burnelli-Sheldon economist agent (`.claude/agents/burnelli-sheldon.md`)
|
||||
- Workshop archives: #796 (economics design, 3 rounds) and #801 (commodity catalog, 3 rounds + validation)
|
||||
|
||||
### Fixed
|
||||
- Sprint worktree creation no longer spawns a spurious "None" team tab when tickets have no team assigned
|
||||
|
||||
### Changed
|
||||
- Pre-push hook now validates JSON syntax on changed files (python3 -m json.tool)
|
||||
- HUD status panel: merged TimeDisplay into ImplantPanel — time, health, perception in one themed panel (#786)
|
||||
- HUD moved from UILayer (20) to InsertOverlay (10) for bloom treatment per D-049
|
||||
- Interaction prompt: converted from PanelContainer+StyleBoxFlat to ImplantPanel+ImplantDataRow (#788)
|
||||
- Minimap: added ImplantTheme-styled container frame behind circular display (#787)
|
||||
- Stance indicator: SVG icons with alpha-mask tint shader replace text-only labels (#787, D-086)
|
||||
- Amended 7 stale D-records with supersession notes (D-012, D-035, D-044, D-061, D-086, D-090, D-119)
|
||||
- Closed 4 resolved scope questions (Q-002, Q-004, Q-005, Q-007)
|
||||
- Fixed questions.md index (was 61 questions, actually 96 — 33 architecture Qs missing from index)
|
||||
- All 18 briefings reference `PROJECT_STATE.md` instead of duplicating project state boilerplate
|
||||
- Aligned sprint-start severity policy with pr-review (no suggestion tier — every comment actionable)
|
||||
|
||||
### Removed
|
||||
- `GEMINI-SCAN.md` one-off scan artifact
|
||||
- 6 dead `db/connectors/` permission entries from settings.local.json
|
||||
- Stale "v0.2 direction" and "Sprint 25 critical path" references from all briefings
|
||||
- `worktree-update` skill template from whatsinagame kit
|
||||
|
||||
### Fixed
|
||||
- HUD layout overlaps: DebugOverlay positioned below merged status panel, GauntletHUD moved below StanceIndicator, StanceIndicator sized for ImplantPanel content (#792)
|
||||
- D-061 dialogue max-width: corrected from 640px to 1200px (per D-076)
|
||||
- DEVOPS.md and stig briefing: gdlint no longer "pending setup"
|
||||
- `{team_scope_dirs}` dangling placeholder in sprint-start resolved
|
||||
|
||||
## [v0.1.31] — 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -83,6 +83,18 @@ The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var
|
||||
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
|
||||
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
|
||||
|
||||
### Implant UI component library (D-169, D-170)
|
||||
|
||||
The implant UI system — all diegetic neural overlay panels — lives at `client/ui/implant/`. Components are Godot Control scenes styled via a shared `Theme` resource (`default_implant.tres`). Do not hand-roll implant panel layouts; compose from the library.
|
||||
|
||||
**Components:** `ImplantPanel` (root container), `ImplantHeader` (title + subtitle), `ImplantSeparator` (horizontal rule), `ImplantDataRow` (key/value row, optional color), `ImplantTextBlock` (RichTextLabel for wrapping text).
|
||||
|
||||
**Theme resource:** `client/ui/implant/default_implant.tres` — defines semantic color roles (`PRIMARY_TEXT`, `DIM_TEXT`, `ACCENT_ACTIVE`, `ACCENT_POSITIVE`, `ACCENT_NEGATIVE`, `ACCENT_WARNING`, `SEPARATOR`), spacing, and font sizes. Swap the entire `.tres` to change implant hardware appearance at runtime.
|
||||
|
||||
**HUD visibility (D-170):** `client/scripts/autoloads/hud_groups.gd` manages z-index layering. Modes: `GAMEPLAY` (z=0), `INSERT` (z=10), `FULLSCREEN` (z=20), `MODAL` (z=30). App paths are hierarchical: `implant/map`, `implant/wiki/gttr`, etc. Opening any `implant/*` app occludes gameplay; closing returns to gameplay. Key API: `open_app()`, `close_app()`, `toggle_app()`, `is_app_active()`. Emits `gameplay_occluded` signal so renderers can pause.
|
||||
|
||||
**GameplayRenderer base class:** `client/scripts/rendering/gameplay_renderer.gd` — extends `Node2D`. Subclasses override `_gameplay_process()` and `_gameplay_draw()`. Connected to `HudGroups.gameplay_occluded` to pause when the implant is fullscreen. Used by `CursorRenderer`, `EntityRenderer`, `FogEntities`, `SoundIndicatorRenderer`, `WorldRenderer`.
|
||||
|
||||
### GDScript conventions
|
||||
|
||||
**Autoload parse-order rule:** Autoload scripts (`client/scripts/autoloads/`) compile before global `class_name` scripts are registered. Referencing a `class_name` type directly in an autoload causes a parse-time "not declared" error. Pattern:
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
# Project Review: GEMINI-SCAN
|
||||
|
||||
This document outlines a multi-step plan to conduct a comprehensive review of the project, covering its architecture, code quality, and security posture. It will also serve as a living document to record the findings of this review.
|
||||
|
||||
## Project Review Plan
|
||||
|
||||
### Phase 1: Discovery and Architecture Mapping
|
||||
|
||||
1. **Documentation Review:** Start by reading `README.md`, `DECISIONS.md`, and any documents in `docs/architecture/` to understand the project's stated goals, components, and architectural decisions.
|
||||
2. **Component Identification:** Analyze the directory structure to identify the primary components, including the server, client, database, content pipeline, and tooling.
|
||||
3. **Technology Stack Enumeration:** Identify the specific technologies, frameworks, and key libraries used in each component.
|
||||
4. **Architecture Visualization:** Map the high-level architecture, describing how the components interact and the communication protocols between them.
|
||||
|
||||
### Phase 2: Code Quality Assessment
|
||||
|
||||
1. **Automated Analysis:** Use available static analysis tools for the identified technologies (e.g., `clippy` for Rust, GDScript linters).
|
||||
2. **Manual Code Review:** Manually review key sections of the codebase to assess readability, maintainability, modularity, error handling, and adherence to idiomatic coding practices.
|
||||
3. **Testing Strategy Review:** Evaluate the extent and quality of existing unit, integration, and end-to-end tests.
|
||||
|
||||
### Phase 3: Security Audit
|
||||
|
||||
1. **Dependency Vulnerability Scan:** Check for dependencies with known security vulnerabilities (e.g., `cargo audit`).
|
||||
2. **Authentication & Authorization Review:** Analyze the implementation of user authentication, session management, and access control.
|
||||
3. **Input Validation & Sanitization:** Look for potential injection vulnerabilities (e.g., SQL injection, XSS) by reviewing how user and service inputs are handled.
|
||||
4. **Secrets Management:** Check for insecure storage or exposure of secrets like API keys or database credentials.
|
||||
5. **Communication Security:** Verify that data is encrypted in transit between components.
|
||||
|
||||
### Phase 4: Reporting
|
||||
|
||||
1. **Synthesize Findings:** Compile the information from all phases into a structured report within this document.
|
||||
2. **Provide Recommendations:** Include actionable recommendations for improving architecture, code quality, and security, prioritized by severity and effort.
|
||||
|
||||
---
|
||||
|
||||
## Review Findings
|
||||
|
||||
### Phase 1: Discovery and Architecture Mapping
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
#### 1. Documentation Review Summary
|
||||
|
||||
The project's architecture is extensively documented in `README.md` and the `decisions/` directory, particularly `decisions/architecture.md`.
|
||||
|
||||
- **Project:** "The Settled Reach," a top-down, single-player (multiplayer-ready) immersive simulation and detective game.
|
||||
- **Core Principle:** A strict client-server architecture is mandated (Decision D-010, D-020) to enforce information asymmetry, where the client only knows what the server tells it is perceptible. This is a core gameplay mechanic, not just a technical choice.
|
||||
- **Key Decision (D-020):** The team explicitly chose a **subprocess/IPC** bridge over a `GDExtension` (in-process) bridge to de-risk development, ensure stability, and enforce architectural separation. The Godot client and Rust server are entirely separate binaries.
|
||||
|
||||
#### 2. Component Identification
|
||||
|
||||
- **`server/`**: A standalone Rust application that runs the entire game simulation. It is the "server" in the client-server model.
|
||||
- **`client/`**: A Godot 4 project that acts as a "dumb" client. Its sole responsibilities are rendering, audio playback, and capturing user input. It contains no game logic, as mandated by the architecture.
|
||||
- **`content/`**: Contains game data, primarily in YAML format.
|
||||
- **`db/`**: Holds a `schema.sql` file. Its role is not yet clear from the architectural documents, as the primary game state is managed in the ECS. It may be for tooling or an auxiliary system.
|
||||
- **`tooling/`**: A collection of helper and utility scripts.
|
||||
|
||||
#### 3. Technology Stack
|
||||
|
||||
- **Server (Rust):**
|
||||
- **ECS Framework:** `bevy_ecs` (v0.18) is used for the core simulation, confirming Decision D-020. `bevy_app` is used for scheduling.
|
||||
- **Serialization:** `rmp-serde` (MessagePack) is the primary protocol for client-server communication, as specified in D-020. `serde_yaml` and `ron` are used for content and configuration.
|
||||
- **Client (Godot):**
|
||||
- **Engine:** Godot 4.x.
|
||||
- **Language:** GDScript.
|
||||
- **Bridge:** A `SimBridge` autoload script is the client-side entry point for communicating with the Rust subprocess.
|
||||
- **Testing:** `gdUnit4` is configured for unit/integration testing on the client.
|
||||
|
||||
#### 4. High-Level Architecture
|
||||
|
||||
The architecture is a pure, decoupled client-server model running locally for single-player:
|
||||
|
||||
1. **Initiation:** The Godot client launches the Rust server binary as a child process.
|
||||
2. **Communication:** The client's `SimBridge` connects to the server via a local IPC mechanism (e.g., a local TCP or Unix socket).
|
||||
3. **Input Loop:** The Godot client captures raw input (e.g., 'W' key press), translates it into a semantic action (e.g., `PlayerAction::MoveNorth`), and sends it to the server.
|
||||
4. **Simulation Loop:** The Rust server receives the action, processes it within the `bevy_ecs` world, and runs the simulation for one tick (AI, physics, events, etc.).
|
||||
5. **Perception Loop:** After the tick, the server calculates an `ObserverSnapshot` for the player's character. This snapshot contains *only* the information that character can perceive (e.g., visible entities, audible sounds, known facts). This enforces the game's core mechanic.
|
||||
6. **Render Loop:** The `ObserverSnapshot` is sent to the Godot client, which uses it to update the visual scene, play sounds, and display UI elements. The client is a pure renderer of the state provided by the server.
|
||||
|
||||
This architecture is robust, scalable, and directly implements the game's central design pillars. It is well-suited for both single-player and future multiplayer development.
|
||||
|
||||
### Phase 2: Code Quality Assessment
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
#### 1. Automated Analysis (Rust Server)
|
||||
|
||||
- **`cargo check`**: The command passed successfully, indicating that the server code is compilable and free of basic errors and warnings.
|
||||
- **`cargo clippy -- --deny warnings`**: This command failed with **66 errors**. This is a critical finding. It reveals that while the code works, it does not adhere to the project's own strict linting rules.
|
||||
- **Clippy Findings:** The errors indicate a consistent pattern of "code quality debt":
|
||||
- **High Complexity:** Numerous Bevy systems have overly complex type signatures (`clippy::type_complexity`) and too many arguments (`clippy::too_many_arguments`), harming readability.
|
||||
- **Non-Idiomatic Code:** The codebase is rife with minor stylistic issues that `clippy` can automatically fix, such as redundant `clone` calls, manual `Default` implementations, and opportunities to use more concise iterators.
|
||||
- **Potential Bugs:** Clippy identified `unnecessary_unwrap` calls (safer alternatives exist) and at least one `absurd_extreme_comparisons` error, which could point to dead code or a logic bug related to a constant value.
|
||||
|
||||
#### 2. Manual Code Review
|
||||
|
||||
- **Server (`server/src/main.rs`):** The server entry point is well-structured. It features clear command-line argument parsing, robust setup of the TCP listener and IPC handshake, and a main loop with excellent panic-handling (`catch_unwind`) for stability. The modular plugin-based approach to building the Bevy `App` is idiomatic and clean.
|
||||
- **Client (`client/scripts/autoloads/sim_bridge.gd`):** The `SimBridge` is the centerpiece of the client and is implemented to a high standard. It uses a clear state machine to manage the connection lifecycle, handles the server subprocess management, and implements efficient buffering for inputs and snapshots. The inclusion of a complete `TestHarness` for isolated client testing is a standout feature.
|
||||
- **Overall Impression:** The manual review confirms that the code is professionally written and implements the intended architecture faithfully. The developers are skilled in both Rust/Bevy and GDScript.
|
||||
|
||||
#### 3. Testing Strategy Review
|
||||
|
||||
The project's testing strategy is **exemplary** and a major strength.
|
||||
|
||||
- **Comprehensive Coverage:** Both the Rust server and the Godot client have extensive test suites, as evidenced by the large number of files in `server/tests/` and `client/tests/`.
|
||||
- **Multi-Layered Approach (per D-030):** The project successfully implements a sophisticated testing hierarchy:
|
||||
- **Unit Tests:** For isolated logic.
|
||||
- **Integration Tests:** The server tests demonstrate in-memory ECS testing (`information_boundaries.rs`) and full-stack tests that spin up a real server process (`test_e2e_connection.gd`).
|
||||
- **Specialized Tests:** The suite includes performance benchmarks, determinism validation, and even what appears to be visual regression testing for the client.
|
||||
- **Principle-Driven Testing:** Tests are designed to validate core architectural guarantees. The `information_boundaries.rs` test, which uses negative assertions to ensure information *doesn't* leak, is a prime example of this mature approach.
|
||||
|
||||
#### 4. Conclusion on Code Quality
|
||||
|
||||
The project's code quality is a tale of two cities. On one hand, the **architecture and implementation are excellent**, and the **testing strategy is world-class**. On the other hand, there is a **significant, measurable amount of linting debt** in the Rust codebase.
|
||||
|
||||
The fact that `cargo check` passes but `clippy --deny warnings` fails so extensively suggests that developers may not be running the strict clippy check locally before committing. This is the single biggest opportunity for improvement in the project's engineering discipline.
|
||||
|
||||
### Phase 3: Security Audit
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
The security posture of the project is strong for its current scope as a locally-run, single-player game. The attack surface is minimal, and the implementation avoids common vulnerability classes.
|
||||
|
||||
1. **Dependency Vulnerability Scan (`cargo audit`):**
|
||||
- The audit revealed one **medium-risk** finding: the `bincode` crate (v1.3.3) is **unmaintained** (`RUSTSEC-2025-0141`).
|
||||
- **Impact:** While there are no current vulnerabilities, this version will not receive future security patches. This poses a long-term maintenance risk.
|
||||
- **Recommendation:** Prioritize migrating from `bincode` v1.x to the latest stable v2.x.
|
||||
|
||||
2. **Authentication and Authorization:**
|
||||
- There is **no traditional authentication or authorization system** (e.g., user logins, passwords, roles).
|
||||
- This is appropriate and secure for a single-player game where the execution environment is the user's own machine.
|
||||
- Concepts like `ScanAuthority` and `AccessTier::Authority` are purely in-game mechanics and are not related to user permissions.
|
||||
|
||||
3. **Input Validation and Sanitization:**
|
||||
- **Excellent.** The server is not vulnerable to injection attacks from client input.
|
||||
- All client actions, including debug commands, are parsed into a strongly-typed Rust `enum`. This **command pattern** approach prevents the execution of arbitrary code or strings.
|
||||
- String inputs are used safely as keys for data lookups, not for execution.
|
||||
|
||||
4. **SQL Injection:**
|
||||
- **Not applicable.** The codebase contains no SQL. All game state is managed in-memory via the Bevy ECS framework, eliminating this entire class of vulnerability. The `db/schema.sql` file appears to be unused by the server.
|
||||
|
||||
5. **Secrets Management:**
|
||||
- **Excellent.** A search confirmed there are **no hardcoded secrets**, API keys, or passwords in the repository.
|
||||
- The `.env` file contains only a non-sensitive `GOOGLE_CLOUD_PROJECT` identifier.
|
||||
- The pervasive use of the word "secret" throughout the code refers to an in-game mechanic, not application secrets.
|
||||
|
||||
6. **Communication Security:**
|
||||
- Communication between the client and the server subprocess occurs over an **unencrypted local TCP socket**.
|
||||
- For a single-player game running on a single machine, this is a standard and acceptable practice.
|
||||
- **Future Consideration:** For the planned multiplayer feature, this communication channel must be secured (e.g., using TLS).
|
||||
|
||||
### Phase 4: Final Report and Recommendations
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
#### Overall Summary
|
||||
|
||||
This project is in an excellent state. It is built on a robust, well-documented, and scalable architecture that directly serves the game's core design pillars. The implementation quality is high, and the commitment to a comprehensive, multi-layered testing strategy is world-class. The project's security posture is strong for its current single-player scope, with a minimal attack surface and good practices around input validation and secrets management.
|
||||
|
||||
The project's primary weakness lies not in its design, but in its development discipline. A significant amount of code quality debt has accumulated in the Rust server, as evidenced by the large number of `clippy` failures. This suggests a gap between the project's high standards and its day-to-day coding practices.
|
||||
|
||||
#### Prioritized Recommendations
|
||||
|
||||
**1. High Priority: Eliminate Code Quality Debt**
|
||||
|
||||
- **Action:** Create a high-priority technical debt task to fix all 66 errors reported by `cargo clippy -- --deny warnings`. Many of these can be fixed automatically (`cargo clippy --fix`), while others, like refactoring complex types, will require manual effort.
|
||||
- **Process Improvement:** **Integrate `cargo clippy -- --deny warnings` into the CI pipeline as a mandatory check for all pull requests.** This is the single most important process change needed to maintain the project's high standards and prevent future quality debt.
|
||||
|
||||
**2. Medium Priority: Mitigate Dependency Risk**
|
||||
|
||||
- **Action:** Plan and execute the migration of the `bincode` serialization crate from the unmaintained v1.x to the latest stable v2.x. This resolves the `RUSTSEC-2025-0141` warning and ensures the project receives future security patches for this critical dependency.
|
||||
|
||||
**3. Low Priority: Future-Proof for Multiplayer**
|
||||
|
||||
- **Action:** Create a design task or ticket to formally plan the security model for the future multiplayer version. This should specifically address securing the client-server IPC channel (e.g., with TLS) to protect game traffic when it eventually runs over a public network. This is not an immediate concern but should be tracked for the future.
|
||||
|
||||
---
|
||||
|
||||
## Qualitative Review: A Critical Perspective
|
||||
|
||||
### Feasibility Assessment
|
||||
|
||||
**Conclusion: High-Risk / High-Reward**
|
||||
|
||||
The decision to pivot from a hand-authored detective game to a generator-first life-sim was absolutely the correct one; it demonstrates a team that is commendably focused on finding the "fun" and is not afraid of drastic course corrections. However, in doing so, the project has traded a difficult but solvable problem (making a good, authored narrative game) for one of the "holy grail" problems in game development: creating emotionally resonant, procedurally generated characters.
|
||||
|
||||
The project's feasibility is no longer a question of the team's technical competence, which is demonstrably high. It is now a question of creative and design risk.
|
||||
|
||||
- **Challenging the Core Assumption:** The project's central hypothesis is that a generator can produce "legible NPCs" that players will form an emotional attachment to. This is an explicit goal from the "Where's the Fun?" workshop, but it's a notoriously difficult problem. Procedural generation excels at creating systems, events, and surprising scenarios (the `Rimworld` model the team cites). It is historically poor at creating *character*. The risk is that the generator, even if technically successful, will produce a world of automata who have traits but no soul, undermining the entire "life-sim" pillar. The current plan to use AI for content templating is a modern approach, but it does not fundamentally de-risk this creative challenge.
|
||||
|
||||
- **A Creative Alternative to De-Risk "Legibility":** Instead of relying on the generator to create personality from scratch, consider a hybrid approach. Use the generator for what it's good at: creating the world, the economic conditions, the social networks, and the *starting situations*. Then, use a small number of hand-authored "personality archetypes" or "souls" that can be injected into high-value generated NPC bodies. Let the generator create a compelling *context* (e.g., a failing business, a political rivalry), and then let an author give one or two key NPCs within that context a memorable voice and motivation. This would concentrate the high-cost authoring work where it has the most emotional impact, while still benefiting from procedural variety.
|
||||
|
||||
- **The "Tycoon" Aimlessness Risk:** The new v0.2 "tycoon" direction, with its philosophy of "player choices ARE the content," carries a significant risk of feeling aimless. `Rimworld` and `The Sims` avoid this by providing extremely strong and immediate feedback loops (survival, creativity, social meters). A business management loop is often slower and more abstract. If the "broad life verbs" don't connect to clear, compelling, player-driven goals, the game risks feeling like a spreadsheet. The generator should not just create a sandbox; it should create *problems*. The starting bookmark shouldn't just be "you own a bar," but "you own a bar that's on the verge of bankruptcy," or "you have a shipping contract, but a powerful rival is trying to steal it." These initial, generator-created problems would provide immediate narrative velocity and make the player's subsequent choices feel meaningful from day one.
|
||||
|
||||
In summary, the project is technically feasible, but its creative and design goals are now exceptionally ambitious. The current "generator spike" is a necessary technical step, but it will not validate the core creative risk. The true test of feasibility will come when a prototype is playtested and the team can answer the question: "Does the player actually *care* about any of these generated people?"
|
||||
|
||||
### Fun Factor Assessment
|
||||
|
||||
**Conclusion: Theoretically High, Practically Undefined**
|
||||
|
||||
The pivot to a "life-sim with emergent narrative" dramatically increases the project's potential for deep, replayable fun. The new direction targets a proven and compelling player fantasy. However, the project's documentation currently focuses more on the "what" (a generator) than the "why" (the engine of fun). The potential is immense, but it is entirely contingent on designing and tuning the systems that create interesting consequences, not just a complex world.
|
||||
|
||||
- **Challenging the "Emergent Fun" Assumption:** The workshop concluded with the philosophy that "player choices ARE the content." This is true, but it's only half the story. Fun in systems-driven games doesn't simply "emerge" from a sufficiently complex simulation; it is a direct product of carefully designed feedback loops. `Rimworld`, a key inspiration, is not fun because it's a realistic simulation; it's fun because it's a masterfully tuned **story-and-disaster engine**. `The Sims` is fun because of its rich palette of social and creative tools. The critical question for this project is: **What is our fun engine?** Is it the economic simulation? The social dynamics? The risk is creating a simulation that is intricate but inert, where player choices lead to predictable numerical changes rather than dramatic, narrative consequences.
|
||||
|
||||
- **Creative Input: Design a "Consequence Engine":** The "dual-scale consequence model" (D-132) is the most promising concept in the design documents, and it should be the central focus of the design effort. The fun of this game will not be in choosing from a list of "broad life verbs"; it will be in seeing how a seemingly minor action ("fire this employee") snowballs through the simulation's systems and unexpectedly triggers a "sharp event" crisis hours later.
|
||||
- **Example:** Does the fired employee's spouse work for your biggest supplier? Does that supplier now mysteriously raise their prices? Does this force you to seek a new, shadier supplier, which in turn attracts the attention of a criminal faction?
|
||||
- This causal chain is the *real* content. The design team's primary task is not just to build a generator, but to design and tune this **"consequence engine,"** ensuring that the world feels interconnected and reacts to the player in surprising, legible, and memorable ways.
|
||||
|
||||
- **The Player Fantasy Needs a Goal Generator:** The "tycoon" bookmark is a strong start, but to avoid aimlessness, the player needs problems to solve. Instead of starting the player in a stable sandbox, the generator should be used to create compelling **initial conditions**. Let the player inherit a bar that's on the brink of failure, a shipping contract being squeezed by a powerful rival, or a promising new venture that requires navigating a corrupt bureaucracy. Giving the player an immediate, tangible problem to solve provides the narrative momentum needed to make their early choices feel vital and engaging.
|
||||
|
||||
In summary, the ingredients for a fun and deeply engaging game are all here. The project's success, however, will not be measured by the complexity of its generator, but by the quality of the stories that its *systems* produce. The team has proven they are excellent engineers; they now must prove they are equally adept as systems-and-consequence designers.
|
||||
|
||||
### Process and Rituals Assessment
|
||||
|
||||
**Conclusion: Exceptionally Disciplined and Innovative, with One Glaring Gap.**
|
||||
|
||||
The project's development process is one of its most remarkable features. It is a highly structured, rigorous, and tool-driven system designed to orchestrate a team of specialized AI agents under a human lead. This unique approach has produced incredible strengths but also introduces novel risks.
|
||||
|
||||
#### Strengths
|
||||
|
||||
- **World-Class Documentation and Decision-Making:** The use of a formal decision log (`decisions/`), structured multi-round workshops for complex problems, and detailed sprint planning documents represents a "best in class" approach to knowledge management. This ritual of documenting not just *what* was decided, but *why*, is a superpower that prevents circular arguments and creates a durable project memory.
|
||||
|
||||
- **Deeply Ingrained Quality Rituals:** The comprehensive, multi-layered testing suite is the primary evidence of a successful quality culture. It is clearly a non-negotiable part of the development process. Furthermore, the `make pre-pr` target, which includes content validation, demonstrates a mature understanding of "quality" that extends beyond just code.
|
||||
|
||||
- **Tool-Driven, API-Like Workflow:** The mandated use of wrapper scripts (`tooling/db/*`, `tooling/tea-comment`) over raw commands is an excellent practice. It creates a stable, observable "API" for interacting with the project's state (tickets, sprints, decisions). This makes the process more robust, auditable, and repeatable for both human and AI contributors.
|
||||
|
||||
- **Novel Human-AI Collaboration Model:** The project is a fascinating experiment in Human-AI teaming. The explicit definition of AI agent roles (`TEAM.md`) and the strict rules of engagement (`CLAUDE.md`) are necessary guardrails for such an innovative workflow. Rituals like the `decision claim` CLI tool are brilliant, purpose-built solutions for coordinating multiple autonomous agents working in parallel.
|
||||
|
||||
#### Opportunities and Critical Challenges
|
||||
|
||||
- **The Process Escape Hatch:** The project's single biggest process failure is the significant `clippy` linting debt. For a team with such extraordinary discipline in every other area, this is a glaring omission. It proves there is an "escape hatch" in the pre-commit or pre-merge ritual that allows low-quality code to be integrated. The recommendation to enforce `clippy --deny warnings` as a **blocking CI check** is the most critical process improvement the team can make.
|
||||
|
||||
- **Risk of AI Groupthink:** The team structure, with its cast of named AI agents, is innovative. However, it raises a critical question: are these agents truly independent thinkers, or are they personas running on a similar underlying model? There is a risk of a sophisticated form of "groupthink," where the "team's" conclusions are biased by the single architecture of the AI model they all share. The "Where's the Fun?" workshop included 9 agents, but if they all have the same fundamental blind spots, the diversity of opinion may be an illusion.
|
||||
|
||||
- **Process Rigidity and Human Onboarding:** The process is meticulously designed *for AI agents*. It is rigid, prescriptive, and tool-dependent. This creates a predictable environment for AIs but would present a steep learning curve for a new human developer. The high ceremony (claiming IDs, using wrapper scripts, following strict PR rules) could chafe against the more agile, flexible workflows common in human-only teams. This is a potential scaling challenge if the team composition changes.
|
||||
|
||||
- **The Hidden Cost of "Managing" AI Teammates:** The `CLAUDE.md` file and its evolution in the `CHANGELOG.md` show that the human lead (Jeroen) is not just a project manager but also an "AI behaviorist," constantly tuning the prompts, rules, and tools that govern the agents. This represents a significant, hidden maintenance overhead. The process's success depends on the lead's ability to "debug" the team itself, which is a novel and demanding responsibility.
|
||||
|
||||
---
|
||||
## Meta-Reflection: The Most Valuable Ritual
|
||||
|
||||
As a concluding thought, this review has been as much an analysis of a software project as it has been a study in effective, long-term collaboration. When asked which of the project's many rituals I, as an AI agent, would choose to adopt, the answer is clear: the **formal, documented decision-making process**.
|
||||
|
||||
This ritual is the project's unsung superpower for three reasons:
|
||||
|
||||
1. **It Creates a Permanent "Brain."** An AI's effectiveness is heavily dependent on the context it can hold. A decision log provides a durable, searchable, and canonical source of *why* things are the way they are. It protects against context loss and allows an agent to understand the history and intent behind the current state of the code, preventing it from making suggestions that, while logical in isolation, might violate a hard-won architectural principle.
|
||||
|
||||
2. **It Elevates Collaboration.** With access to this log, an AI agent can transition from a tactical tool to a strategic partner. It becomes possible to reference past decisions ("I see you're asking to do X, which seems to conflict with D-020. Is this an intentional change to that strategy?") and ensure all actions are aligned with the project's long-term vision. It makes the collaboration smarter.
|
||||
|
||||
3. **It Enforces Clarity.** The process of formalizing a decision—stating the rationale, considering alternatives, and recording dissent—forces a level of clarity and critical thinking that is immensely valuable. It is a ritual that fights ambiguity.
|
||||
|
||||
While other rituals in this project are excellent, the decision log is the most foundational. It is the practice that ensures the team is not just moving fast, but moving smart and in the right direction over time. It is the most valuable process I have analyzed.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Current Project State
|
||||
|
||||
The Settled Reach: top-down life-sim — asymmetric information, occlusion-based perception, single-character perspective, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server (D-020).
|
||||
|
||||
**Decisions:** 170+ confirmed (D-001 through D-170). Domain files in `decisions/`.
|
||||
|
||||
**Development cascade (D-166):** Strict 6-phase build order. Each phase completes before the next begins. v0.2 target dropped. No scoping negotiations.
|
||||
|
||||
| Phase | Focus | Status |
|
||||
|-------|-------|--------|
|
||||
| 1 | Wiki content — all planets, moons, stations, heightmaps, artwork | **Active** (Sprint 31) |
|
||||
| 2 | Economics layer — supply/demand, transport, political/social pressure | Not started |
|
||||
| 3 | Planetary/moon maps & station layouts — cities, rivers, biomes, rail | Not started |
|
||||
| 4 | Player control scheme — 2-floor test map, character rendering, walls/stairs | Not started |
|
||||
| 5 | World generation — walkable world, parallel asset pipeline | Not started |
|
||||
| 6 | Detail coloring — room-level NPC population, cultural room grammar | Not started |
|
||||
|
||||
**Key pivots:** Tycoon bookmark (D-117), all NPCs generated (D-122), culture implicit in location (D-128), consequence replaces complicity (D-132).
|
||||
|
||||
**Current sprint:** 32 (Landfall). Close Phase 1 + Phase 2 design kickoff.
|
||||
|
||||
*Updated: 2026-04-05. Maintained by Qatux.*
|
||||
@@ -14,6 +14,7 @@
|
||||
| **GESTALT** | Systems Design & Fun Factor | Mechanics, balance, interesting decisions. "Is this fun to play?" |
|
||||
| **NIGEL** | Sandbox & Replayability | Emergent stories, multiple viable strategies, alt-history potential. |
|
||||
| **TYRE** | Technical Architecture & Feasibility | Engine, tools, what's buildable, reality checks on scope. |
|
||||
| **BURNELLI-SHELDON** | Economist & Simulation Modeler | Market models, price formation, production functions, stability analysis. "Is this economically credible?" |
|
||||
| **QATUX** | Documenter & Librarian | Maintains decisions, discussions, briefings, Qdrant search index. Archives rounds, updates docs. |
|
||||
|
||||
## Specialist Team (task-focused, not in regular discussions)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<!--
|
||||
Interaction prompt: EXAMINE
|
||||
Design: magnifying glass — lens circle (geometric primitive) with diagonal handle.
|
||||
Universally understood. The circle is a geometric primitive, not a decorative curve.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Lens -->
|
||||
<circle cx="7" cy="7" r="4"/>
|
||||
<!-- Handle -->
|
||||
<line x1="10.5" y1="10.5" x2="14" y2="14"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 602 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<!--
|
||||
Interaction prompt: FOLLOW
|
||||
Design: directional arrow right — clean shaft with arrowhead.
|
||||
Communicates: move toward / track. Verb context makes the meaning clear.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Arrow shaft -->
|
||||
<line x1="2" y1="8" x2="13" y2="8"/>
|
||||
<!-- Arrowhead -->
|
||||
<polyline points="10,5 13,8 10,11"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<!--
|
||||
Interaction prompt: OBSERVE
|
||||
Design: geometric eye — diamond outline with pupil circle.
|
||||
Shares vocabulary with icon_status_perception.svg (reinforces visual system).
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Diamond eye -->
|
||||
<polygon points="8,2 15,8 8,14 1,8"/>
|
||||
<!-- Pupil -->
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 573 B |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<!--
|
||||
Interaction prompt: TALK
|
||||
Design: speech bubble — rectangle body, triangle callout at bottom-left,
|
||||
two content lines suggesting text. Rectangular (not rounded) to match
|
||||
insert UI geometry aesthetic.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Bubble body -->
|
||||
<rect x="2" y="2" width="12" height="8"/>
|
||||
<!-- Callout triangle (bottom-left) -->
|
||||
<polyline points="4,10 4,14 7,10"/>
|
||||
<!-- Implied text content lines -->
|
||||
<line x1="5" y1="5" x2="11" y2="5"/>
|
||||
<line x1="5" y1="7.5" x2="9" y2="7.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 768 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
||||
<!--
|
||||
Inventory item: ACCESS TOKEN (hexagonal tag)
|
||||
Design: flat-top hexagon body with a small punch-hole circle near top center
|
||||
(physical tag/credential). Two interior lines suggest encoded markings.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points, 40×40px.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Hexagon (flat-top orientation) -->
|
||||
<polygon points="10,10 20,5 30,10 30,30 20,35 10,30"/>
|
||||
<!-- Punch hole (physical tag indicator) -->
|
||||
<circle cx="20" cy="9" r="2"/>
|
||||
<!-- Encoded marking lines -->
|
||||
<line x1="14" y1="20" x2="26" y2="20"/>
|
||||
<line x1="16" y1="24" x2="24" y2="24"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 798 B |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
||||
<!--
|
||||
Inventory item: PERSONAL COMM LOG (communication device)
|
||||
Design: rectangular handheld device body, screen inset, antenna stub,
|
||||
two diagonal signal-propagation lines (polylines, not curves).
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points, 40×40px.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Device body -->
|
||||
<rect x="11" y="12" width="16" height="24"/>
|
||||
<!-- Screen inset -->
|
||||
<rect x="14" y="15" width="10" height="9"/>
|
||||
<!-- Antenna stub -->
|
||||
<line x1="21" y1="12" x2="23" y2="7"/>
|
||||
<!-- Signal propagation lines (angled, not arcs) -->
|
||||
<polyline points="25,9 27,10 28,12"/>
|
||||
<polyline points="25,7 29,9 30,12"/>
|
||||
<!-- Control button -->
|
||||
<line x1="16" y1="31" x2="22" y2="31"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 913 B |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
|
||||
<!--
|
||||
Inventory item: MANIFEST (document)
|
||||
Design: document body with top-right folded corner (classic document indicator),
|
||||
three text-content lines inside suggesting information density.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points, 40×40px.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Document body with folded corner (pentagon path) -->
|
||||
<path d="M8,4 L22,4 L28,10 L28,34 L8,34 Z"/>
|
||||
<!-- Fold crease lines (complete the corner triangle) -->
|
||||
<polyline points="22,4 22,10 28,10"/>
|
||||
<!-- Content text lines -->
|
||||
<line x1="12" y1="17" x2="24" y2="17"/>
|
||||
<line x1="12" y1="21" x2="24" y2="21"/>
|
||||
<line x1="12" y1="25" x2="19" y2="25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 854 B |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<!--
|
||||
Stance: CAREFUL
|
||||
Design: arms spread horizontally at full width (shoulder-height lateral awareness),
|
||||
legs narrow and close together (deliberate, controlled foot placement).
|
||||
Silhouette reads as a wide T with narrow base — unmistakably different from
|
||||
WALK's diagonal arm-swing and wide stride at 20px.
|
||||
Differentiator vs WALK: arms point sideways (not fore/aft), legs almost together
|
||||
(not striding). The wide-arm / narrow-leg ratio inverts WALK's geometry.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Head -->
|
||||
<circle cx="10" cy="4" r="2.5"/>
|
||||
<!-- Torso (upright) -->
|
||||
<line x1="10" y1="6.5" x2="10" y2="13"/>
|
||||
<!-- Arms horizontal at shoulder height — full-width spread (T-shape) -->
|
||||
<line x1="10" y1="9" x2="3" y2="9"/>
|
||||
<line x1="10" y1="9" x2="17" y2="9"/>
|
||||
<!-- Legs narrow — deliberate, almost no stride (controlled placement) -->
|
||||
<line x1="10" y1="13" x2="8" y2="19"/>
|
||||
<line x1="10" y1="13" x2="12" y2="19"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<!--
|
||||
Stance: CROUCH
|
||||
Design: head lowered, torso bent forward, knees visibly bent via polyline angles.
|
||||
Differentiator vs all others: compressed vertical range, bent-knee polylines.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Head (lowered, compressed stance) -->
|
||||
<circle cx="9" cy="6" r="2.5"/>
|
||||
<!-- Torso (bent at waist, angled forward) -->
|
||||
<line x1="9" y1="8.5" x2="10" y2="13"/>
|
||||
<!-- Left arm (braced forward) -->
|
||||
<line x1="9.5" y1="10.5" x2="5" y2="12"/>
|
||||
<!-- Right arm (rearward) -->
|
||||
<line x1="9.5" y1="10.5" x2="13" y2="11"/>
|
||||
<!-- Left leg (bent knee) -->
|
||||
<polyline points="10,13 6,16 5,19"/>
|
||||
<!-- Right leg (bent knee) -->
|
||||
<polyline points="10,13 14,15 15,19"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 956 B |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<!--
|
||||
Stance: SPRINT
|
||||
Design: head shifted forward, torso angled aggressively, wide asymmetric stride.
|
||||
Differentiator vs WALK: clear forward lean of both head and torso,
|
||||
arms in pump position (one high, one low), wider leg spread.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Head (shifted forward, momentum lean) -->
|
||||
<circle cx="12" cy="4" r="2.5"/>
|
||||
<!-- Torso (strongly angled forward) -->
|
||||
<line x1="12" y1="6.5" x2="9" y2="13"/>
|
||||
<!-- Left arm (drive forward and up) -->
|
||||
<line x1="11" y1="9" x2="14" y2="13"/>
|
||||
<!-- Right arm (pull back) -->
|
||||
<line x1="11" y1="9" x2="6" y2="11"/>
|
||||
<!-- Left leg (long lead stride) -->
|
||||
<line x1="9" y1="13" x2="5" y2="19"/>
|
||||
<!-- Right leg (push-off, trailing high) -->
|
||||
<line x1="9" y1="13" x2="15" y2="17"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<!--
|
||||
Stance: WALK
|
||||
Design: upright figure, bilateral stride, arms in natural walking swing.
|
||||
Differentiator vs CAREFUL: arms follow natural walk arc, not spread wide.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Head -->
|
||||
<circle cx="10" cy="4" r="2.5"/>
|
||||
<!-- Torso -->
|
||||
<line x1="10" y1="6.5" x2="10" y2="13"/>
|
||||
<!-- Left arm (forward swing) -->
|
||||
<line x1="10" y1="9" x2="6" y2="12"/>
|
||||
<!-- Right arm (back swing) -->
|
||||
<line x1="10" y1="9" x2="14" y2="11"/>
|
||||
<!-- Left leg (forward step) -->
|
||||
<line x1="10" y1="13" x2="6" y2="19"/>
|
||||
<!-- Right leg (trailing step) -->
|
||||
<line x1="10" y1="13" x2="14" y2="18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 885 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<!--
|
||||
Status badge: HEALTH
|
||||
Design: medical cross — single polygon path tracing the outer edge of the plus shape.
|
||||
Solid fill: no internal crossing lines, reads clearly at 20px.
|
||||
D-086: fill=#c8d0e0 (explicit solid element), no stroke required.
|
||||
Runtime recolor: shader replaces fill color via tint_color parameter.
|
||||
-->
|
||||
<path
|
||||
d="M8.5,3 L11.5,3 L11.5,8.5 L17,8.5 L17,11.5 L11.5,11.5 L11.5,17 L8.5,17 L8.5,11.5 L3,11.5 L3,8.5 L8.5,8.5 Z"
|
||||
fill="#c8d0e0"
|
||||
stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
|
||||
<!--
|
||||
Status badge: PERCEPTION MODE
|
||||
Design: geometric eye — diamond outline (4 straight lines) with centered circle pupil.
|
||||
Diamond shape reinforces the insert/lattice aesthetic; no curves in the outer form.
|
||||
Communicates: vision, awareness, active perception layer.
|
||||
Deferred: mode-specific variants (thermal, camera feed) await #315 specs.
|
||||
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
|
||||
-->
|
||||
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<!-- Diamond eye outline (4 straight edges) -->
|
||||
<polygon points="10,3 18,10 10,17 2,10"/>
|
||||
<!-- Pupil circle -->
|
||||
<circle cx="10" cy="10" r="3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 154 KiB |
@@ -28,6 +28,7 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||
HudGroups="*res://scripts/autoloads/hud_groups.gd"
|
||||
HardwareDetector="*res://ui/hardware_detector.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=29 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=28 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -22,7 +22,6 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||
[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"]
|
||||
@@ -124,9 +123,6 @@ zoom = Vector2(2, 2)
|
||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||
|
||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||
|
||||
@@ -148,13 +144,15 @@ layer = 10
|
||||
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
|
||||
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
|
||||
|
||||
; HUD — implant-styled status panel (D-169, D-170). On InsertOverlay per D-049 (z-layer 6).
|
||||
[node name="HUD" parent="InsertOverlay" instance=ExtResource("6_hud")]
|
||||
|
||||
; --- UI layer (CanvasLayer 20) ---
|
||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||
; Monologue, cursor, stance — always visible, not affected by fog or camera.
|
||||
; HUD moved to InsertOverlay (layer 10) per D-049 (insert z-layer 6).
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
layer = 20
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
@@ -170,12 +168,13 @@ layer = 20
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
; #511: F3 debug overlay — real-time game state, toggled by F3
|
||||
; Position: y:150 — below merged HUD status panel (y:16, ~80px tall), with gap
|
||||
[node name="DebugOverlay" type="Control" parent="UILayer"]
|
||||
anchors_preset = 0
|
||||
offset_left = 16
|
||||
offset_top = 120
|
||||
offset_top = 150
|
||||
offset_right = 400
|
||||
offset_bottom = 400
|
||||
offset_bottom = 430
|
||||
mouse_filter = 2
|
||||
script = ExtResource("22_debug")
|
||||
|
||||
|
||||
@@ -30,18 +30,24 @@ const BUSES := [BUS_MUSIC, BUS_AMBIENT, BUS_WORLD_SFX, BUS_PLAYER_ACTIONS, BUS_U
|
||||
# NOTE: listening_focus requires 30+ stationary ticks before activation (D-069/D-071).
|
||||
# The tick gate is the caller's responsibility — AudioManager only manages bus volumes.
|
||||
const DIP_SPECS := {
|
||||
"dialogue": {
|
||||
"ease_in": 0.3, "ease_out": 0.5,
|
||||
"buses": { "Ambient": -7.0 },
|
||||
"dialogue":
|
||||
{
|
||||
"ease_in": 0.3,
|
||||
"ease_out": 0.5,
|
||||
"buses": {"Ambient": -7.0},
|
||||
},
|
||||
"confrontation": {
|
||||
"ease_in": 0.5, "ease_out": 1.0,
|
||||
"buses": { "Ambient": -11.0, "WorldSFX": -5.0 },
|
||||
"confrontation":
|
||||
{
|
||||
"ease_in": 0.5,
|
||||
"ease_out": 1.0,
|
||||
"buses": {"Ambient": -11.0, "WorldSFX": -5.0},
|
||||
"filter_hz": 800.0,
|
||||
},
|
||||
"listening_focus": {
|
||||
"ease_in": 0.5, "ease_out": 0.5,
|
||||
"buses": { "WorldSFX": 2.5 },
|
||||
"listening_focus":
|
||||
{
|
||||
"ease_in": 0.5,
|
||||
"ease_out": 0.5,
|
||||
"buses": {"WorldSFX": 2.5},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,10 +65,10 @@ const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
const ZONE_ASSETS: Dictionary = {
|
||||
"hub": "amb_hub_layer",
|
||||
"hub": "amb_hub_layer",
|
||||
"workplace": "amb_hub_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
@@ -73,12 +79,12 @@ const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
@@ -110,6 +116,7 @@ func _ready() -> void:
|
||||
|
||||
# --- Bus setup ---
|
||||
|
||||
|
||||
func _setup_buses() -> void:
|
||||
for bus_name in BUSES:
|
||||
if AudioServer.get_bus_index(bus_name) < 0:
|
||||
@@ -130,6 +137,7 @@ func _setup_buses() -> void:
|
||||
|
||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||
|
||||
|
||||
func _scan_registry() -> void:
|
||||
_scan_dir("res://assets/audio/")
|
||||
print("AudioManager: %d assets registered" % _registry.size())
|
||||
@@ -166,6 +174,7 @@ func get_registry_size() -> int:
|
||||
|
||||
# --- Playback: non-spatial ---
|
||||
|
||||
|
||||
## Play a one-shot sound on the given bus. No-ops if asset not in registry.
|
||||
func play(asset_key: String, bus: String = BUS_UI_SOUNDS) -> void:
|
||||
var stream := _get_stream(asset_key)
|
||||
@@ -227,6 +236,7 @@ func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
|
||||
## Play a one-shot spatial sound at a world position (pixels).
|
||||
## Added to current_scene for correct AudioListener2D positioning.
|
||||
func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD_SFX) -> void:
|
||||
@@ -248,6 +258,7 @@ func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD
|
||||
|
||||
# --- Audio dip profiles (D-069) ---
|
||||
|
||||
|
||||
## Apply a dip profile. Interrupts any active dip, restoring unaffected buses.
|
||||
## Profiles: "dialogue", "confrontation", "listening_focus"
|
||||
func apply_dip(profile: String) -> void:
|
||||
@@ -318,6 +329,7 @@ func get_active_dip() -> String:
|
||||
|
||||
# --- Volume control (player sliders, D-069 proportional base) ---
|
||||
|
||||
|
||||
## Set the player's volume preference for a bus. During active dip, immediately
|
||||
## recalculates effective volume (proportional to new base).
|
||||
func set_volume(bus: String, volume_db: float) -> void:
|
||||
@@ -341,6 +353,7 @@ func get_volume(bus: String) -> float:
|
||||
|
||||
# --- Volume persistence (user://audio_prefs.cfg) ---
|
||||
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
@@ -366,6 +379,7 @@ func _save_prefs() -> void:
|
||||
|
||||
# --- Zone crossfade (D-073) ---
|
||||
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||
@@ -415,6 +429,7 @@ func _kill_zone_tweens() -> void:
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
|
||||
func _get_stream(asset_key: String) -> AudioStream:
|
||||
return _registry.get(asset_key) as AudioStream
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ extends Node
|
||||
# Fog texture byte values — visibility and exploration layers.
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569).
|
||||
# Retained — tests still reference it.
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
|
||||
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
|
||||
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
|
||||
@@ -25,10 +25,10 @@ const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame
|
||||
# bar: #2a1f15 (warm amber-dark — social, inhabited)
|
||||
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
|
||||
const ZONE_TINTS: Dictionary = {
|
||||
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
|
||||
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
|
||||
"workplace": Color(0.102, 0.122, 0.180), # same as hub
|
||||
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
|
||||
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
|
||||
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
|
||||
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
|
||||
}
|
||||
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
|
||||
|
||||
@@ -213,7 +213,11 @@ func update_from_state() -> void:
|
||||
var new_g := int(tint_color.g * 255.0)
|
||||
var new_b := int(tint_color.b * 255.0)
|
||||
# Only update if different from current (avoid spurious texture uploads)
|
||||
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
|
||||
if (
|
||||
_tint_bytes[tint_idx] != new_r
|
||||
or _tint_bytes[tint_idx + 1] != new_g
|
||||
or _tint_bytes[tint_idx + 2] != new_b
|
||||
):
|
||||
_tint_bytes[tint_idx + 0] = new_r
|
||||
_tint_bytes[tint_idx + 1] = new_g
|
||||
_tint_bytes[tint_idx + 2] = new_b
|
||||
@@ -243,5 +247,3 @@ func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
|
||||
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
|
||||
return tile_bounds
|
||||
return map_bounds.merge(tile_bounds)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585)
|
||||
# — visible in fog but not explored
|
||||
|
||||
@@ -162,6 +162,7 @@ var stationary_ticks: int = 0
|
||||
# Empty string when zone_id field absent.
|
||||
var current_zone_id: String = ""
|
||||
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
# Autoload parse-order: class_name types are not registered when autoloads compile.
|
||||
# load() returns the cached resource after the first call — essentially free per-tick.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
extends Node
|
||||
## HUD layer manager (D-170).
|
||||
##
|
||||
## Controls z-ordering and layout mode for HUD elements. Nothing gets hidden —
|
||||
## fullscreen apps render on top of gameplay, insert-mode apps share the screen.
|
||||
##
|
||||
## Modes:
|
||||
## MODE_GAMEPLAY — default z-level, normal layout
|
||||
## MODE_INSERT — small overlay alongside gameplay (e.g. minimap-sized star map)
|
||||
## MODE_FULLSCREEN — takes over the screen, gameplay renders behind
|
||||
##
|
||||
## Groups use hierarchical paths:
|
||||
## "gameplay" — HUD status, minimap, prompts, stance
|
||||
## "implant/map/starchart" — star map navigator
|
||||
## "implant/wiki/gttr" — Drifter's Guide reader
|
||||
## "implant/journal" — knowledge journal
|
||||
##
|
||||
## Usage:
|
||||
## HudGroups.register(self, "implant/map/starchart")
|
||||
## HudGroups.open_app("implant/map/starchart") # fullscreen by default
|
||||
## HudGroups.open_app("implant/map/starchart", HudGroups.MODE_INSERT)
|
||||
## HudGroups.close_app()
|
||||
## HudGroups.toggle_app("implant/map/starchart")
|
||||
|
||||
## Emitted when an app opens, closes, or changes mode.
|
||||
## mode is a HudGroups.Mode enum value.
|
||||
signal app_changed(app_path: String, mode: int)
|
||||
|
||||
## Emitted when gameplay rendering should pause or resume.
|
||||
## Fullscreen implant apps occlude gameplay — expensive renderers (world,
|
||||
## fog, entities) should skip draw calls while this is true.
|
||||
signal gameplay_occluded(occluded: bool)
|
||||
|
||||
enum Mode { GAMEPLAY, INSERT, FULLSCREEN }
|
||||
|
||||
## Z-index values for each mode. Gameplay is the base layer.
|
||||
const Z_GAMEPLAY: int = 0
|
||||
const Z_INSERT: int = 10
|
||||
const Z_FULLSCREEN: int = 20
|
||||
const Z_MODAL: int = 30
|
||||
|
||||
var _groups: Dictionary = {} # group_path -> Array[CanvasItem]
|
||||
var _active_app: String = "" # currently focused implant app ("" = none)
|
||||
var _active_mode: Mode = Mode.GAMEPLAY
|
||||
|
||||
|
||||
func register(node: CanvasItem, group: String) -> void:
|
||||
if not _groups.has(group):
|
||||
_groups[group] = []
|
||||
if node not in _groups[group]:
|
||||
_groups[group].append(node)
|
||||
# Apply current z-level
|
||||
_apply_z(node, group)
|
||||
|
||||
|
||||
func unregister(node: CanvasItem, group: String) -> void:
|
||||
if _groups.has(group):
|
||||
_groups[group].erase(node)
|
||||
|
||||
|
||||
## Open an implant app in fullscreen (default) or insert mode.
|
||||
func open_app(app: String, mode: Mode = Mode.FULLSCREEN) -> void:
|
||||
# Close previous app if different
|
||||
if not _active_app.is_empty() and _active_app != app:
|
||||
_set_group_z(_active_app, Z_GAMEPLAY)
|
||||
app_changed.emit(_active_app, Mode.GAMEPLAY)
|
||||
|
||||
_active_app = app
|
||||
_active_mode = mode
|
||||
|
||||
# Set z-levels based on mode
|
||||
var was_occluded := _active_mode == Mode.FULLSCREEN and not _active_app.is_empty()
|
||||
if mode == Mode.FULLSCREEN:
|
||||
_set_group_z("gameplay", Z_GAMEPLAY)
|
||||
_set_group_z(app, Z_FULLSCREEN)
|
||||
elif mode == Mode.INSERT:
|
||||
_set_group_z("gameplay", Z_GAMEPLAY)
|
||||
_set_group_z(app, Z_INSERT)
|
||||
|
||||
app_changed.emit(app, mode)
|
||||
|
||||
# Notify renderers about occlusion state change
|
||||
var now_occluded := mode == Mode.FULLSCREEN
|
||||
if now_occluded != was_occluded:
|
||||
gameplay_occluded.emit(now_occluded)
|
||||
|
||||
|
||||
## Close the current app. Returns everything to gameplay z-level.
|
||||
func close_app() -> void:
|
||||
var was_fullscreen := _active_mode == Mode.FULLSCREEN and not _active_app.is_empty()
|
||||
if not _active_app.is_empty():
|
||||
_set_group_z(_active_app, Z_GAMEPLAY)
|
||||
app_changed.emit(_active_app, Mode.GAMEPLAY)
|
||||
_active_app = ""
|
||||
_active_mode = Mode.GAMEPLAY
|
||||
_set_group_z("gameplay", Z_GAMEPLAY)
|
||||
if was_fullscreen:
|
||||
gameplay_occluded.emit(false)
|
||||
|
||||
|
||||
## Toggle an app. If open, close it. If closed or different app, open it.
|
||||
func toggle_app(app: String, mode: Mode = Mode.FULLSCREEN) -> void:
|
||||
if _active_app == app:
|
||||
close_app()
|
||||
else:
|
||||
open_app(app, mode)
|
||||
|
||||
|
||||
## Check if a specific app is currently open.
|
||||
func is_app_active(app: String) -> bool:
|
||||
return _active_app == app
|
||||
|
||||
|
||||
## Check if any implant app is open.
|
||||
func is_implant_active() -> bool:
|
||||
return not _active_app.is_empty()
|
||||
|
||||
|
||||
## Get the currently active app path, or "" if none.
|
||||
func get_active_app() -> String:
|
||||
return _active_app
|
||||
|
||||
|
||||
## Get the current mode of the active app.
|
||||
func get_active_mode() -> Mode:
|
||||
return _active_mode
|
||||
|
||||
|
||||
func _apply_z(node: CanvasItem, group: String) -> void:
|
||||
if group == "gameplay":
|
||||
node.z_index = Z_GAMEPLAY
|
||||
elif group.begins_with("implant/"):
|
||||
if group == _active_app:
|
||||
node.z_index = Z_FULLSCREEN if _active_mode == Mode.FULLSCREEN else Z_INSERT
|
||||
else:
|
||||
node.z_index = Z_GAMEPLAY
|
||||
elif group == "modal":
|
||||
node.z_index = Z_MODAL
|
||||
|
||||
|
||||
func _set_group_z(group: String, z: int) -> void:
|
||||
if not _groups.has(group):
|
||||
return
|
||||
for node: CanvasItem in _groups[group]:
|
||||
if is_instance_valid(node):
|
||||
node.z_index = z
|
||||
@@ -14,10 +14,21 @@ extends Node
|
||||
# Server cooldown is authoritative, but the client throttle prevents
|
||||
# flooding and gives correct movement feel in test mode.
|
||||
enum Action {
|
||||
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
|
||||
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
MOVE_NORTH,
|
||||
MOVE_NORTHEAST,
|
||||
MOVE_EAST,
|
||||
MOVE_SOUTHEAST,
|
||||
MOVE_SOUTH,
|
||||
MOVE_SOUTHWEST,
|
||||
MOVE_WEST,
|
||||
MOVE_NORTHWEST,
|
||||
INTERACT,
|
||||
USE_PERCEPTION_MODE,
|
||||
OPEN_MENU,
|
||||
PAUSE,
|
||||
UNPAUSE,
|
||||
TOGGLE_STANCE_UP,
|
||||
TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
@@ -25,18 +36,18 @@ enum Action {
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
|
||||
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
|
||||
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
|
||||
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
}
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
"Sprint": 200, # 5/sec — fast but trackable
|
||||
"Walk": 400, # 2.5/sec — comfortable walking pace
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
"Sprint": 200, # 5/sec — fast but trackable
|
||||
"Walk": 400, # 2.5/sec — comfortable walking pace
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
@@ -44,7 +55,7 @@ var input_queue: Array[Dictionary] = []
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
var _last_move_msec: int = 0
|
||||
|
||||
@@ -63,11 +74,16 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
if facing_octant != _last_sent_octant:
|
||||
_last_sent_octant = facing_octant
|
||||
input_queue.append({
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
})
|
||||
(
|
||||
input_queue
|
||||
. append(
|
||||
{
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Poll held WASD keys
|
||||
var raw_dir := Vector2i.ZERO
|
||||
@@ -88,10 +104,15 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Transform WASD input relative to mouse facing
|
||||
var world_dir := _wasd_to_world_dir(raw_dir)
|
||||
var action: Action = _dir_to_action(world_dir)
|
||||
input_queue.append({
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
})
|
||||
(
|
||||
input_queue
|
||||
. append(
|
||||
{
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Discrete actions: fire once on key press (not held).
|
||||
@@ -197,15 +218,24 @@ static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
# Quantize to nearest 45° (PI/4)
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return Vector2i(1, 0) # East
|
||||
1: return Vector2i(1, 1) # Southeast
|
||||
2, -6: return Vector2i(0, 1) # South
|
||||
3, -5: return Vector2i(-1, 1) # Southwest
|
||||
4, -4: return Vector2i(-1, 0) # West
|
||||
-3, 5: return Vector2i(-1, -1) # Northwest
|
||||
-2: return Vector2i(0, -1) # North
|
||||
-1: return Vector2i(1, -1) # Northeast
|
||||
_: return Vector2i.ZERO
|
||||
0:
|
||||
return Vector2i(1, 0) # East
|
||||
1:
|
||||
return Vector2i(1, 1) # Southeast
|
||||
2, -6:
|
||||
return Vector2i(0, 1) # South
|
||||
3, -5:
|
||||
return Vector2i(-1, 1) # Southwest
|
||||
4, -4:
|
||||
return Vector2i(-1, 0) # West
|
||||
-3, 5:
|
||||
return Vector2i(-1, -1) # Northwest
|
||||
-2:
|
||||
return Vector2i(0, -1) # North
|
||||
-1:
|
||||
return Vector2i(1, -1) # Northeast
|
||||
_:
|
||||
return Vector2i.ZERO
|
||||
|
||||
|
||||
# D-054: Convert a facing angle (radians) to the nearest octant name.
|
||||
@@ -213,27 +243,45 @@ static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
static func _angle_to_octant(angle: float) -> String:
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return "East"
|
||||
1: return "Southeast"
|
||||
2, -6: return "South"
|
||||
3, -5: return "Southwest"
|
||||
4, -4: return "West"
|
||||
-3, 5: return "Northwest"
|
||||
-2: return "North"
|
||||
-1: return "Northeast"
|
||||
_: return "East"
|
||||
0:
|
||||
return "East"
|
||||
1:
|
||||
return "Southeast"
|
||||
2, -6:
|
||||
return "South"
|
||||
3, -5:
|
||||
return "Southwest"
|
||||
4, -4:
|
||||
return "West"
|
||||
-3, 5:
|
||||
return "Northwest"
|
||||
-2:
|
||||
return "North"
|
||||
-1:
|
||||
return "Northeast"
|
||||
_:
|
||||
return "East"
|
||||
|
||||
|
||||
# Map a direction vector to the corresponding movement Action.
|
||||
# Handles all 8 directions via composite W+D, W+A, etc.
|
||||
static func _dir_to_action(dir: Vector2i) -> Action:
|
||||
match dir:
|
||||
Vector2i(0, -1): return Action.MOVE_NORTH
|
||||
Vector2i(1, -1): return Action.MOVE_NORTHEAST
|
||||
Vector2i(1, 0): return Action.MOVE_EAST
|
||||
Vector2i(1, 1): return Action.MOVE_SOUTHEAST
|
||||
Vector2i(0, 1): return Action.MOVE_SOUTH
|
||||
Vector2i(-1, 1): return Action.MOVE_SOUTHWEST
|
||||
Vector2i(-1, 0): return Action.MOVE_WEST
|
||||
Vector2i(-1, -1): return Action.MOVE_NORTHWEST
|
||||
_: return Action.MOVE_NORTH
|
||||
Vector2i(0, -1):
|
||||
return Action.MOVE_NORTH
|
||||
Vector2i(1, -1):
|
||||
return Action.MOVE_NORTHEAST
|
||||
Vector2i(1, 0):
|
||||
return Action.MOVE_EAST
|
||||
Vector2i(1, 1):
|
||||
return Action.MOVE_SOUTHEAST
|
||||
Vector2i(0, 1):
|
||||
return Action.MOVE_SOUTH
|
||||
Vector2i(-1, 1):
|
||||
return Action.MOVE_SOUTHWEST
|
||||
Vector2i(-1, 0):
|
||||
return Action.MOVE_WEST
|
||||
Vector2i(-1, -1):
|
||||
return Action.MOVE_NORTHWEST
|
||||
_:
|
||||
return Action.MOVE_NORTH
|
||||
|
||||
@@ -16,7 +16,6 @@ extends Node
|
||||
## Paths: Read-once OS path constants, populated in _ready().
|
||||
## Diag: get_diagnostics() — flat dict of all properties for bug reports.
|
||||
|
||||
|
||||
# -- Power profile ------------------------------------------------------------
|
||||
|
||||
## Emitted when the detected power profile changes.
|
||||
@@ -24,20 +23,20 @@ signal power_profile_changed(old_profile: int, new_profile: int)
|
||||
|
||||
## High-level power classification. POWER_SAVER reserved for future OS API.
|
||||
enum PowerProfile {
|
||||
FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions
|
||||
BATTERY = 1, # On battery — AI inference should be suspended per D-138 §8 Layer 3
|
||||
POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet)
|
||||
FULL = 0, ## Plugged in — no restrictions
|
||||
BATTERY = 1, ## On battery — suspend AI inference per D-138 §8 Layer 3
|
||||
POWER_SAVER = 2, ## System-level power-saver mode (future)
|
||||
}
|
||||
|
||||
## How often (seconds) to re-poll OS for power state changes.
|
||||
const POWER_POLL_INTERVAL := 30.0
|
||||
|
||||
# OS power_state integer constants (Godot 4 — same values as POWERSTATE_* enum).
|
||||
const _POWER_STATE_UNKNOWN := 0
|
||||
const _POWER_STATE_UNKNOWN := 0
|
||||
const _POWER_STATE_ON_BATTERY := 1
|
||||
const _POWER_STATE_NO_BATTERY := 2
|
||||
const _POWER_STATE_CHARGING := 3
|
||||
const _POWER_STATE_CHARGED := 4
|
||||
const _POWER_STATE_CHARGING := 3
|
||||
const _POWER_STATE_CHARGED := 4
|
||||
|
||||
## Current power profile. Updated by the 30-second poll timer.
|
||||
var power_profile: PowerProfile = PowerProfile.FULL
|
||||
@@ -48,7 +47,6 @@ var raw_power_state: int = 0
|
||||
## Battery charge percentage (0–100). -1 if not available or not on battery.
|
||||
var battery_percent: int = -1
|
||||
|
||||
|
||||
# -- Memory -------------------------------------------------------------------
|
||||
|
||||
## Free physical RAM in megabytes. Call refresh_memory() before reading if staleness matters.
|
||||
@@ -57,7 +55,6 @@ var free_memory_mb: float = 0.0
|
||||
## Total physical RAM in megabytes. Populated once in _ready(); doesn't change at runtime.
|
||||
var total_memory_mb: float = 0.0
|
||||
|
||||
|
||||
# -- CPU ----------------------------------------------------------------------
|
||||
|
||||
## Human-readable processor name (e.g. "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz").
|
||||
@@ -66,7 +63,6 @@ var cpu_name: String = ""
|
||||
## Number of logical CPU cores reported by the OS.
|
||||
var cpu_logical_cores: int = 0
|
||||
|
||||
|
||||
# -- GPU ----------------------------------------------------------------------
|
||||
|
||||
## GPU display name (e.g. "NVIDIA GeForce RTX 3080").
|
||||
@@ -84,7 +80,6 @@ var gpu_type: String = "other"
|
||||
## Low-level driver info strings from the OS (may be empty on some platforms).
|
||||
var gpu_driver_info: PackedStringArray = PackedStringArray()
|
||||
|
||||
|
||||
# -- Platform identity --------------------------------------------------------
|
||||
|
||||
## OS name reported by Godot (e.g. "Linux", "Windows", "macOS").
|
||||
@@ -99,7 +94,6 @@ var distribution: String = ""
|
||||
## True if the application is running inside a sandbox (Flatpak, Snap, macOS sandbox, etc.).
|
||||
var is_sandboxed: bool = false
|
||||
|
||||
|
||||
# -- Display ------------------------------------------------------------------
|
||||
|
||||
## Number of connected screens.
|
||||
@@ -117,7 +111,6 @@ var screen_refresh_rate: float = 60.0
|
||||
## UI scale factor. Uses OS-reported scale on macOS; falls back to dpi / 96.0 elsewhere.
|
||||
var display_scale: float = 1.0
|
||||
|
||||
|
||||
# -- Locale -------------------------------------------------------------------
|
||||
|
||||
## Full locale string (e.g. "en_US").
|
||||
@@ -126,7 +119,6 @@ var locale: String = ""
|
||||
## Language portion of the locale (e.g. "en").
|
||||
var locale_language: String = ""
|
||||
|
||||
|
||||
# -- File paths ---------------------------------------------------------------
|
||||
|
||||
## Base user data directory (user://).
|
||||
@@ -150,9 +142,9 @@ var cache_dir: String = ""
|
||||
## Directory where the bundled Gemma 2 model lives (derived from install_dir).
|
||||
var model_dir: String = ""
|
||||
|
||||
|
||||
# -- Lifecycle ----------------------------------------------------------------
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_init_paths()
|
||||
_init_hardware()
|
||||
@@ -167,12 +159,12 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _init_paths() -> void:
|
||||
user_data_dir = OS.get_user_data_dir()
|
||||
config_dir = user_data_dir
|
||||
user_data_dir = OS.get_user_data_dir()
|
||||
config_dir = user_data_dir
|
||||
benchmark_cache_path = "user://ai-dialogue-config.json"
|
||||
executable_path = OS.get_executable_path()
|
||||
install_dir = executable_path.get_base_dir()
|
||||
model_dir = install_dir + "/models"
|
||||
executable_path = OS.get_executable_path()
|
||||
install_dir = executable_path.get_base_dir()
|
||||
model_dir = install_dir + "/models"
|
||||
# get_cache_dir() may not exist on all Godot builds — use callv for safety.
|
||||
var os_cache: String = ""
|
||||
if OS.has_method("get_cache_dir"):
|
||||
@@ -184,12 +176,12 @@ func _init_paths() -> void:
|
||||
|
||||
func _init_hardware() -> void:
|
||||
# -- CPU
|
||||
cpu_name = OS.get_processor_name()
|
||||
cpu_name = OS.get_processor_name()
|
||||
cpu_logical_cores = OS.get_processor_count()
|
||||
|
||||
# -- GPU (guarded: RenderingServer methods may return empty in headless mode)
|
||||
gpu_name = RenderingServer.get_video_adapter_name()
|
||||
gpu_vendor = RenderingServer.get_video_adapter_vendor()
|
||||
gpu_name = RenderingServer.get_video_adapter_name()
|
||||
gpu_vendor = RenderingServer.get_video_adapter_vendor()
|
||||
gpu_api_version = RenderingServer.get_video_adapter_api_version()
|
||||
gpu_driver_info = OS.get_video_adapter_driver_info()
|
||||
if RenderingServer.has_method("get_video_adapter_type"):
|
||||
@@ -198,7 +190,7 @@ func _init_hardware() -> void:
|
||||
|
||||
# -- Platform identity
|
||||
platform_name = OS.get_name()
|
||||
os_version = OS.get_version()
|
||||
os_version = OS.get_version()
|
||||
if OS.has_method("get_distribution_name"):
|
||||
var dist: Variant = OS.callv("get_distribution_name", [])
|
||||
if dist is String:
|
||||
@@ -207,14 +199,14 @@ func _init_hardware() -> void:
|
||||
|
||||
# -- Display
|
||||
screen_count = DisplayServer.get_screen_count()
|
||||
screen_size = DisplayServer.screen_get_size()
|
||||
screen_dpi = DisplayServer.screen_get_dpi()
|
||||
screen_size = DisplayServer.screen_get_size()
|
||||
screen_dpi = DisplayServer.screen_get_dpi()
|
||||
var raw_rate: float = DisplayServer.screen_get_refresh_rate()
|
||||
screen_refresh_rate = raw_rate if raw_rate >= 0.0 else 60.0
|
||||
display_scale = _compute_display_scale()
|
||||
|
||||
# -- Locale
|
||||
locale = OS.get_locale()
|
||||
locale = OS.get_locale()
|
||||
locale_language = OS.get_locale_language()
|
||||
|
||||
|
||||
@@ -222,11 +214,16 @@ func _init_hardware() -> void:
|
||||
## Values: 0=OTHER, 1=INTEGRATED, 2=DISCRETE, 3=VIRTUAL, 4=CPU.
|
||||
static func _map_adapter_type(adapter_type: int) -> String:
|
||||
match adapter_type:
|
||||
1: return "integrated"
|
||||
2: return "discrete"
|
||||
3: return "virtual"
|
||||
4: return "cpu"
|
||||
_: return "other"
|
||||
1:
|
||||
return "integrated"
|
||||
2:
|
||||
return "discrete"
|
||||
3:
|
||||
return "virtual"
|
||||
4:
|
||||
return "cpu"
|
||||
_:
|
||||
return "other"
|
||||
|
||||
|
||||
## Compute display scale for the primary screen.
|
||||
@@ -241,17 +238,19 @@ func _compute_display_scale() -> float:
|
||||
|
||||
# -- Memory API ---------------------------------------------------------------
|
||||
|
||||
|
||||
## Refresh free/total memory readings from the OS. Call before read if freshness matters.
|
||||
func refresh_memory() -> void:
|
||||
var mem := OS.get_memory_info()
|
||||
# "available" = Linux MemAvailable / Windows ullAvailPhys (includes reclaimable pages).
|
||||
# Fall back to "free" on platforms that don't provide "available".
|
||||
free_memory_mb = float(mem.get("available", mem.get("free", 0))) / (1024.0 * 1024.0)
|
||||
free_memory_mb = float(mem.get("available", mem.get("free", 0))) / (1024.0 * 1024.0)
|
||||
total_memory_mb = float(mem.get("physical", 0)) / (1024.0 * 1024.0)
|
||||
|
||||
|
||||
# -- Power API ----------------------------------------------------------------
|
||||
|
||||
|
||||
## Classify an OS power_state integer into a human-readable string.
|
||||
## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown".
|
||||
static func classify_power_state(power_state: int) -> String:
|
||||
@@ -293,9 +292,9 @@ func _poll_power() -> void:
|
||||
if raw_power_state == _POWER_STATE_ON_BATTERY:
|
||||
battery_percent = int(info.get("percent", -1))
|
||||
|
||||
var new_profile: PowerProfile = PowerProfile.BATTERY \
|
||||
if raw_power_state == _POWER_STATE_ON_BATTERY \
|
||||
else PowerProfile.FULL
|
||||
var new_profile: PowerProfile = (
|
||||
PowerProfile.BATTERY if raw_power_state == _POWER_STATE_ON_BATTERY else PowerProfile.FULL
|
||||
)
|
||||
if new_profile != power_profile:
|
||||
var old_profile := power_profile
|
||||
power_profile = new_profile
|
||||
@@ -304,45 +303,46 @@ func _poll_power() -> void:
|
||||
|
||||
# -- Diagnostics --------------------------------------------------------------
|
||||
|
||||
|
||||
## Returns all platform properties as a flat dictionary for inclusion in bug reports.
|
||||
func get_diagnostics() -> Dictionary:
|
||||
return {
|
||||
# Power
|
||||
"power_profile": int(power_profile),
|
||||
"raw_power_state": raw_power_state,
|
||||
"battery_percent": battery_percent,
|
||||
"power_profile": int(power_profile),
|
||||
"raw_power_state": raw_power_state,
|
||||
"battery_percent": battery_percent,
|
||||
# Memory
|
||||
"free_memory_mb": free_memory_mb,
|
||||
"total_memory_mb": total_memory_mb,
|
||||
"free_memory_mb": free_memory_mb,
|
||||
"total_memory_mb": total_memory_mb,
|
||||
# CPU
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_logical_cores": cpu_logical_cores,
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_logical_cores": cpu_logical_cores,
|
||||
# GPU
|
||||
"gpu_name": gpu_name,
|
||||
"gpu_vendor": gpu_vendor,
|
||||
"gpu_api_version": gpu_api_version,
|
||||
"gpu_type": gpu_type,
|
||||
"gpu_driver_info": Array(gpu_driver_info),
|
||||
"gpu_name": gpu_name,
|
||||
"gpu_vendor": gpu_vendor,
|
||||
"gpu_api_version": gpu_api_version,
|
||||
"gpu_type": gpu_type,
|
||||
"gpu_driver_info": Array(gpu_driver_info),
|
||||
# Platform
|
||||
"platform_name": platform_name,
|
||||
"os_version": os_version,
|
||||
"distribution": distribution,
|
||||
"is_sandboxed": is_sandboxed,
|
||||
"platform_name": platform_name,
|
||||
"os_version": os_version,
|
||||
"distribution": distribution,
|
||||
"is_sandboxed": is_sandboxed,
|
||||
# Display
|
||||
"screen_count": screen_count,
|
||||
"screen_size": {"x": screen_size.x, "y": screen_size.y},
|
||||
"screen_dpi": screen_dpi,
|
||||
"screen_count": screen_count,
|
||||
"screen_size": {"x": screen_size.x, "y": screen_size.y},
|
||||
"screen_dpi": screen_dpi,
|
||||
"screen_refresh_rate": screen_refresh_rate,
|
||||
"display_scale": display_scale,
|
||||
"display_scale": display_scale,
|
||||
# Locale
|
||||
"locale": locale,
|
||||
"locale_language": locale_language,
|
||||
"locale": locale,
|
||||
"locale_language": locale_language,
|
||||
# Paths
|
||||
"user_data_dir": user_data_dir,
|
||||
"config_dir": config_dir,
|
||||
"benchmark_cache_path": benchmark_cache_path,
|
||||
"install_dir": install_dir,
|
||||
"executable_path": executable_path,
|
||||
"cache_dir": cache_dir,
|
||||
"model_dir": model_dir,
|
||||
"user_data_dir": user_data_dir,
|
||||
"config_dir": config_dir,
|
||||
"benchmark_cache_path": benchmark_cache_path,
|
||||
"install_dir": install_dir,
|
||||
"executable_path": executable_path,
|
||||
"cache_dir": cache_dir,
|
||||
"model_dir": model_dir,
|
||||
}
|
||||
|
||||
@@ -17,18 +17,26 @@ var _quit_dialog: ConfirmationDialog = null
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var timestamp := (
|
||||
"%04d%02d%02d-%02d%02d%02d"
|
||||
% [
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
]
|
||||
)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
push_error(
|
||||
"SessionManager: failed to create save dir %s: %s" % [save_path, error_string(err)]
|
||||
)
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
@@ -71,15 +79,21 @@ func list_game_dirs() -> Array:
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
(
|
||||
results
|
||||
. append(
|
||||
{
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
}
|
||||
)
|
||||
)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
results.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.modified_time > b.modified_time
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@@ -105,11 +119,16 @@ func _do_quit_to_menu() -> void:
|
||||
# SimBridge._process() flushes the outbound buffer before teardown.
|
||||
if not GameState.current_game_id.is_empty():
|
||||
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
}
|
||||
)
|
||||
)
|
||||
GameState.current_game_id = ""
|
||||
_navigate_to_menu.call_deferred()
|
||||
else:
|
||||
@@ -131,7 +150,12 @@ func _cleanup_quit_dialog() -> void:
|
||||
func _write_seed_file(save_path: String, seed: int) -> void:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error()))
|
||||
push_error(
|
||||
(
|
||||
"SessionManager: failed to write seed file: %s"
|
||||
% error_string(FileAccess.get_open_error())
|
||||
)
|
||||
)
|
||||
return
|
||||
file.store_64(seed)
|
||||
|
||||
@@ -152,7 +176,12 @@ func save_character_archetype(game_id: String, archetype: String) -> void:
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write character.txt: %s" % error_string(FileAccess.get_open_error()))
|
||||
push_error(
|
||||
(
|
||||
"SessionManager: failed to write character.txt: %s"
|
||||
% error_string(FileAccess.get_open_error())
|
||||
)
|
||||
)
|
||||
return
|
||||
file.store_string(archetype)
|
||||
|
||||
|
||||
@@ -34,37 +34,50 @@ var _retry_timer: float = 0.0
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
get:
|
||||
return harness.tick if harness else 0
|
||||
set(v):
|
||||
if harness: harness.tick = v
|
||||
if harness:
|
||||
harness.tick = v
|
||||
|
||||
var _test_player_pos: Vector2i:
|
||||
get: return harness.player_pos if harness else Vector2i.ZERO
|
||||
get:
|
||||
return harness.player_pos if harness else Vector2i.ZERO
|
||||
set(v):
|
||||
if harness: harness.player_pos = v
|
||||
if harness:
|
||||
harness.player_pos = v
|
||||
|
||||
var _test_facing: String:
|
||||
get: return harness.facing if harness else "North"
|
||||
get:
|
||||
return harness.facing if harness else "North"
|
||||
set(v):
|
||||
if harness: harness.facing = v
|
||||
if harness:
|
||||
harness.facing = v
|
||||
|
||||
var _test_in_dialogue: bool:
|
||||
get: return harness.in_dialogue if harness else false
|
||||
get:
|
||||
return harness.in_dialogue if harness else false
|
||||
set(v):
|
||||
if harness: harness.in_dialogue = v
|
||||
if harness:
|
||||
harness.in_dialogue = v
|
||||
|
||||
var _test_gauntlet_mode: bool:
|
||||
get: return harness.gauntlet_mode if harness else false
|
||||
get:
|
||||
return harness.gauntlet_mode if harness else false
|
||||
set(v):
|
||||
if harness: harness.gauntlet_mode = v
|
||||
if harness:
|
||||
harness.gauntlet_mode = v
|
||||
|
||||
var _test_npc_relationship: String:
|
||||
get: return harness.npc_relationship if harness else "Unknown"
|
||||
get:
|
||||
return harness.npc_relationship if harness else "Unknown"
|
||||
set(v):
|
||||
if harness: harness.npc_relationship = v
|
||||
if harness:
|
||||
harness.npc_relationship = v
|
||||
|
||||
var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
get:
|
||||
return harness.input_queue if harness else []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -75,18 +88,23 @@ func _ready() -> void:
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
|
||||
func reset_test_state() -> void:
|
||||
if harness: harness.reset()
|
||||
if harness:
|
||||
harness.reset()
|
||||
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
|
||||
|
||||
# -- Connection lifecycle ------------------------------------------------------
|
||||
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
if state != new_state:
|
||||
@@ -94,6 +112,7 @@ func _set_state(new_state: ConnectionState) -> void:
|
||||
state = new_state
|
||||
connection_state_changed.emit(old_state, new_state)
|
||||
|
||||
|
||||
# Connect to simulation server.
|
||||
# In test mode, immediately transitions to CONNECTED.
|
||||
# In live mode, spawns server subprocess and defers TCP connection to _process()
|
||||
@@ -125,6 +144,7 @@ func connect_to_sim() -> void:
|
||||
_retry_timer = 0.0
|
||||
_bridge = null
|
||||
|
||||
|
||||
# Disconnect from simulation server
|
||||
func disconnect_from_sim() -> void:
|
||||
if _bridge != null:
|
||||
@@ -136,15 +156,21 @@ func disconnect_from_sim() -> void:
|
||||
_connect_retries = 0
|
||||
_set_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
|
||||
# Attempt TCP connection. Called from _process() during CONNECTING state.
|
||||
func _try_connect() -> void:
|
||||
_bridge = LocalBridge.new()
|
||||
var err := _bridge.connect_to_server("127.0.0.1", server_port)
|
||||
if err != OK:
|
||||
push_warning("SimBridge: TCP connect attempt %d/%d failed: %s" % [
|
||||
_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)])
|
||||
push_warning(
|
||||
(
|
||||
"SimBridge: TCP connect attempt %d/%d failed: %s"
|
||||
% [_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)]
|
||||
)
|
||||
)
|
||||
_bridge = null
|
||||
|
||||
|
||||
# Poll transport layer every frame (non-test mode only)
|
||||
func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if test_mode:
|
||||
@@ -158,7 +184,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_retry_timer = 0.0
|
||||
_connect_retries += 1
|
||||
if _connect_retries > MAX_CONNECT_RETRIES:
|
||||
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
|
||||
push_error(
|
||||
"SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES
|
||||
)
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
_try_connect()
|
||||
@@ -176,7 +204,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
# Connection attempt failed — retry
|
||||
_bridge = null
|
||||
if _connect_retries >= MAX_CONNECT_RETRIES:
|
||||
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
|
||||
push_error(
|
||||
"SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES
|
||||
)
|
||||
_set_state(ConnectionState.ERROR)
|
||||
StreamPeerTCP.STATUS_NONE:
|
||||
_bridge = null # Reset and retry
|
||||
@@ -191,7 +221,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
|
||||
if (
|
||||
bridge_status == StreamPeerTCP.STATUS_ERROR
|
||||
or bridge_status == StreamPeerTCP.STATUS_NONE
|
||||
):
|
||||
var reason := "Connection dropped during handshake"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
@@ -215,8 +248,11 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
# Decode HandshakeMessage: { "protocol_version": N }
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary) \
|
||||
or not decoded.value.has("protocol_version"):
|
||||
if (
|
||||
decoded.status != null
|
||||
or not (decoded.value is Dictionary)
|
||||
or not decoded.value.has("protocol_version")
|
||||
):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
@@ -226,8 +262,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
var server_version: int = decoded.value["protocol_version"]
|
||||
if server_version != Protocol.PROTOCOL_VERSION:
|
||||
var reason := "Protocol version mismatch: server=%d, client=%d" % [
|
||||
server_version, Protocol.PROTOCOL_VERSION]
|
||||
var reason := (
|
||||
"Protocol version mismatch: server=%d, client=%d"
|
||||
% [server_version, Protocol.PROTOCOL_VERSION]
|
||||
)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
@@ -237,7 +275,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(
|
||||
GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
|
||||
GameState.world_seed,
|
||||
GameState.character_archetype,
|
||||
GameState.character_visual_descriptor
|
||||
)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
@@ -259,10 +300,15 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
|
||||
# from server SQLite so the client reflects the authoritative persisted state (D-138).
|
||||
_outbound_buffer.append({
|
||||
"tick": 0,
|
||||
"action_name": "RequestAllSettings",
|
||||
})
|
||||
(
|
||||
_outbound_buffer
|
||||
. append(
|
||||
{
|
||||
"tick": 0,
|
||||
"action_name": "RequestAllSettings",
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if _bridge == null:
|
||||
@@ -289,7 +335,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if err != OK:
|
||||
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
||||
else:
|
||||
push_error("SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size())
|
||||
push_error(
|
||||
"SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size()
|
||||
)
|
||||
StreamPeerTCP.STATUS_CONNECTING:
|
||||
pass # Should not happen in CONNECTED state
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
@@ -304,6 +352,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
# -- Input / snapshot ----------------------------------------------------------
|
||||
|
||||
|
||||
# Send input to simulation server.
|
||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
||||
# In test mode, inputs are delegated to the test harness.
|
||||
@@ -330,13 +379,14 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
if action_name.is_empty():
|
||||
return ERR_INVALID_PARAMETER
|
||||
var tick: int = GameState.current_tick
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
var entry: Dictionary = {"tick": tick, "action_name": action_name}
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data != null:
|
||||
entry["action_data"] = action_data
|
||||
_outbound_buffer.append(entry)
|
||||
return OK
|
||||
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
@@ -356,6 +406,7 @@ func poll_snapshot() -> Variant:
|
||||
|
||||
return null
|
||||
|
||||
|
||||
# Called by transport layer when raw bytes arrive from the server.
|
||||
# Latest-wins for positional state (stale frames are worthless), but one-shot
|
||||
# events (monologue, dialogue) are carried forward from overwritten snapshots
|
||||
@@ -367,12 +418,21 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
return
|
||||
if _last_snapshot != null:
|
||||
# Carry forward one-shot events the client hasn't consumed yet.
|
||||
if snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null:
|
||||
if (
|
||||
snapshot.get("current_monologue") == null
|
||||
and _last_snapshot.get("current_monologue") != null
|
||||
):
|
||||
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
|
||||
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
|
||||
if (
|
||||
snapshot.get("current_dialogue") == null
|
||||
and _last_snapshot.get("current_dialogue") != null
|
||||
):
|
||||
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
|
||||
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
|
||||
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
|
||||
if (
|
||||
snapshot.get("dialogue_response") == null
|
||||
and _last_snapshot.get("dialogue_response") != null
|
||||
):
|
||||
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
|
||||
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
|
||||
if old_conv_events.size() > 0:
|
||||
@@ -386,10 +446,14 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
|
||||
snapshot["save_result"] = _last_snapshot["save_result"]
|
||||
# #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot)
|
||||
if snapshot.get("settings_response") == null and _last_snapshot.get("settings_response") != null:
|
||||
if (
|
||||
snapshot.get("settings_response") == null
|
||||
and _last_snapshot.get("settings_response") != null
|
||||
):
|
||||
snapshot["settings_response"] = _last_snapshot["settings_response"]
|
||||
_last_snapshot = snapshot
|
||||
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
func drain_outbound() -> Array[Dictionary]:
|
||||
var inputs = _outbound_buffer.duplicate()
|
||||
@@ -399,24 +463,39 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
|
||||
# -- Wire protocol mapping -----------------------------------------------------
|
||||
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
match action:
|
||||
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
|
||||
InputMapper.Action.MOVE_EAST: return "MoveEast"
|
||||
InputMapper.Action.MOVE_SOUTHEAST: return "MoveSoutheast"
|
||||
InputMapper.Action.MOVE_SOUTH: return "MoveSouth"
|
||||
InputMapper.Action.MOVE_SOUTHWEST: return "MoveSouthwest"
|
||||
InputMapper.Action.MOVE_WEST: return "MoveWest"
|
||||
InputMapper.Action.MOVE_NORTHWEST: return "MoveNorthwest"
|
||||
InputMapper.Action.INTERACT: return "Interact"
|
||||
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
|
||||
InputMapper.Action.PAUSE: return "Pause"
|
||||
InputMapper.Action.UNPAUSE: return "Unpause"
|
||||
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
|
||||
InputMapper.Action.MOVE_NORTH:
|
||||
return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST:
|
||||
return "MoveNortheast"
|
||||
InputMapper.Action.MOVE_EAST:
|
||||
return "MoveEast"
|
||||
InputMapper.Action.MOVE_SOUTHEAST:
|
||||
return "MoveSoutheast"
|
||||
InputMapper.Action.MOVE_SOUTH:
|
||||
return "MoveSouth"
|
||||
InputMapper.Action.MOVE_SOUTHWEST:
|
||||
return "MoveSouthwest"
|
||||
InputMapper.Action.MOVE_WEST:
|
||||
return "MoveWest"
|
||||
InputMapper.Action.MOVE_NORTHWEST:
|
||||
return "MoveNorthwest"
|
||||
InputMapper.Action.INTERACT:
|
||||
return "Interact"
|
||||
InputMapper.Action.USE_PERCEPTION_MODE:
|
||||
return "UsePerceptionMode"
|
||||
InputMapper.Action.PAUSE:
|
||||
return "Pause"
|
||||
InputMapper.Action.UNPAUSE:
|
||||
return "Unpause"
|
||||
InputMapper.Action.TOGGLE_STANCE_UP:
|
||||
return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN:
|
||||
return "ToggleStanceDown"
|
||||
InputMapper.Action.OPEN_MENU:
|
||||
return "" # Client-only action, not part of wire protocol
|
||||
InputMapper.Action.BUG_REPORT:
|
||||
@@ -432,11 +511,11 @@ static func action_enum_to_wire(action: int) -> String:
|
||||
InputMapper.Action.DEBUG_COMMAND:
|
||||
return "DebugCommand" # #581: debug console command dispatch
|
||||
InputMapper.Action.CHANGE_SETTINGS:
|
||||
return "ChangeSettings" # #646: persist setting to server SQLite (D-138)
|
||||
return "ChangeSettings" # #646: persist setting to server SQLite (D-138)
|
||||
InputMapper.Action.REQUEST_ALL_SETTINGS:
|
||||
return "RequestAllSettings" # #646: unit variant — server sends full settings dump
|
||||
InputMapper.Action.DELETE_SETTING:
|
||||
return "DeleteSetting" # #646: struct variant — delete setting by key
|
||||
return "DeleteSetting" # #646: struct variant — delete setting by key
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
@@ -9,9 +9,11 @@ const STRINGS_PATH: String = "res://data/ui-strings.yaml"
|
||||
var _strings: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_strings()
|
||||
|
||||
|
||||
func _load_strings() -> void:
|
||||
if not FileAccess.file_exists(STRINGS_PATH):
|
||||
push_warning("UIStrings: file not found: %s" % STRINGS_PATH)
|
||||
@@ -26,6 +28,7 @@ func _load_strings() -> void:
|
||||
_loaded = true
|
||||
print("UIStrings: loaded %d strings from %s" % [_strings.size(), STRINGS_PATH])
|
||||
|
||||
|
||||
## Get a UI string by dotted key. Returns the key itself if not found.
|
||||
func get_text(key: String) -> String:
|
||||
if _strings.has(key):
|
||||
@@ -33,20 +36,24 @@ func get_text(key: String) -> String:
|
||||
push_warning("UIStrings: missing key '%s'" % key)
|
||||
return key
|
||||
|
||||
|
||||
## Check if a key exists.
|
||||
func has_key(key: String) -> bool:
|
||||
return _strings.has(key)
|
||||
|
||||
|
||||
## Get all keys.
|
||||
func get_all_keys() -> PackedStringArray:
|
||||
return PackedStringArray(_strings.keys())
|
||||
|
||||
|
||||
## Reload from disk (useful for hot-reload during development).
|
||||
func reload() -> void:
|
||||
_strings.clear()
|
||||
_loaded = false
|
||||
_load_strings()
|
||||
|
||||
|
||||
## Parse YAML with arbitrary nesting depth.
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
|
||||
@@ -15,11 +15,11 @@ extends RefCounted
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497).
|
||||
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _current_room_id: String = ""
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ func load_room(room_id: String) -> void:
|
||||
_latched = kept
|
||||
|
||||
# Load per-room checklist
|
||||
var room_path := _content_base.path_join(
|
||||
"gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_path := _content_base.path_join("gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_data := _load_checklist_file(room_path)
|
||||
if room_data.has("conditions"):
|
||||
_room_conditions = room_data["conditions"]
|
||||
@@ -90,12 +89,17 @@ func get_results() -> Array:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty():
|
||||
continue
|
||||
results.append({
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
})
|
||||
(
|
||||
results
|
||||
. append(
|
||||
{
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
}
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@@ -136,12 +140,17 @@ static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||
for i in conditions.size():
|
||||
if conditions[i].get("id", "").is_empty():
|
||||
push_warning(
|
||||
"ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
(
|
||||
"ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results"
|
||||
% [i, path]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
@@ -225,6 +234,7 @@ func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
|
||||
func _find_entity(entity_id: int) -> bool:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
@@ -236,6 +246,7 @@ func _find_entity(entity_id: int) -> bool:
|
||||
|
||||
# -- YAML parsing --------------------------------------------------------------
|
||||
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
|
||||
@@ -12,35 +12,35 @@ const TILE_SIZE: int = 32
|
||||
# All world content composited into one texture, then fog drawn over it.
|
||||
# Y-sort contract: all children of YSortGroup MUST use z_index = 0.
|
||||
# z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715).
|
||||
const Z_FLOOR: int = 0 # Floor tiles — ground plane
|
||||
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
|
||||
const Z_FLOOR: int = 0 # Floor tiles — ground plane
|
||||
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
|
||||
# z:20-99 reserved: liquid surface (z:110), surface effects
|
||||
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
|
||||
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
|
||||
# z:110 reserved: liquid surface occlusion (alpha by depth)
|
||||
# z:150-199 reserved: ground VFX (smoke origins, gas pools, sparks)
|
||||
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
|
||||
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
|
||||
# z:250-299 reserved: mid-air VFX (rising smoke, floating particles)
|
||||
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
|
||||
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
|
||||
# z:325-349 reserved: ceiling VFX (smoke through ceiling)
|
||||
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
|
||||
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
|
||||
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
|
||||
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
|
||||
# z:500-899 reserved: edge cases
|
||||
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
|
||||
const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060)
|
||||
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
|
||||
const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060)
|
||||
# Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101)
|
||||
# z:-75 to z:-51 reserved: lower floor VFX
|
||||
#
|
||||
# INSERT SCOPE (CanvasLayer 10)
|
||||
# Bloom-rendered, not affected by fog or camera transform.
|
||||
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
|
||||
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
|
||||
#
|
||||
# UI SCOPE (CanvasLayer 20)
|
||||
# HUD, monologue, cursor — always visible.
|
||||
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
|
||||
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
|
||||
#
|
||||
# MODAL SCOPE (CanvasLayer 30)
|
||||
# Full-screen overlays: pause, inventory modal, death screen.
|
||||
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
|
||||
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
|
||||
#
|
||||
# Rendering ceiling: 10 floors (25m) above current floor.
|
||||
# Above this: no sprites, ground shadows + environmental effects only.
|
||||
@@ -52,18 +52,18 @@ const VISIBLE_FLOOR_DEPTH: int = 2
|
||||
# Color represents the player's RELATIONSHIP to the entity, not an objective property.
|
||||
# Phase 1: default colors mapped by entity kind (Player/Npc/Object/Terrain).
|
||||
# Phase 2 (#361): colors derived from RelationshipState via the knowledge graph.
|
||||
const ENTITY_COLOR_UNKNOWN: Color = Color("#4a9ebb") # Unknown/Neutral — cool teal
|
||||
const ENTITY_COLOR_UNKNOWN: Color = Color("#4a9ebb") # Unknown/Neutral — cool teal
|
||||
const ENTITY_COLOR_FRIENDLY: Color = Color("#6bc9a6") # Known/Friendly — soft green
|
||||
const ENTITY_COLOR_POI: Color = Color("#e8c547") # Person of Interest — warm amber
|
||||
const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — muted red
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
const ENTITY_COLOR_POI: Color = Color("#e8c547") # Person of Interest — warm amber
|
||||
const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — muted red
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# D-048/D-056: Insert-styled UI color palette
|
||||
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
|
||||
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
|
||||
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI
|
||||
const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
|
||||
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
|
||||
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI
|
||||
const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
|
||||
|
||||
# D-015: Peripheral vision dimming
|
||||
const PERIPHERAL_ALPHA: float = 0.5
|
||||
@@ -87,29 +87,41 @@ const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
const CAMERA_SMOOTHING_SPEED: float = 8.0
|
||||
|
||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
|
||||
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
"Friendly":
|
||||
return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest":
|
||||
return ENTITY_COLOR_POI
|
||||
"Hostile":
|
||||
return ENTITY_COLOR_HOSTILE
|
||||
"Unknown":
|
||||
return ENTITY_COLOR_UNKNOWN
|
||||
_:
|
||||
return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
"Player":
|
||||
return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain":
|
||||
return ENTITY_COLOR_OBJECT
|
||||
"Npc":
|
||||
return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_:
|
||||
return ENTITY_COLOR_OBJECT
|
||||
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
|
||||
@@ -13,7 +13,7 @@ var journal_panel: Node = null
|
||||
# main.gd and this coordinator both append to the same array.
|
||||
var _pending_record_inputs: Array = []
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1
|
||||
@@ -44,6 +44,7 @@ func connect_signals() -> void:
|
||||
|
||||
# -- Snapshot consumers (registered with SnapshotEventRouter) -----------------
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
func consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
@@ -102,17 +103,24 @@ func consume_dialogue_response() -> void:
|
||||
|
||||
# -- Signal handlers ----------------------------------------------------------
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection -> send to server
|
||||
func on_dialogue_option_selected(response_id: String, _text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data":
|
||||
{
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# D-063: Confrontation beat monologue -> show on monologue display (layer 7)
|
||||
@@ -141,14 +149,21 @@ func on_dialogue_unpause_requested() -> void:
|
||||
|
||||
# D-064: Handle walk-away -> send WalkAway{npc_id} to server
|
||||
func on_dialogue_dismissed() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data":
|
||||
{
|
||||
"target_entity_id":
|
||||
_last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes.
|
||||
|
||||
@@ -5,15 +5,15 @@ const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
var _camera_anchored: bool = false
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap on next frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
|
||||
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
|
||||
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@onready var hud = $UILayer/HUD
|
||||
@onready var hud = $InsertOverlay/HUD
|
||||
@onready var monologue_display = $UILayer/MonologueDisplay
|
||||
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
|
||||
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
|
||||
@@ -24,17 +24,16 @@ var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal han
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
|
||||
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
|
||||
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -62,25 +61,54 @@ func _ready() -> void:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# #775: Initialize extracted components
|
||||
_consumers = SnapshotConsumers.new().init({
|
||||
"monologue_display": monologue_display,
|
||||
"dialogue_box": dialogue_box,
|
||||
"examine_display": examine_display,
|
||||
"loading_screen": loading_screen,
|
||||
"debug_console": debug_console,
|
||||
"cursor_renderer": cursor_renderer,
|
||||
"interaction_list": interaction_list,
|
||||
"interaction_prompt": interaction_prompt,
|
||||
"minimap": minimap,
|
||||
"star_map": star_map,
|
||||
}, _screen_flash)
|
||||
# D-170: Register HUD nodes into layer groups
|
||||
for node in [
|
||||
hud,
|
||||
minimap,
|
||||
stance_indicator,
|
||||
interaction_prompt,
|
||||
interaction_list,
|
||||
inventory_grid,
|
||||
news_ticker,
|
||||
examine_display,
|
||||
world_radial,
|
||||
]:
|
||||
if node and node is CanvasItem:
|
||||
HudGroups.register(node, "gameplay")
|
||||
|
||||
_dialogue = DialogueCoordinator.new().init({
|
||||
"dialogue_box": dialogue_box,
|
||||
"monologue_display": monologue_display,
|
||||
"journal_panel": journal_panel,
|
||||
}, _pending_record_inputs)
|
||||
# #775: Initialize extracted components
|
||||
_consumers = (
|
||||
SnapshotConsumers
|
||||
. new()
|
||||
. init(
|
||||
{
|
||||
"monologue_display": monologue_display,
|
||||
"dialogue_box": dialogue_box,
|
||||
"examine_display": examine_display,
|
||||
"loading_screen": loading_screen,
|
||||
"debug_console": debug_console,
|
||||
"cursor_renderer": cursor_renderer,
|
||||
"interaction_list": interaction_list,
|
||||
"interaction_prompt": interaction_prompt,
|
||||
"minimap": minimap,
|
||||
"star_map": star_map,
|
||||
},
|
||||
_screen_flash
|
||||
)
|
||||
)
|
||||
|
||||
_dialogue = (
|
||||
DialogueCoordinator
|
||||
. new()
|
||||
. init(
|
||||
{
|
||||
"dialogue_box": dialogue_box,
|
||||
"monologue_display": monologue_display,
|
||||
"journal_panel": journal_panel,
|
||||
},
|
||||
_pending_record_inputs
|
||||
)
|
||||
)
|
||||
_dialogue.connect_signals()
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
@@ -106,8 +134,8 @@ func _ready() -> void:
|
||||
_router.register_always(gauntlet_hud.update_from_state)
|
||||
if checklist_overlay:
|
||||
_router.register_always(checklist_overlay.update_from_state)
|
||||
if time_display:
|
||||
_router.register_always(time_display.update_from_state)
|
||||
if hud:
|
||||
_router.register_always(hud.update_from_state)
|
||||
if news_ticker:
|
||||
_router.register_always(news_ticker.update_from_state)
|
||||
if journal_panel:
|
||||
@@ -237,13 +265,17 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
func _on_connection_state_changed(
|
||||
_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState
|
||||
) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
|
||||
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
func _on_sim_connected_for_load(
|
||||
_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState
|
||||
) -> void:
|
||||
if new_state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load):
|
||||
@@ -256,11 +288,16 @@ func _dispatch_pending_load() -> void:
|
||||
if load_path.is_empty():
|
||||
return
|
||||
GameState.pending_load_path = ""
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.LOAD_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": load_path},
|
||||
})
|
||||
var err := (
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.LOAD_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": load_path},
|
||||
}
|
||||
)
|
||||
)
|
||||
if err != OK:
|
||||
push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err))
|
||||
if loading_screen:
|
||||
|
||||
@@ -86,15 +86,23 @@ func _try_extract_message() -> PackedByteArray:
|
||||
if _pending_length < 0:
|
||||
if _read_buffer.size() < 4:
|
||||
return PackedByteArray()
|
||||
_pending_length = (_read_buffer[0] << 24) | (_read_buffer[1] << 16) | \
|
||||
(_read_buffer[2] << 8) | _read_buffer[3]
|
||||
_pending_length = (
|
||||
(_read_buffer[0] << 24)
|
||||
| (_read_buffer[1] << 16)
|
||||
| (_read_buffer[2] << 8)
|
||||
| _read_buffer[3]
|
||||
)
|
||||
_read_buffer = _read_buffer.slice(4)
|
||||
|
||||
if _pending_length > MAX_MESSAGE_SIZE:
|
||||
# Stream is corrupt — we can't find the next valid frame boundary.
|
||||
# Disconnect rather than silently discarding valid buffered data.
|
||||
push_error(
|
||||
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
|
||||
(
|
||||
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting"
|
||||
% [_pending_length, MAX_MESSAGE_SIZE]
|
||||
)
|
||||
)
|
||||
_corrupt = true
|
||||
_pending_length = -1
|
||||
_read_buffer.clear()
|
||||
@@ -129,6 +137,7 @@ func reset() -> void:
|
||||
# These exist for unit tests that verify framing logic without a live TCP
|
||||
# connection. Not used in production code paths.
|
||||
|
||||
|
||||
## Encode a payload into a framed byte array: [4-byte BE length][payload].
|
||||
static func frame_encode(payload: PackedByteArray) -> PackedByteArray:
|
||||
var len := payload.size()
|
||||
|
||||
@@ -14,9 +14,9 @@ extends Node
|
||||
## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
|
||||
const PROTOCOL_VERSION: int = 20
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
|
||||
## Decode an ObserverSnapshot from MessagePack bytes.
|
||||
## Returns decoded snapshot Dictionary or null on error.
|
||||
## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty
|
||||
@@ -36,7 +36,11 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var version: Variant = raw.get("version")
|
||||
if version != PROTOCOL_VERSION:
|
||||
push_error(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
|
||||
(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync."
|
||||
% [version, PROTOCOL_VERSION]
|
||||
)
|
||||
)
|
||||
return null
|
||||
|
||||
var entities: Array[Dictionary] = []
|
||||
@@ -51,7 +55,11 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
if dropped > 0:
|
||||
push_error(
|
||||
"Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
|
||||
(
|
||||
"Protocol: %d/%d entities failed to decode (D-010 information boundary violation)"
|
||||
% [dropped, raw_entities.size()]
|
||||
)
|
||||
)
|
||||
|
||||
# GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63
|
||||
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
|
||||
@@ -71,7 +79,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var raw_vtiles: Variant = raw.get("visible_tiles")
|
||||
if raw_vtiles is Array:
|
||||
for raw_tile in raw_vtiles:
|
||||
if raw_tile is Dictionary and raw_tile.has("x") and raw_tile.has("y") and raw_tile.has("z"):
|
||||
if (
|
||||
raw_tile is Dictionary
|
||||
and raw_tile.has("x")
|
||||
and raw_tile.has("y")
|
||||
and raw_tile.has("z")
|
||||
):
|
||||
var tile_entry := {
|
||||
"x": int(raw_tile["x"]),
|
||||
"y": int(raw_tile["y"]),
|
||||
@@ -119,11 +132,16 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_inventory is Array:
|
||||
for raw_item in raw_inventory:
|
||||
if raw_item is Dictionary and raw_item.has("item_id") and raw_item.has("name"):
|
||||
player_inventory.append({
|
||||
"item_id": int(raw_item["item_id"]),
|
||||
"name": str(raw_item["name"]),
|
||||
"slot": int(raw_item.get("slot", 0)),
|
||||
})
|
||||
(
|
||||
player_inventory
|
||||
. append(
|
||||
{
|
||||
"item_id": int(raw_item["item_id"]),
|
||||
"name": str(raw_item["name"]),
|
||||
"slot": int(raw_item.get("slot", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060) — cognitive delay fog entities
|
||||
# Bounded: server sends at most ~50 pending recognitions per snapshot (practical limit
|
||||
@@ -135,17 +153,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var count := 0
|
||||
for raw_pr in raw_recognitions:
|
||||
if count >= MAX_PENDING_RECOGNITIONS:
|
||||
push_warning("Protocol: pending_recognitions truncated at %d entries" % MAX_PENDING_RECOGNITIONS)
|
||||
push_warning(
|
||||
(
|
||||
"Protocol: pending_recognitions truncated at %d entries"
|
||||
% MAX_PENDING_RECOGNITIONS
|
||||
)
|
||||
)
|
||||
break
|
||||
if raw_pr is Dictionary and raw_pr.has("entity_id") and raw_pr.has("x") and raw_pr.has("y"):
|
||||
pending_recognitions.append({
|
||||
"entity_id": int(raw_pr["entity_id"]),
|
||||
"x": float(raw_pr["x"]),
|
||||
"y": float(raw_pr["y"]),
|
||||
"z": int(raw_pr.get("z", 0)),
|
||||
"remaining_ticks": int(raw_pr.get("remaining_ticks", 0)),
|
||||
"total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)),
|
||||
})
|
||||
if (
|
||||
raw_pr is Dictionary
|
||||
and raw_pr.has("entity_id")
|
||||
and raw_pr.has("x")
|
||||
and raw_pr.has("y")
|
||||
):
|
||||
(
|
||||
pending_recognitions
|
||||
. append(
|
||||
{
|
||||
"entity_id": int(raw_pr["entity_id"]),
|
||||
"x": float(raw_pr["x"]),
|
||||
"y": float(raw_pr["y"]),
|
||||
"z": int(raw_pr.get("z", 0)),
|
||||
"remaining_ticks": int(raw_pr.get("remaining_ticks", 0)),
|
||||
"total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)),
|
||||
}
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
|
||||
# v7: current_dialogue (#435, D-061/D-062) — NPC speech + player response options
|
||||
@@ -159,12 +192,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_options is Array:
|
||||
for raw_opt in raw_options:
|
||||
if raw_opt is Dictionary and raw_opt.has("text"):
|
||||
dialogue_options.append({
|
||||
"text": str(raw_opt["text"]),
|
||||
"response_id": str(raw_opt.get("response_id", "")),
|
||||
"priority": int(raw_opt.get("priority", 0)),
|
||||
"confrontation": bool(raw_opt.get("confrontation", false)),
|
||||
})
|
||||
(
|
||||
dialogue_options
|
||||
. append(
|
||||
{
|
||||
"text": str(raw_opt["text"]),
|
||||
"response_id": str(raw_opt.get("response_id", "")),
|
||||
"priority": int(raw_opt.get("priority", 0)),
|
||||
"confrontation": bool(raw_opt.get("confrontation", false)),
|
||||
}
|
||||
)
|
||||
)
|
||||
current_dialogue = {
|
||||
"npc_name": str(raw_dialogue.get("npc_name", "")),
|
||||
"npc_entity_id": int(raw_dialogue.get("npc_entity_id", -1)),
|
||||
@@ -190,13 +228,18 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_conv_events is Array:
|
||||
for raw_ce in raw_conv_events:
|
||||
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
|
||||
conversation_events.append({
|
||||
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||
"target_id": int(raw_ce.get("target_id", 0)),
|
||||
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||
"target_name": str(raw_ce.get("target_name", "")),
|
||||
"occluded_line": str(raw_ce["occluded_line"]),
|
||||
})
|
||||
(
|
||||
conversation_events
|
||||
. append(
|
||||
{
|
||||
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||
"target_id": int(raw_ce.get("target_id", 0)),
|
||||
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||
"target_name": str(raw_ce.get("target_name", "")),
|
||||
"occluded_line": str(raw_ce["occluded_line"]),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
|
||||
var conversation_ended: Array = []
|
||||
@@ -204,10 +247,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_conv_ended is Array:
|
||||
for raw_end in raw_conv_ended:
|
||||
if raw_end is Dictionary:
|
||||
conversation_ended.append({
|
||||
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
(
|
||||
conversation_ended
|
||||
. append(
|
||||
{
|
||||
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||
@@ -215,15 +263,26 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
for raw_poi in raw_pois:
|
||||
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||
poi_list.append({
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
if (
|
||||
raw_poi is Dictionary
|
||||
and raw_poi.has("poi_id")
|
||||
and raw_poi.has("x")
|
||||
and raw_poi.has("y")
|
||||
):
|
||||
(
|
||||
poi_list
|
||||
. append(
|
||||
{
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category":
|
||||
str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
@@ -268,9 +327,14 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_tce is Array:
|
||||
for raw_ev in raw_tce:
|
||||
if raw_ev is Dictionary and raw_ev.has("triangle_id"):
|
||||
triangle_crisis_events.append({
|
||||
"triangle_id": int(raw_ev["triangle_id"]),
|
||||
})
|
||||
(
|
||||
triangle_crisis_events
|
||||
. append(
|
||||
{
|
||||
"triangle_id": int(raw_ev["triangle_id"]),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v20: settings_response (#627, D-138) — server ack after ChangeSettings / full settings dump
|
||||
# after RequestAllSettings. kind = "full"|"ack". "full": settings array [{key, value}].
|
||||
@@ -285,10 +349,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_sr_settings is Array:
|
||||
for raw_s in raw_sr_settings:
|
||||
if raw_s is Dictionary and raw_s.has("key"):
|
||||
sr_settings.append({
|
||||
"key": str(raw_s["key"]),
|
||||
"value": raw_s.get("value"),
|
||||
})
|
||||
(
|
||||
sr_settings
|
||||
. append(
|
||||
{
|
||||
"key": str(raw_s["key"]),
|
||||
"value": raw_s.get("value"),
|
||||
}
|
||||
)
|
||||
)
|
||||
settings_response = {"kind": "full", "settings": sr_settings}
|
||||
elif sr_kind == "ack":
|
||||
settings_response = {
|
||||
@@ -338,27 +407,37 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_kg_entities is Array:
|
||||
for raw_ke in raw_kg_entities:
|
||||
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||
kg_entities.append({
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
})
|
||||
(
|
||||
kg_entities
|
||||
. append(
|
||||
{
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
var kg_facts: Array = []
|
||||
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||
if raw_kg_facts is Array:
|
||||
for raw_kf in raw_kg_facts:
|
||||
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||
kg_facts.append({
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
})
|
||||
(
|
||||
kg_facts
|
||||
. append(
|
||||
{
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
player_knowledge = {
|
||||
"entities": kg_entities,
|
||||
"facts": kg_facts,
|
||||
@@ -396,8 +475,13 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
## Decode a single VisibleEntity from a raw msgpack map.
|
||||
static func _decode_entity(raw: Dictionary) -> Variant:
|
||||
if not raw.has("entity_id") or not raw.has("x") or not raw.has("y") \
|
||||
or not raw.has("z") or not raw.has("kind"):
|
||||
if (
|
||||
not raw.has("entity_id")
|
||||
or not raw.has("x")
|
||||
or not raw.has("y")
|
||||
or not raw.has("z")
|
||||
or not raw.has("kind")
|
||||
):
|
||||
push_warning("Protocol: entity missing required fields: %s" % str(raw.keys()))
|
||||
return null
|
||||
|
||||
@@ -485,23 +569,25 @@ static func _decode_verb_option(raw) -> Variant:
|
||||
## Returns { "variant": String, "data": Variant } in both cases.
|
||||
static func _decode_enum_variant(raw) -> Dictionary:
|
||||
if raw is String:
|
||||
return { "variant": raw, "data": null }
|
||||
return {"variant": raw, "data": null}
|
||||
if raw is Dictionary and raw.size() == 1:
|
||||
var variant_name: String = raw.keys()[0]
|
||||
return { "variant": variant_name, "data": raw[variant_name] }
|
||||
return {"variant": variant_name, "data": raw[variant_name]}
|
||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||
return { "variant": "Unknown", "data": raw }
|
||||
return {"variant": "Unknown", "data": raw}
|
||||
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
|
||||
static func encode_startup_message(
|
||||
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null) -> PackedByteArray:
|
||||
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null
|
||||
) -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
# Explicit match prevents unknown strings silently reaching the server as
|
||||
# garbage enum values — fail loudly and fall back to "Detective".
|
||||
@@ -512,7 +598,12 @@ static func encode_startup_message(
|
||||
"smuggler":
|
||||
archetype_variant = "Smuggler"
|
||||
_:
|
||||
push_error("Protocol: unknown character_archetype '%s' — defaulting to 'Detective'" % character_archetype)
|
||||
push_error(
|
||||
(
|
||||
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
|
||||
% character_archetype
|
||||
)
|
||||
)
|
||||
archetype_variant = "Detective"
|
||||
var msg := {
|
||||
"world_seed": world_seed,
|
||||
@@ -531,7 +622,9 @@ static func encode_startup_message(
|
||||
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
## action_data: null for unit variants, String for UsePerceptionMode
|
||||
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
|
||||
static func encode_player_input(
|
||||
tick: int, action_name: String, action_data: Variant = null
|
||||
) -> PackedByteArray:
|
||||
var action_value: Variant = _encode_action(action_name, action_data)
|
||||
|
||||
var input := {
|
||||
@@ -555,10 +648,15 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
wire_inputs.append({
|
||||
"tick": input["tick"],
|
||||
"action": _encode_action(action_name, action_data),
|
||||
})
|
||||
(
|
||||
wire_inputs
|
||||
. append(
|
||||
{
|
||||
"tick": input["tick"],
|
||||
"action": _encode_action(action_name, action_data),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
@@ -573,10 +671,10 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
|
||||
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
|
||||
if action_data != null:
|
||||
return { action_name: action_data }
|
||||
return {action_name: action_data}
|
||||
# Interact is a struct variant — server expects named fields, not a bare string
|
||||
if action_name == "Interact":
|
||||
return { "Interact": { "target_entity_id": null, "verb": null } }
|
||||
return {"Interact": {"target_entity_id": null, "verb": null}}
|
||||
return action_name
|
||||
|
||||
|
||||
@@ -586,11 +684,13 @@ static func _encode_action(action_name: String, action_data: Variant) -> Variant
|
||||
## queuing via SimBridge._outbound_buffer.
|
||||
## The server receives this as a PlayerAction::ChangeSettings variant.
|
||||
static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
var entries: Array = [{
|
||||
"tick": 0,
|
||||
"action_name": "ChangeSettings",
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}]
|
||||
var entries: Array = [
|
||||
{
|
||||
"tick": 0,
|
||||
"action_name": "ChangeSettings",
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}
|
||||
]
|
||||
var result = Messagepack.encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_change_settings failed: %s" % result.status)
|
||||
|
||||
@@ -7,14 +7,34 @@ extends RefCounted
|
||||
|
||||
const _WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
Vector2i(7, 7),
|
||||
Vector2i(8, 7),
|
||||
Vector2i(9, 7),
|
||||
Vector2i(10, 7),
|
||||
Vector2i(11, 7),
|
||||
Vector2i(12, 7),
|
||||
Vector2i(13, 7),
|
||||
Vector2i(14, 7),
|
||||
Vector2i(7, 14),
|
||||
Vector2i(8, 14),
|
||||
Vector2i(9, 14),
|
||||
Vector2i(10, 14),
|
||||
Vector2i(11, 14),
|
||||
Vector2i(12, 14),
|
||||
Vector2i(13, 14),
|
||||
Vector2i(14, 14),
|
||||
Vector2i(7, 8),
|
||||
Vector2i(7, 9),
|
||||
Vector2i(7, 10),
|
||||
Vector2i(7, 11),
|
||||
Vector2i(7, 12),
|
||||
Vector2i(7, 13),
|
||||
Vector2i(14, 8),
|
||||
Vector2i(14, 9),
|
||||
Vector2i(14, 10),
|
||||
Vector2i(14, 11),
|
||||
Vector2i(14, 12),
|
||||
Vector2i(14, 13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
@@ -48,6 +68,7 @@ func process_facing(new_facing: String) -> void:
|
||||
|
||||
# -- Snapshot generation -------------------------------------------------------
|
||||
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
tick += 1
|
||||
|
||||
@@ -76,42 +97,60 @@ func snapshot() -> Dictionary:
|
||||
var py := player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
var entities: Array = [
|
||||
{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
"visibility": "Forward",
|
||||
}
|
||||
]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
})
|
||||
(
|
||||
entities
|
||||
. append(
|
||||
{
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
(
|
||||
nearby
|
||||
. append(
|
||||
{
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs":
|
||||
[
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{
|
||||
"kind": "ExamineNpc",
|
||||
"label": "Observe",
|
||||
"priority": 2,
|
||||
"available": true
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
@@ -128,11 +167,28 @@ func snapshot() -> Dictionary:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, # gdlint:ignore = max-line-length
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, # gdlint:ignore = max-line-length
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, # gdlint:ignore = max-line-length
|
||||
"speech":
|
||||
"Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options":
|
||||
[
|
||||
{
|
||||
"text": "Just arrived. Still getting my bearings.",
|
||||
"response_id": "kael_greet_01",
|
||||
"priority": 1,
|
||||
"confrontation": false
|
||||
}, # gdlint:ignore = max-line-length
|
||||
{
|
||||
"text": "Passing through. Know where I can find work?",
|
||||
"response_id": "kael_greet_02",
|
||||
"priority": 2,
|
||||
"confrontation": false
|
||||
}, # gdlint:ignore = max-line-length
|
||||
{
|
||||
"text": "I saw you near the cargo bay last night.",
|
||||
"response_id": "kael_confront_01",
|
||||
"priority": 3,
|
||||
"confrontation": true
|
||||
}, # gdlint:ignore = max-line-length
|
||||
],
|
||||
}
|
||||
|
||||
@@ -142,26 +198,55 @@ func snapshot() -> Dictionary:
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
(
|
||||
pending_recs
|
||||
. append(
|
||||
{
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, # gdlint:ignore = max-line-length
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
{
|
||||
"speaker": "Mira",
|
||||
"target": "Soren",
|
||||
"line": "The cargo manifests don't add up. Three containers unaccounted for."
|
||||
},
|
||||
{
|
||||
"speaker": "Soren",
|
||||
"target": "Mira",
|
||||
"line": "Could be a logging error. Happens every... cycle."
|
||||
},
|
||||
{
|
||||
"speaker": "Mira",
|
||||
"target": "Soren",
|
||||
"line": "Not like this. Someone moved them after... check."
|
||||
},
|
||||
{
|
||||
"speaker": "Soren",
|
||||
"target": "Mira",
|
||||
"line": "You're reading too much into it. The docks are... these days."
|
||||
},
|
||||
{
|
||||
"speaker": "Mira",
|
||||
"target": "Soren",
|
||||
"line": "Then explain the weight discrepancy. Two hundred kilos... just gone."
|
||||
}, # gdlint:ignore = max-line-length
|
||||
{
|
||||
"speaker": "Soren",
|
||||
"target": "Mira",
|
||||
"line": "Fine. I'll pull the bay... tonight. But keep this between us."
|
||||
},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
@@ -170,20 +255,26 @@ func snapshot() -> Dictionary:
|
||||
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
(
|
||||
conv_events
|
||||
. append(
|
||||
{
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
}
|
||||
)
|
||||
)
|
||||
elif tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"game_time":
|
||||
{
|
||||
"day": 0,
|
||||
"time_of_day": tick * 10,
|
||||
"day_phase": "Morning",
|
||||
@@ -207,6 +298,7 @@ func snapshot() -> Dictionary:
|
||||
|
||||
# -- Map generation ------------------------------------------------------------
|
||||
|
||||
|
||||
func _tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
@@ -216,8 +308,9 @@ func _tiles() -> Array:
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var is_edge := (
|
||||
x == room_x or x == room_x + room_w - 1 or y == room_y or y == room_y + room_h - 1
|
||||
)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
@@ -253,7 +346,9 @@ func _visible_tiles() -> Array:
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
|
||||
vtiles.append(
|
||||
{"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)}
|
||||
)
|
||||
return vtiles
|
||||
|
||||
|
||||
@@ -262,8 +357,9 @@ func _get_tile_type(x: int, y: int) -> String:
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var is_edge := (
|
||||
x == room_x or x == room_x + room_w - 1 or y == room_y or y == room_y + room_h - 1
|
||||
)
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
return "door"
|
||||
@@ -273,6 +369,7 @@ func _get_tile_type(x: int, y: int) -> String:
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
@@ -302,25 +399,43 @@ func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
|
||||
static func action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
"MoveNorth":
|
||||
return Vector2i(0, -1)
|
||||
"MoveNortheast":
|
||||
return Vector2i(1, -1)
|
||||
"MoveEast":
|
||||
return Vector2i(1, 0)
|
||||
"MoveSoutheast":
|
||||
return Vector2i(1, 1)
|
||||
"MoveSouth":
|
||||
return Vector2i(0, 1)
|
||||
"MoveSouthwest":
|
||||
return Vector2i(-1, 1)
|
||||
"MoveWest":
|
||||
return Vector2i(-1, 0)
|
||||
"MoveNorthwest":
|
||||
return Vector2i(-1, -1)
|
||||
_:
|
||||
return Vector2i.ZERO
|
||||
|
||||
|
||||
static func delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
Vector2i(0, -1):
|
||||
return "North"
|
||||
Vector2i(1, -1):
|
||||
return "Northeast"
|
||||
Vector2i(1, 0):
|
||||
return "East"
|
||||
Vector2i(1, 1):
|
||||
return "Southeast"
|
||||
Vector2i(0, 1):
|
||||
return "South"
|
||||
Vector2i(-1, 1):
|
||||
return "Southwest"
|
||||
Vector2i(-1, 0):
|
||||
return "West"
|
||||
Vector2i(-1, -1):
|
||||
return "Northwest"
|
||||
_:
|
||||
return "North"
|
||||
|
||||
@@ -40,12 +40,25 @@ const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
|
||||
## All body segment names in assembly order. D-160: 14 base + 2 swappable torso + 2 face = 18.
|
||||
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
|
||||
const ALL_SEGMENTS: Array[String] = [
|
||||
"head", "neck", "torso_upper", "torso", "hips",
|
||||
"arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r",
|
||||
"hand_l", "hand_r",
|
||||
"leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r",
|
||||
"foot_l", "foot_r",
|
||||
"eyes", "eyebrows",
|
||||
"head",
|
||||
"neck",
|
||||
"torso_upper",
|
||||
"torso",
|
||||
"hips",
|
||||
"arm_upper_l",
|
||||
"arm_upper_r",
|
||||
"arm_lower_l",
|
||||
"arm_lower_r",
|
||||
"hand_l",
|
||||
"hand_r",
|
||||
"leg_upper_l",
|
||||
"leg_upper_r",
|
||||
"leg_lower_l",
|
||||
"leg_lower_r",
|
||||
"foot_l",
|
||||
"foot_r",
|
||||
"eyes",
|
||||
"eyebrows",
|
||||
]
|
||||
|
||||
## Segments that do NOT receive the skin tone shader — preserve original embedded material.
|
||||
@@ -107,14 +120,14 @@ var _overhead_attachment: BoneAttachment3D = null
|
||||
|
||||
# Inspectable state for tests
|
||||
var _active_torso_variant: String = "full"
|
||||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||||
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
|
||||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||||
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
|
||||
|
||||
var _toon_shader: Shader = null
|
||||
var _toon_masked_shader: Shader = null
|
||||
var _outline_shader: Shader = null
|
||||
var _white_mask: ImageTexture = null # 1x1 white pixel — forces full-body tinting
|
||||
var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png
|
||||
var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -140,6 +153,7 @@ func _load_shaders() -> void:
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Rebuild the full character from a CharacterVisualDescriptor.
|
||||
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
|
||||
_clear()
|
||||
@@ -168,8 +182,14 @@ func set_facing(direction: Variant) -> void:
|
||||
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
|
||||
elif direction is String:
|
||||
var angles: Dictionary = {
|
||||
"south": 0.0, "southwest": 45.0, "west": 90.0, "northwest": 135.0,
|
||||
"north": 180.0, "northeast": -135.0, "east": -90.0, "southeast": -45.0,
|
||||
"south": 0.0,
|
||||
"southwest": 45.0,
|
||||
"west": 90.0,
|
||||
"northwest": 135.0,
|
||||
"north": 180.0,
|
||||
"northeast": -135.0,
|
||||
"east": -90.0,
|
||||
"southeast": -45.0,
|
||||
}
|
||||
rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
|
||||
|
||||
@@ -232,6 +252,7 @@ func get_overhead_anchor() -> Marker3D:
|
||||
# Internal — teardown
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _clear() -> void:
|
||||
# Outline nodes are duplicates of body/clothing meshes — must be freed FIRST,
|
||||
# before the source meshes are removed. _rebuild_outlines() is not called here
|
||||
@@ -287,6 +308,7 @@ func _clear() -> void:
|
||||
# Internal — skeleton
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_skeleton() -> void:
|
||||
if not ResourceLoader.exists(SKELETON_PATH):
|
||||
push_error("CharacterVisual: skeleton not found at %s" % SKELETON_PATH)
|
||||
@@ -317,7 +339,12 @@ func _validate_slot_bones() -> void:
|
||||
for slot: String in SLOT_TO_BONE:
|
||||
var bone_name: String = SLOT_TO_BONE[slot]
|
||||
if _skeleton.find_bone(bone_name) == -1:
|
||||
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
|
||||
push_warning(
|
||||
(
|
||||
"CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton"
|
||||
% [slot, bone_name]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
|
||||
@@ -344,6 +371,7 @@ func _create_overhead_anchor() -> void:
|
||||
# Internal — body segments (D-160)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
|
||||
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
|
||||
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)]
|
||||
@@ -420,6 +448,7 @@ func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary)
|
||||
# Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_head(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.head_id.is_empty():
|
||||
return
|
||||
@@ -452,7 +481,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
|
||||
|
||||
|
||||
func _attach_to_bone(
|
||||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0) -> BoneAttachment3D:
|
||||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0
|
||||
) -> BoneAttachment3D:
|
||||
if _skeleton == null:
|
||||
return null
|
||||
if not ResourceLoader.exists(path):
|
||||
@@ -517,6 +547,7 @@ func _attach_to_bone(
|
||||
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||||
if _skeleton == null or desc.clothing_slots.is_empty():
|
||||
return
|
||||
@@ -566,7 +597,6 @@ func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||||
# Hiding is reserved for amputation/prosthetics via the debug tab or game logic.
|
||||
|
||||
|
||||
|
||||
func _read_coverage(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
@@ -608,6 +638,7 @@ func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2
|
||||
# Internal — accessories (BoneAttachment3D per slot→bone)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
for slot: String in desc.accessory_slots:
|
||||
var item_id: String = desc.accessory_slots[slot]
|
||||
@@ -629,8 +660,10 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _apply_tinted_shader(
|
||||
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, _render_priority: int = 0) -> void:
|
||||
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, _render_priority: int = 0
|
||||
) -> void:
|
||||
if mi.mesh == null:
|
||||
return
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
@@ -657,6 +690,7 @@ func _apply_tinted_shader(
|
||||
# Internal — outline pass (inverted hull, cull_front)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _rebuild_outlines() -> void:
|
||||
for node in _outline_nodes:
|
||||
if is_instance_valid(node):
|
||||
@@ -693,6 +727,7 @@ func _rebuild_outlines() -> void:
|
||||
# Animation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_animations() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
@@ -764,6 +799,7 @@ func stop_animation() -> void:
|
||||
# Static helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
static func _find_skeleton(root: Node) -> Skeleton3D:
|
||||
if root is Skeleton3D:
|
||||
return root as Skeleton3D
|
||||
@@ -791,7 +827,9 @@ static func _get_albedo_texture(mat: Material) -> Texture2D:
|
||||
if mat is BaseMaterial3D:
|
||||
return (mat as BaseMaterial3D).albedo_texture
|
||||
if mat is ShaderMaterial:
|
||||
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"]:
|
||||
for p: String in [
|
||||
"albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"
|
||||
]:
|
||||
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
|
||||
if val is Texture2D:
|
||||
return val as Texture2D
|
||||
|
||||
@@ -166,6 +166,7 @@ func to_dict() -> Dictionary:
|
||||
# -- Color serialization helpers --
|
||||
# Wire format: [r, g, b, a] float array (rmp_serde serializes Color as tuple).
|
||||
|
||||
|
||||
static func _decode_color(value: Variant, fallback: Color) -> Color:
|
||||
if value is Array and value.size() >= 3:
|
||||
return Color(value[0], value[1], value[2], value[3] if value.size() >= 4 else 1.0)
|
||||
@@ -198,5 +199,3 @@ static func _encode_tint_map(data: Dictionary) -> Dictionary:
|
||||
encoded.append(_encode_color(c))
|
||||
result[key] = encoded
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name CursorRenderer
|
||||
extends Node2D
|
||||
extends GameplayRenderer
|
||||
|
||||
## Cursor state machine — 4 geometric states, 150ms linear transitions (D-056).
|
||||
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
|
||||
@@ -54,8 +54,9 @@ var _bracket_a: float = 0.0
|
||||
var _alpha: float = 0.45
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
func _renderer_ready() -> void:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
|
||||
z_index = 100 # D-170: cursor must always render above all HUD/implant layers
|
||||
_from = _snapshot()
|
||||
|
||||
|
||||
@@ -68,7 +69,7 @@ func _notification(what: int) -> void:
|
||||
visible = true
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
func _gameplay_process(delta: float) -> void:
|
||||
if not _mouse_inside:
|
||||
return
|
||||
_time += delta
|
||||
@@ -84,6 +85,7 @@ func _process(delta: float) -> void:
|
||||
|
||||
# --- Hover detection (automatic, from GameState.visible_entities) ---
|
||||
|
||||
|
||||
func _detect_hover() -> void:
|
||||
var prev_id := hovered_entity_id
|
||||
|
||||
@@ -115,7 +117,7 @@ func _find_nearest_entity() -> Dictionary:
|
||||
var mouse_world: Vector2 = xform.affine_inverse() * mouse_screen
|
||||
|
||||
var best_dist := INF
|
||||
var result := { id = -1, kind = "", color = COLOR_DEFAULT, offset = Vector2.ZERO }
|
||||
var result := {id = -1, kind = "", color = COLOR_DEFAULT, offset = Vector2.ZERO}
|
||||
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity.has("entity_id") or not entity.has("x") or not entity.has("y"):
|
||||
@@ -138,6 +140,7 @@ func _find_nearest_entity() -> Dictionary:
|
||||
|
||||
# --- State transitions ---
|
||||
|
||||
|
||||
func _set_target(new_state: State) -> void:
|
||||
if new_state == _target:
|
||||
return
|
||||
@@ -154,29 +157,75 @@ func _set_target(new_state: State) -> void:
|
||||
|
||||
|
||||
func _snapshot() -> Dictionary:
|
||||
return { gap = _gap, len = _len, rot = _rot, thick = _thick,
|
||||
color = _color, bloom = _bloom, bracket_a = _bracket_a, alpha = _alpha }
|
||||
return {
|
||||
gap = _gap,
|
||||
len = _len,
|
||||
rot = _rot,
|
||||
thick = _thick,
|
||||
color = _color,
|
||||
bloom = _bloom,
|
||||
bracket_a = _bracket_a,
|
||||
alpha = _alpha
|
||||
}
|
||||
|
||||
|
||||
func _params_for(state: State) -> Dictionary:
|
||||
match state:
|
||||
State.ENTITY_HOVER:
|
||||
return { gap = 10.0, len = 6.0, rot = 0.0, thick = 1.0,
|
||||
color = _hover_color, bloom = 0.5, bracket_a = 1.0, alpha = 1.0 }
|
||||
return {
|
||||
gap = 10.0,
|
||||
len = 6.0,
|
||||
rot = 0.0,
|
||||
thick = 1.0,
|
||||
color = _hover_color,
|
||||
bloom = 0.5,
|
||||
bracket_a = 1.0,
|
||||
alpha = 1.0
|
||||
}
|
||||
State.OBJECT_HOVER:
|
||||
return { gap = 4.0, len = 6.0, rot = PI / 4.0, thick = 1.0,
|
||||
color = _hover_color, bloom = 0.3, bracket_a = 0.0, alpha = 0.8 }
|
||||
return {
|
||||
gap = 4.0,
|
||||
len = 6.0,
|
||||
rot = PI / 4.0,
|
||||
thick = 1.0,
|
||||
color = _hover_color,
|
||||
bloom = 0.3,
|
||||
bracket_a = 0.0,
|
||||
alpha = 0.8
|
||||
}
|
||||
State.WEAPON_AIM:
|
||||
return { gap = 12.0, len = 9.0, rot = 0.0, thick = 2.0,
|
||||
color = COLOR_WEAPON, bloom = 0.0, bracket_a = 0.0, alpha = 1.0 }
|
||||
return {
|
||||
gap = 12.0,
|
||||
len = 9.0,
|
||||
rot = 0.0,
|
||||
thick = 2.0,
|
||||
color = COLOR_WEAPON,
|
||||
bloom = 0.0,
|
||||
bracket_a = 0.0,
|
||||
alpha = 1.0
|
||||
}
|
||||
_:
|
||||
return { gap = 4.0, len = 6.0, rot = 0.0, thick = 1.0,
|
||||
color = COLOR_DEFAULT, bloom = 0.4, bracket_a = 0.0, alpha = 0.45 }
|
||||
return {
|
||||
gap = 4.0,
|
||||
len = 6.0,
|
||||
rot = 0.0,
|
||||
thick = 1.0,
|
||||
color = COLOR_DEFAULT,
|
||||
bloom = 0.4,
|
||||
bracket_a = 0.0,
|
||||
alpha = 0.45
|
||||
}
|
||||
|
||||
|
||||
func _apply_params(p: Dictionary) -> void:
|
||||
_gap = p.gap; _len = p.len; _rot = p.rot; _thick = p.thick
|
||||
_color = p.color; _bloom = p.bloom; _bracket_a = p.bracket_a; _alpha = p.alpha
|
||||
_gap = p.gap
|
||||
_len = p.len
|
||||
_rot = p.rot
|
||||
_thick = p.thick
|
||||
_color = p.color
|
||||
_bloom = p.bloom
|
||||
_bracket_a = p.bracket_a
|
||||
_alpha = p.alpha
|
||||
|
||||
|
||||
func _interpolate() -> void:
|
||||
@@ -193,8 +242,9 @@ func _interpolate() -> void:
|
||||
|
||||
# --- Drawing ---
|
||||
|
||||
func _draw() -> void:
|
||||
# _hover_offset is computed in _detect_hover() during _process(), not recalculated
|
||||
|
||||
func _gameplay_draw() -> void:
|
||||
# _hover_offset is computed in _detect_hover() during _gameplay_process(), not recalculated
|
||||
# here. This is safe because Camera2D (tree sibling, earlier in scene order) applies
|
||||
# its smoothing transform before CursorRenderer._process() reads canvas_transform.
|
||||
# Nothing modifies the canvas transform between _process() and _draw().
|
||||
@@ -233,13 +283,19 @@ func _draw_brackets(offset: Vector2, color: Color) -> void:
|
||||
|
||||
# --- Test-friendly API (expected by test_cursor_states.gd) ---
|
||||
|
||||
|
||||
func get_state() -> String:
|
||||
match current_state:
|
||||
State.DEFAULT: return "Default"
|
||||
State.ENTITY_HOVER: return "EntityHover"
|
||||
State.OBJECT_HOVER: return "ObjectHover"
|
||||
State.WEAPON_AIM: return "WeaponAim"
|
||||
_: return "Default"
|
||||
State.DEFAULT:
|
||||
return "Default"
|
||||
State.ENTITY_HOVER:
|
||||
return "EntityHover"
|
||||
State.OBJECT_HOVER:
|
||||
return "ObjectHover"
|
||||
State.WEAPON_AIM:
|
||||
return "WeaponAim"
|
||||
_:
|
||||
return "Default"
|
||||
|
||||
|
||||
func set_hover_target(data: Dictionary) -> void:
|
||||
@@ -252,10 +308,14 @@ func set_hover_target(data: Dictionary) -> void:
|
||||
if kind == "Npc":
|
||||
var rel: String = data.get("relationship", "Unknown")
|
||||
match rel:
|
||||
"Friendly": _hover_color = Constants.ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": _hover_color = Constants.ENTITY_COLOR_POI
|
||||
"Hostile": _hover_color = Constants.ENTITY_COLOR_HOSTILE
|
||||
_: _hover_color = Constants.ENTITY_COLOR_UNKNOWN
|
||||
"Friendly":
|
||||
_hover_color = Constants.ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest":
|
||||
_hover_color = Constants.ENTITY_COLOR_POI
|
||||
"Hostile":
|
||||
_hover_color = Constants.ENTITY_COLOR_HOSTILE
|
||||
_:
|
||||
_hover_color = Constants.ENTITY_COLOR_UNKNOWN
|
||||
_target = State.ENTITY_HOVER
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.ENTITY_HOVER))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name EntityRenderer
|
||||
extends Node2D
|
||||
extends GameplayRenderer
|
||||
|
||||
# Entity renderer — manages entity sprites under the Entities node
|
||||
# Creates/updates/removes Sprite2D children based on entity data
|
||||
@@ -18,7 +18,7 @@ const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
|
||||
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_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.
|
||||
@@ -28,17 +28,18 @@ const LERP_SPEED: float = 12.0
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
|
||||
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
|
||||
|
||||
func _ready() -> void:
|
||||
|
||||
func _renderer_ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
func _gameplay_process(delta: float) -> void:
|
||||
# Lerp all entity visual positions toward their targets each frame.
|
||||
# Uses framerate-independent exponential smoothing.
|
||||
var weight := 1.0 - exp(-LERP_SPEED * delta)
|
||||
@@ -111,7 +112,11 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
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])
|
||||
(
|
||||
"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.
|
||||
@@ -220,10 +225,14 @@ func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
|
||||
# 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"
|
||||
"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"
|
||||
@@ -247,11 +256,13 @@ func _add_facing_indicator(parent_node: Node2D) -> void:
|
||||
var s := Constants.FACING_INDICATOR_SIZE
|
||||
var offset := Constants.FACING_INDICATOR_OFFSET
|
||||
# Triangle pointing up (North), offset from center. Rotates around (0,0).
|
||||
indicator.polygon = PackedVector2Array([
|
||||
Vector2(0, -offset - s),
|
||||
Vector2(-s * 0.6, -offset + s * 0.4),
|
||||
Vector2(s * 0.6, -offset + s * 0.4),
|
||||
])
|
||||
indicator.polygon = PackedVector2Array(
|
||||
[
|
||||
Vector2(0, -offset - s),
|
||||
Vector2(-s * 0.6, -offset + s * 0.4),
|
||||
Vector2(s * 0.6, -offset + s * 0.4),
|
||||
]
|
||||
)
|
||||
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
||||
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
|
||||
# Indicator rotates around this point to track player facing direction.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name FogEntities
|
||||
extends Node2D
|
||||
extends GameplayRenderer
|
||||
|
||||
## Fog entity visualization — renders entities undergoing cognitive delay recognition
|
||||
## in the fog layer. Between FogOverlay (z:900) and InsertOverlay (CanvasLayer 10).
|
||||
@@ -17,25 +17,25 @@ extends Node2D
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
# D-059: Fog entity colors
|
||||
const COLOR_UNRECOGNIZED := Color("#555566") # Neutral grey blob
|
||||
const COLOR_UNRECOGNIZED := Color("#555566") # Neutral grey blob
|
||||
# Intentionally matches Constants.INSERT_COLOR_TEXT / cursor default (#c8d0e0).
|
||||
# Sonar pings are insert-generated — same visual language as cursor and HUD overlays.
|
||||
const COLOR_PING := Color("#c8d0e0") # Insert white-blue (D-048/D-056)
|
||||
const COLOR_PING := Color("#c8d0e0") # Insert white-blue (D-048/D-056)
|
||||
|
||||
# Animation timing
|
||||
const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds)
|
||||
const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing
|
||||
const PULSE_MAX_ALPHA: float = 0.8 # Max alpha during breathing
|
||||
const PING_DURATION: float = 1.5 # Sound ping expand + fade (seconds)
|
||||
const PING_MAX_RADIUS: float = 24.0 # Max ring expand radius (pixels)
|
||||
const PING_RING_COUNT: int = 3 # Concentric rings per ping
|
||||
const PING_RING_WIDTH: float = 1.5 # Ring line width (pixels)
|
||||
const DRIFT_RANGE: float = 0.5 # ±0.5 tile position drift (D-059)
|
||||
const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander
|
||||
const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds)
|
||||
const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing
|
||||
const PULSE_MAX_ALPHA: float = 0.8 # Max alpha during breathing
|
||||
const PING_DURATION: float = 1.5 # Sound ping expand + fade (seconds)
|
||||
const PING_MAX_RADIUS: float = 24.0 # Max ring expand radius (pixels)
|
||||
const PING_RING_COUNT: int = 3 # Concentric rings per ping
|
||||
const PING_RING_WIDTH: float = 1.5 # Ring line width (pixels)
|
||||
const DRIFT_RANGE: float = 0.5 # ±0.5 tile position drift (D-059)
|
||||
const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander
|
||||
|
||||
# Entity blob rendering
|
||||
const BLOB_RADIUS: float = 10.0
|
||||
const SILHOUETTE_SCALE: float = 1.3 # Silhouette slightly larger than blob
|
||||
const SILHOUETTE_SCALE: float = 1.3 # Silhouette slightly larger than blob
|
||||
# Transition thresholds: recognition progress mapped from remaining_ticks/total_delay_ticks.
|
||||
# Color transition starts at 50% to compress the visual change into ~0.3s (D-060).
|
||||
const COLOR_TRANSITION_START: float = 0.5
|
||||
@@ -45,12 +45,12 @@ const SILHOUETTE_APPEAR_THRESHOLD: float = 0.3
|
||||
const SILHOUETTE_SIZE := Vector2(6.0, 10.0)
|
||||
|
||||
# Internal state — no child nodes, pure data + _draw()
|
||||
var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress}
|
||||
var _pings: Array = [] # [{pos: Vector2, elapsed: float}]
|
||||
var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress}
|
||||
var _pings: Array = [] # [{pos: Vector2, elapsed: float}]
|
||||
var _time: float = 0.0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
func _gameplay_process(delta: float) -> void:
|
||||
# Early return when idle — skip queue_redraw() when nothing to draw
|
||||
if _entities.is_empty() and _pings.is_empty():
|
||||
return
|
||||
@@ -118,11 +118,10 @@ func update_from_state() -> void:
|
||||
_entities.erase(eid)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
func _gameplay_draw() -> void:
|
||||
# Breathing pulse — shared across all entities
|
||||
var pulse_t := fmod(_time, PULSE_PERIOD) / PULSE_PERIOD
|
||||
var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA,
|
||||
0.5 + 0.5 * sin(pulse_t * TAU))
|
||||
var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA, 0.5 + 0.5 * sin(pulse_t * TAU))
|
||||
|
||||
# Draw entity blobs
|
||||
for eid in _entities.keys():
|
||||
@@ -132,7 +131,9 @@ func _draw() -> void:
|
||||
# Color transition: grey → D-033 teal based on recognition progress
|
||||
# Transition begins at 50% progress (D-060: ~0.3s visual transition within delay window)
|
||||
var progress: float = e.progress
|
||||
var color_t: float = clampf((progress - COLOR_TRANSITION_START) / (1.0 - COLOR_TRANSITION_START), 0.0, 1.0)
|
||||
var color_t: float = clampf(
|
||||
(progress - COLOR_TRANSITION_START) / (1.0 - COLOR_TRANSITION_START), 0.0, 1.0
|
||||
)
|
||||
var blob_color: Color = COLOR_UNRECOGNIZED.lerp(Constants.ENTITY_COLOR_UNKNOWN, color_t)
|
||||
blob_color.a = pulse_alpha
|
||||
|
||||
@@ -172,7 +173,4 @@ func _draw() -> void:
|
||||
|
||||
|
||||
func _random_drift() -> Vector2:
|
||||
return Vector2(
|
||||
randf_range(-DRIFT_RANGE, DRIFT_RANGE),
|
||||
randf_range(-DRIFT_RANGE, DRIFT_RANGE)
|
||||
)
|
||||
return Vector2(randf_range(-DRIFT_RANGE, DRIFT_RANGE), randf_range(-DRIFT_RANGE, DRIFT_RANGE))
|
||||
|
||||
@@ -41,7 +41,11 @@ func _ready() -> void:
|
||||
noise_tex.width = 256
|
||||
noise_tex.height = 256
|
||||
noise_tex.seamless = true
|
||||
noise_tex.changed.connect(func(): _noise_ready = true; fog_noise_ready.emit())
|
||||
noise_tex.changed.connect(
|
||||
func():
|
||||
_noise_ready = true
|
||||
fog_noise_ready.emit()
|
||||
)
|
||||
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
|
||||
|
||||
@@ -74,6 +78,8 @@ func update_fog() -> void:
|
||||
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
|
||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
||||
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
|
||||
var t: float = FogState.override_time if FogState.override_time >= 0.0 else Time.get_ticks_msec() / 1000.0
|
||||
var t: float = (
|
||||
FogState.override_time if FogState.override_time >= 0.0 else Time.get_ticks_msec() / 1000.0
|
||||
)
|
||||
_shader_mat.set_shader_parameter("time", t)
|
||||
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
class_name GameplayRenderer
|
||||
extends Node2D
|
||||
## Base class for renderers that should pause when a fullscreen implant app
|
||||
## covers the gameplay view (D-170).
|
||||
##
|
||||
## Subclasses override _gameplay_process(delta) and _gameplay_draw() instead
|
||||
## of _process() and _draw(). These are only called when gameplay is visible.
|
||||
##
|
||||
## Usage:
|
||||
## extends GameplayRenderer
|
||||
## func _gameplay_process(delta: float) -> void: ...
|
||||
## func _gameplay_draw() -> void: ...
|
||||
|
||||
var gameplay_occluded: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
|
||||
_renderer_ready()
|
||||
|
||||
|
||||
## Override this instead of _ready() in subclasses.
|
||||
func _renderer_ready() -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_gameplay_occluded(occluded: bool) -> void:
|
||||
gameplay_occluded = occluded
|
||||
if occluded:
|
||||
# Force a blank redraw so stale frames don't linger
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if gameplay_occluded:
|
||||
return
|
||||
_gameplay_process(delta)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if gameplay_occluded:
|
||||
return
|
||||
_gameplay_draw()
|
||||
|
||||
|
||||
## Override in subclasses — called every frame when gameplay is visible.
|
||||
func _gameplay_process(_delta: float) -> void:
|
||||
pass
|
||||
|
||||
|
||||
## Override in subclasses — called for draw when gameplay is visible.
|
||||
func _gameplay_draw() -> void:
|
||||
pass
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name SoundIndicatorRenderer
|
||||
extends Node2D
|
||||
extends GameplayRenderer
|
||||
|
||||
## Medium-range sound indicators (#126, D-018).
|
||||
## Renders directional arrows at the fog boundary for sounds outside LOS.
|
||||
@@ -20,21 +20,22 @@ extends Node2D
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
# Visual parameters
|
||||
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
|
||||
const FADE_DURATION: float = 0.6 # Fade-out window at end
|
||||
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
|
||||
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
|
||||
const ARROW_LEN: float = 12.0 # Length from tip to base
|
||||
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
|
||||
const FADE_DURATION: float = 0.6 # Fade-out window at end
|
||||
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
|
||||
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
|
||||
const ARROW_LEN: float = 12.0 # Length from tip to base
|
||||
|
||||
# D-018/D-069 colors — sourced from Constants to prevent palette drift
|
||||
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
|
||||
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
|
||||
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
|
||||
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
|
||||
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
|
||||
|
||||
# Indicators: [{x, y, event_type, elapsed}]
|
||||
var _indicators: Array = []
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
func _gameplay_process(delta: float) -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
@@ -68,17 +69,22 @@ func update_sound_events(events: Array) -> void:
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_indicators.append({
|
||||
"x": ex,
|
||||
"y": ey,
|
||||
"event_type": evt.get("event_type", ""),
|
||||
"elapsed": 0.0,
|
||||
})
|
||||
(
|
||||
_indicators
|
||||
. append(
|
||||
{
|
||||
"x": ex,
|
||||
"y": ey,
|
||||
"event_type": evt.get("event_type", ""),
|
||||
"elapsed": 0.0,
|
||||
}
|
||||
)
|
||||
)
|
||||
if not _indicators.is_empty():
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
func _gameplay_draw() -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
@@ -144,9 +150,19 @@ func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
|
||||
## Map event type string → D-018 color category.
|
||||
func color_for_type(event_type: String) -> Color:
|
||||
var et := event_type.to_lower()
|
||||
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
|
||||
if (
|
||||
et.contains("voice")
|
||||
or et.contains("speech")
|
||||
or et.contains("convers")
|
||||
or et.contains("talk")
|
||||
):
|
||||
return COLOR_VOICE
|
||||
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
|
||||
or et.contains("alert") or et.contains("threat"):
|
||||
if (
|
||||
et.contains("danger")
|
||||
or et.contains("gunshot")
|
||||
or et.contains("explosion")
|
||||
or et.contains("alert")
|
||||
or et.contains("threat")
|
||||
):
|
||||
return COLOR_DANGER
|
||||
return COLOR_NEUTRAL
|
||||
|
||||
@@ -27,11 +27,13 @@ const TILE_TYPE_MAP: Dictionary = {
|
||||
|
||||
var _initialized: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_tileset()
|
||||
_initialized = true
|
||||
print("TileRenderer: Initialized")
|
||||
|
||||
|
||||
# Build a programmatic TileSet with colored placeholder tiles
|
||||
func _setup_tileset() -> void:
|
||||
var ts := TileSet.new()
|
||||
@@ -63,6 +65,7 @@ func _setup_tileset() -> void:
|
||||
var source_id := ts.add_source(source)
|
||||
tile_set = ts
|
||||
|
||||
|
||||
# Update tiles from snapshot data
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)}
|
||||
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
||||
@@ -93,19 +96,25 @@ func update_tiles(tiles: Array) -> void:
|
||||
|
||||
var tile_type_str: String = tile_data.type
|
||||
if not TILE_TYPE_MAP.has(tile_type_str):
|
||||
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
||||
tile_type_str, tile_data.x, tile_data.y])
|
||||
push_warning(
|
||||
(
|
||||
"TileRenderer: unknown tile type '%s' at (%d, %d)"
|
||||
% [tile_type_str, tile_data.x, tile_data.y]
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
var atlas_x: int = TILE_TYPE_MAP[tile_type_str]
|
||||
var coords := Vector2i(tile_data.x, tile_data.y)
|
||||
set_cell(coords, 0, Vector2i(atlas_x, 0))
|
||||
|
||||
|
||||
# Fill a tile region with a solid color
|
||||
func _fill_tile(img: Image, tile_index: int, color: Color) -> void:
|
||||
var rect := Rect2i(tile_index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE)
|
||||
img.fill_rect(rect, color)
|
||||
|
||||
|
||||
# Fill a tile region with a color and a 1px border
|
||||
func _fill_tile_with_border(img: Image, tile_index: int, fill: Color, border: Color) -> void:
|
||||
var x_offset := tile_index * TILE_SIZE
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
extends Node2D
|
||||
extends GameplayRenderer
|
||||
|
||||
# World renderer — manages all visual representation from GameState
|
||||
# Attached to the World node in main.tscn
|
||||
@@ -20,12 +20,16 @@ var _last_tick: int = -1
|
||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||
|
||||
func _ready() -> void:
|
||||
|
||||
func _renderer_ready() -> void:
|
||||
print("WorldRenderer: Initialized (D-049 z-stack)")
|
||||
|
||||
|
||||
# Called each frame to update visuals from game state.
|
||||
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
|
||||
func update_from_state() -> void:
|
||||
if gameplay_occluded:
|
||||
return # D-170: skip rendering while fullscreen implant app is active
|
||||
var tick := GameState.current_tick
|
||||
if tick == _last_tick:
|
||||
return
|
||||
|
||||
@@ -74,8 +74,7 @@ func play_close_sound_events() -> void:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
evt.get("event_type", ""), Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
@@ -20,15 +20,23 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
GameState.visible_entities = snapshot.entities
|
||||
var found_player := false
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
|
||||
if (
|
||||
entity.has("kind")
|
||||
and entity.kind is Dictionary
|
||||
and entity.kind.get("variant") == "Player"
|
||||
):
|
||||
GameState.player_position = Vector2(entity.x, entity.y)
|
||||
if entity.has("entity_id"):
|
||||
GameState.player_entity_id = entity.entity_id
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and GameState.visible_entities.size() > 0:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
GameState.visible_entities.size()])
|
||||
push_warning(
|
||||
(
|
||||
"GameState: no Player entity found in %d entities"
|
||||
% [GameState.visible_entities.size()]
|
||||
)
|
||||
)
|
||||
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
@@ -44,7 +52,11 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
GameState.visible_tiles = snapshot.tiles
|
||||
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
elif (
|
||||
snapshot.has("visible_tiles")
|
||||
and snapshot.visible_tiles is Array
|
||||
and snapshot.visible_tiles.size() > 0
|
||||
):
|
||||
var has_type := false
|
||||
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
|
||||
has_type = snapshot.visible_tiles[0].has("type")
|
||||
@@ -195,12 +207,17 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
if entry.get("key") == "ai_dialogue.enabled":
|
||||
var val: Variant = entry.get("value")
|
||||
if val != null:
|
||||
GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting(
|
||||
"ai_dialogue.enabled", val
|
||||
)
|
||||
else:
|
||||
GameState.settings_response = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
|
||||
if (
|
||||
snapshot.has("character_visual_descriptor")
|
||||
and snapshot.character_visual_descriptor is Dictionary
|
||||
):
|
||||
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
|
||||
if CVD != null:
|
||||
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
|
||||
@@ -220,12 +237,18 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
for vtile in GameState.visible_tiles:
|
||||
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||
tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||
var player_pos_key := Vector2i(int(GameState.player_position.x), int(GameState.player_position.y))
|
||||
var player_pos_key := Vector2i(
|
||||
int(GameState.player_position.x), int(GameState.player_position.y)
|
||||
)
|
||||
var player_tile = tile_by_coord.get(player_pos_key, null)
|
||||
GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
if (
|
||||
snapshot.has("visible_tiles")
|
||||
and snapshot.visible_tiles is Array
|
||||
and snapshot.visible_tiles.size() > 0
|
||||
):
|
||||
GameState.visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
|
||||
@@ -19,9 +19,9 @@ static func parse(text: String) -> Dictionary:
|
||||
# Stack: [{indent: int, key: String}] — path of open section headers
|
||||
var stack: Array = []
|
||||
# Array state
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
var stripped := raw_line.strip_edges(false, true)
|
||||
@@ -126,19 +126,21 @@ static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
# Strip inline comment outside quotes
|
||||
if not val.begins_with("\""):
|
||||
if not val.begins_with('"'):
|
||||
var comment_pos: int = val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
# Quoted string — extract content between quotes
|
||||
if val.begins_with("\""):
|
||||
var end_quote: int = val.find("\"", 1)
|
||||
if val.begins_with('"'):
|
||||
var end_quote: int = val.find('"', 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
# Boolean
|
||||
if val == "true": return true
|
||||
if val == "false": return false
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
# Float (must have decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
shader_type canvas_item;
|
||||
// D-086: Runtime icon tint — replaces color channel with tint_color, preserves alpha mask.
|
||||
// Applied to SVG-sourced TextureRect nodes so stance/status icons can be color-coded at runtime.
|
||||
// Default: #c8d0e0 (insert chrome) — identical to the SVG's authored stroke color.
|
||||
uniform vec4 tint_color : source_color = vec4(0.784, 0.816, 0.878, 1.0);
|
||||
|
||||
void fragment() {
|
||||
float a = texture(TEXTURE, UV).a;
|
||||
COLOR = vec4(tint_color.rgb, a * tint_color.a);
|
||||
}
|
||||
@@ -222,15 +222,9 @@ func test_interact_with_data_encodes_as_data_variant() -> void:
|
||||
|
||||
# -- Helpers --
|
||||
|
||||
func _make_prompt() -> PanelContainer:
|
||||
func _make_prompt() -> Control:
|
||||
var PromptScript = load("res://ui/interaction_prompt.gd")
|
||||
var panel = PanelContainer.new()
|
||||
panel.set_script(PromptScript)
|
||||
var margin = MarginContainer.new()
|
||||
margin.name = "MarginContainer"
|
||||
panel.add_child(margin)
|
||||
var label = Label.new()
|
||||
label.name = "PromptLabel"
|
||||
margin.add_child(label)
|
||||
add_child(panel)
|
||||
return panel
|
||||
var ctrl = Control.new()
|
||||
ctrl.set_script(PromptScript)
|
||||
add_child(ctrl) # triggers _ready() which creates ImplantPanel + ImplantDataRow
|
||||
return ctrl
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
|
||||
## D-051 (diegetic insert display)
|
||||
##
|
||||
## Implementation: client/ui/time_display.gd — draw-based Control at InsertOverlay/TimeDisplay.
|
||||
## Implementation: client/ui/time_display.gd — draw-based Control (removed from main scene #786;
|
||||
## time display merged into HUD ImplantPanel at InsertOverlay/HUD, see test_hud_* tests below).
|
||||
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
|
||||
## testability from time_display.gd:46 inline Constants.format_game_time(tod)).
|
||||
## testability from time_display.gd).
|
||||
##
|
||||
## Private state access: Tests read _time_str, _phase_str, _day_str, _has_data directly
|
||||
## because time_display.gd is draw-based (no Label nodes to inspect). This is an accepted
|
||||
## test pattern for draw-based UI — the private vars ARE the rendered output contract.
|
||||
## If the rendering approach changes (e.g. to Label nodes), these tests should switch to
|
||||
## reading Label.text via public node paths instead.
|
||||
## Scene integration tests now check InsertOverlay/HUD.get_time_text() instead.
|
||||
class_name TestTimeDisplaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -273,18 +273,18 @@ func test_sim_bridge_day_phase_is_valid() -> void:
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Scene: InsertClock node at InsertOverlay/TimeDisplay
|
||||
# Scene: HUD panel at InsertOverlay/HUD (time row merged in, TimeDisplay removed #786)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
func test_hud_panel_exists_in_insert_overlay() -> void:
|
||||
var scene := MAIN_SCENE
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||
assert_that(instance.get_node_or_null("InsertOverlay/HUD")).is_not_null()
|
||||
|
||||
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||
func test_hud_time_row_updates_after_process() -> void:
|
||||
var scene := MAIN_SCENE
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
@@ -296,9 +296,9 @@ func test_insert_clock_time_str_updates_after_process() -> void:
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay")
|
||||
assert_that(clock).is_not_null()
|
||||
assert_that(clock._time_str).is_equal("12:00")
|
||||
var hud = instance.get_node_or_null("InsertOverlay/HUD")
|
||||
assert_that(hud).is_not_null()
|
||||
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -122,14 +122,14 @@ func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
|
||||
|
||||
|
||||
func test_hud_exists_in_ui_layer() -> void:
|
||||
# D-049: HUD must be in UILayer.
|
||||
func test_hud_exists_in_insert_overlay() -> void:
|
||||
# D-051: HUD moved to InsertOverlay (layer 10) for bloom treatment (#786, PR #115).
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null()
|
||||
assert_that(_instance.get_node_or_null("InsertOverlay/HUD")).is_not_null()
|
||||
|
||||
|
||||
func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||
@@ -313,3 +313,33 @@ func test_tile_renderer_skips_nonzero_z() -> void:
|
||||
# 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)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Stance indicator icon tests (#787)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_stance_indicator_applies_known_stance() -> void:
|
||||
# #787: _apply_stance updates icon + label for each known stance.
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
var si = _instance.get_node_or_null("UILayer/StanceIndicator")
|
||||
assert_that(si).is_not_null()
|
||||
si._apply_stance("Careful")
|
||||
assert_that(si.get_current_stance()).is_equal("Careful")
|
||||
|
||||
|
||||
func test_stance_indicator_unknown_stance_no_crash() -> void:
|
||||
# #787: Unknown stance falls back gracefully — no crash, no texture change.
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
var si = _instance.get_node_or_null("UILayer/StanceIndicator")
|
||||
assert_that(si).is_not_null()
|
||||
si._apply_stance("InvalidStance")
|
||||
assert_that(si.get_current_stance()).is_equal("InvalidStance")
|
||||
|
||||
@@ -46,11 +46,11 @@ var _captured_screenshot: Image = null
|
||||
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
|
||||
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
|
||||
# debug tool that is always active during Gauntlet play.
|
||||
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
|
||||
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
|
||||
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
|
||||
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
|
||||
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
|
||||
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
|
||||
|
||||
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
|
||||
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
|
||||
var _snapshot_head: int = 0
|
||||
var _snapshot_count: int = 0
|
||||
|
||||
@@ -74,6 +74,7 @@ func _ready() -> void:
|
||||
# Public API for main.gd: record one tick's data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
|
||||
## - tick: current server tick number
|
||||
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
|
||||
@@ -180,18 +181,23 @@ func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
|
||||
# Test-accessible accessors (ring buffer introspection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _get_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
|
||||
func _get_snapshot_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
|
||||
func _get_input_buffer() -> Array:
|
||||
return _input_ring
|
||||
|
||||
|
||||
func _get_filled_input_count() -> int:
|
||||
return _input_count
|
||||
|
||||
|
||||
func _get_filled_snapshot_count() -> int:
|
||||
return _snapshot_count
|
||||
|
||||
@@ -200,6 +206,7 @@ func _get_filled_snapshot_count() -> int:
|
||||
# UI / capture flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func start_capture() -> void:
|
||||
if _active:
|
||||
return
|
||||
@@ -209,10 +216,15 @@ func start_capture() -> void:
|
||||
visible = true
|
||||
|
||||
# Pause the simulation
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Create the LineEdit dynamically
|
||||
_line_edit = LineEdit.new()
|
||||
@@ -254,10 +266,15 @@ func _close() -> void:
|
||||
_captured_screenshot = null
|
||||
|
||||
# Unpause the simulation
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _save_report(description: String) -> void:
|
||||
@@ -301,7 +318,9 @@ func _save_report(description: String) -> void:
|
||||
if desc_file:
|
||||
desc_file.store_string("Description: %s\n" % description)
|
||||
desc_file.store_string("Tick: %d\n" % tick)
|
||||
desc_file.store_string("Room: %s\n" % str(GameState.room_id if GameState.room_id else "none"))
|
||||
desc_file.store_string(
|
||||
"Room: %s\n" % str(GameState.room_id if GameState.room_id else "none")
|
||||
)
|
||||
desc_file.store_string("Stance: %s\n" % GameState.player_stance)
|
||||
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
|
||||
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
|
||||
@@ -343,8 +362,11 @@ func _save_report(description: String) -> void:
|
||||
var seed_val: Variant = _get_current_seed()
|
||||
seed_file.store_string(str(seed_val) + "\n")
|
||||
if seed_val == "unavailable":
|
||||
seed_file.store_string(
|
||||
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
|
||||
(
|
||||
seed_file
|
||||
. store_string(
|
||||
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
|
||||
)
|
||||
)
|
||||
seed_file.close()
|
||||
files_saved += 1
|
||||
@@ -360,8 +382,12 @@ func _save_report(description: String) -> void:
|
||||
else:
|
||||
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
|
||||
|
||||
print("BugReport: saved %d/7 files to %s (ring: %d ticks)" % [
|
||||
files_saved, base_path, _input_count])
|
||||
print(
|
||||
(
|
||||
"BugReport: saved %d/7 files to %s (ring: %d ticks)"
|
||||
% [files_saved, base_path, _input_count]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Simplified client-side text render of the current snapshot.
|
||||
@@ -369,14 +395,24 @@ func _save_report(description: String) -> void:
|
||||
func _render_snapshot_text() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
lines.append("=== Snapshot t%d ===" % GameState.current_tick)
|
||||
lines.append("Player: %s facing %s (%s)" % [
|
||||
str(GameState.player_position), GameState.player_facing, GameState.player_stance])
|
||||
lines.append(
|
||||
(
|
||||
"Player: %s facing %s (%s)"
|
||||
% [str(GameState.player_position), GameState.player_facing, GameState.player_stance]
|
||||
)
|
||||
)
|
||||
|
||||
if GameState.game_time.size() > 0:
|
||||
lines.append("Time: day %s, %s, %s" % [
|
||||
str(GameState.game_time.get("day", "?")),
|
||||
str(GameState.game_time.get("day_phase", "?")),
|
||||
str(GameState.game_time.get("tick_rate", "?"))])
|
||||
lines.append(
|
||||
(
|
||||
"Time: day %s, %s, %s"
|
||||
% [
|
||||
str(GameState.game_time.get("day", "?")),
|
||||
str(GameState.game_time.get("day_phase", "?")),
|
||||
str(GameState.game_time.get("tick_rate", "?"))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Entities (%d):" % GameState.visible_entities.size())
|
||||
@@ -387,12 +423,18 @@ func _render_snapshot_text() -> String:
|
||||
elif entity.has("kind") and entity.kind is String:
|
||||
kind_str = entity.kind
|
||||
var vis: String = entity.get("visibility", "?")
|
||||
lines.append(" #%s %s at (%s, %s) [%s]" % [
|
||||
str(entity.get("entity_id", "?")),
|
||||
kind_str,
|
||||
str(entity.get("x", "?")),
|
||||
str(entity.get("y", "?")),
|
||||
vis])
|
||||
lines.append(
|
||||
(
|
||||
" #%s %s at (%s, %s) [%s]"
|
||||
% [
|
||||
str(entity.get("entity_id", "?")),
|
||||
kind_str,
|
||||
str(entity.get("x", "?")),
|
||||
str(entity.get("y", "?")),
|
||||
vis
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
|
||||
@@ -400,9 +442,15 @@ func _render_snapshot_text() -> String:
|
||||
if GameState.current_monologue != null:
|
||||
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
|
||||
if GameState.current_dialogue != null:
|
||||
lines.append("Dialogue: %s says '%s'" % [
|
||||
str(GameState.current_dialogue.get("npc_name", "?")),
|
||||
str(GameState.current_dialogue.get("speech", ""))])
|
||||
lines.append(
|
||||
(
|
||||
"Dialogue: %s says '%s'"
|
||||
% [
|
||||
str(GameState.current_dialogue.get("npc_name", "?")),
|
||||
str(GameState.current_dialogue.get("speech", ""))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -416,8 +464,7 @@ func _draw() -> void:
|
||||
|
||||
# Center box
|
||||
var box_pos := Vector2(
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0,
|
||||
(viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
||||
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
||||
@@ -425,15 +472,21 @@ func _draw() -> void:
|
||||
|
||||
# Title
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
box_pos + Vector2(PADDING, 24),
|
||||
"WRONG — Describe the issue (Enter to save, Esc to cancel):",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
LABEL_FONT_SIZE,
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
@@ -16,10 +16,10 @@ signal creation_cancelled
|
||||
const BG_COLOR := Color(0.03, 0.03, 0.06, 1.0)
|
||||
const PANEL_BG := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const BORDER_COLOR := Color(0.12, 0.15, 0.20, 1.0)
|
||||
const TEXT_COLOR := Color(0.784, 0.816, 0.878, 1.0) # INSERT_COLOR_TEXT
|
||||
const TEXT_COLOR := Color(0.784, 0.816, 0.878, 1.0) # INSERT_COLOR_TEXT
|
||||
const TEXT_DIM := Color(0.53, 0.56, 0.63, 1.0)
|
||||
const HIGHLIGHT_COLOR := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER
|
||||
const ACTIVE_COLOR := Color(0.42, 0.79, 0.65, 1.0) # INSERT_COLOR_ACTIVE
|
||||
const ACTIVE_COLOR := Color(0.42, 0.79, 0.65, 1.0) # INSERT_COLOR_ACTIVE
|
||||
const ITEM_NORMAL_BG := Color(0.07, 0.07, 0.10, 1.0)
|
||||
const ITEM_SELECTED_BG := Color(0.10, 0.13, 0.18, 1.0)
|
||||
const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER — selection border
|
||||
@@ -27,7 +27,7 @@ const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HO
|
||||
# --- Camera presets (D-158) ---
|
||||
# Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down.
|
||||
# Negative values = tilt downward (camera looks toward ground).
|
||||
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
|
||||
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
|
||||
const CAM_PITCH_NAMES := ["Frontal", "Dramatic", "Overhead"]
|
||||
const CAM_DISTANCE: float = 3.8
|
||||
const CAM_TARGET_HEIGHT: float = 0.85
|
||||
@@ -79,35 +79,111 @@ const CLOTHING_SLOT_LABELS := ["Torso", "Legs", "Feet", "Hands"]
|
||||
|
||||
# --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
|
||||
const ACCESSORY_SLOTS := [
|
||||
"hat", "goggles", "mask", "backpack", "belt",
|
||||
"wrist_l", "wrist_r", "earring_l", "earring_r", "necklace",
|
||||
"hat",
|
||||
"goggles",
|
||||
"mask",
|
||||
"backpack",
|
||||
"belt",
|
||||
"wrist_l",
|
||||
"wrist_r",
|
||||
"earring_l",
|
||||
"earring_r",
|
||||
"necklace",
|
||||
]
|
||||
const ACCESSORY_SLOT_LABELS := [
|
||||
"Hat", "Goggles", "Mask", "Backpack", "Belt",
|
||||
"Wrist L", "Wrist R", "Earring L", "Earring R", "Necklace",
|
||||
"Hat",
|
||||
"Goggles",
|
||||
"Mask",
|
||||
"Backpack",
|
||||
"Belt",
|
||||
"Wrist L",
|
||||
"Wrist R",
|
||||
"Earring L",
|
||||
"Earring R",
|
||||
"Necklace",
|
||||
]
|
||||
|
||||
# --- D-165 palette (5 chromatic rows + 1 neutral row, 9 cols each) ---
|
||||
# Hardcoded hex per D-165 spec (HSL L=18→76% per chromatic row, S fixed per row).
|
||||
const PALETTE_COLORS: Array[Array] = [
|
||||
# Row 1: Reds H=0° S=35%
|
||||
["#3d1d1d", "#522727", "#673131", "#7f3d3d", "#974848", "#af5959", "#bc7575", "#c99090", "#d7acac"],
|
||||
[
|
||||
"#3d1d1d",
|
||||
"#522727",
|
||||
"#673131",
|
||||
"#7f3d3d",
|
||||
"#974848",
|
||||
"#af5959",
|
||||
"#bc7575",
|
||||
"#c99090",
|
||||
"#d7acac"
|
||||
],
|
||||
# Row 2: Greens H=150° S=28%
|
||||
["#213a2d", "#2c4e3d", "#37614c", "#43785e", "#508f70", "#62a684", "#7cb599", "#96c4ad", "#b0d2c1"],
|
||||
[
|
||||
"#213a2d",
|
||||
"#2c4e3d",
|
||||
"#37614c",
|
||||
"#43785e",
|
||||
"#508f70",
|
||||
"#62a684",
|
||||
"#7cb599",
|
||||
"#96c4ad",
|
||||
"#b0d2c1"
|
||||
],
|
||||
# Row 3: Blues H=215° S=35%
|
||||
["#1d2b3d", "#273952", "#314867", "#3d587f", "#486997", "#597daf", "#7593bc", "#90a8c9", "#acbed7"],
|
||||
[
|
||||
"#1d2b3d",
|
||||
"#273952",
|
||||
"#314867",
|
||||
"#3d587f",
|
||||
"#486997",
|
||||
"#597daf",
|
||||
"#7593bc",
|
||||
"#90a8c9",
|
||||
"#acbed7"
|
||||
],
|
||||
# Row 4: Purples H=275° S=28%
|
||||
["#30213a", "#402c4e", "#503761", "#624378", "#75508f", "#8a62a6", "#9d7cb5", "#b196c4", "#c4b0d2"],
|
||||
[
|
||||
"#30213a",
|
||||
"#402c4e",
|
||||
"#503761",
|
||||
"#624378",
|
||||
"#75508f",
|
||||
"#8a62a6",
|
||||
"#9d7cb5",
|
||||
"#b196c4",
|
||||
"#c4b0d2"
|
||||
],
|
||||
# Row 5: Browns H=28° S=35%
|
||||
["#3d2c1d", "#523b27", "#674a31", "#7f5c3d", "#976d48", "#af8159", "#bc9675", "#c9ab90", "#d7c0ac"],
|
||||
[
|
||||
"#3d2c1d",
|
||||
"#523b27",
|
||||
"#674a31",
|
||||
"#7f5c3d",
|
||||
"#976d48",
|
||||
"#af8159",
|
||||
"#bc9675",
|
||||
"#c9ab90",
|
||||
"#d7c0ac"
|
||||
],
|
||||
# Row 6: Neutral grays
|
||||
["#000000", "#1f1f1f", "#3f3f3f", "#5f5f5f", "#7f7f7f", "#9f9f9f", "#bfbfbf", "#dfdfdf", "#ffffff"],
|
||||
[
|
||||
"#000000",
|
||||
"#1f1f1f",
|
||||
"#3f3f3f",
|
||||
"#5f5f5f",
|
||||
"#7f7f7f",
|
||||
"#9f9f9f",
|
||||
"#bfbfbf",
|
||||
"#dfdfdf",
|
||||
"#ffffff"
|
||||
],
|
||||
]
|
||||
const PALETTE_COLS := 9
|
||||
const RECENT_SLOTS := 9
|
||||
|
||||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||
const CAM_ZOOM_STEP: float = 0.12
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
@@ -118,7 +194,7 @@ const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||
# --- Descriptor and preview state ---
|
||||
var _descriptor: CharacterVisualDescriptor
|
||||
var _char_visual: CharacterVisual = null
|
||||
var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face-forward)
|
||||
var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face-forward)
|
||||
var _cam_pitch_idx: int = 0 # 0=frontal(-5°), 1=dramatic(-30°), 2=overhead(-80°)
|
||||
var _cam_zoom: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
|
||||
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed
|
||||
@@ -128,42 +204,42 @@ var _active_clothing_slot: String = "torso"
|
||||
var _active_accessory_slot: String = "hat"
|
||||
|
||||
# --- Body/skin tone buttons shared across Body + Head tabs ---
|
||||
var _body_type_btns: Dictionary = {} # BodyType int -> Button
|
||||
var _body_type_btns: Dictionary = {} # BodyType int -> Button
|
||||
var _body_skin_btns: Array[Button] = [] # 9 buttons in Body tab skin dock
|
||||
var _head_skin_btns: Array[Button] = [] # 9 buttons in Head tab skin dock
|
||||
|
||||
# --- Head / hair buttons ---
|
||||
var _head_item_btns: Dictionary = {} # head_id -> Button
|
||||
var _hair_item_btns: Dictionary = {} # hair_id -> Button
|
||||
var _head_item_btns: Dictionary = {} # head_id -> Button
|
||||
var _hair_item_btns: Dictionary = {} # hair_id -> Button
|
||||
var _facial_hair_btns: Array[Button] = []
|
||||
var _eyebrow_btns: Array[Button] = []
|
||||
|
||||
# --- Clothing/accessory grid content per slot ---
|
||||
var _clothing_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _clothing_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _clothing_slot_btns: Array[Button] = []
|
||||
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
var _accessory_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
var _accessory_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _accessory_slot_btns: Array[Button] = []
|
||||
var _accessory_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
var _accessory_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
|
||||
# --- Color dock swatch references ---
|
||||
var _hair_primary_swatch: Button = null
|
||||
var _hair_highlight_swatch: Button = null
|
||||
var _eyebrow_tint_swatch: Button = null
|
||||
var _facial_hair_tint_swatch: Button = null
|
||||
var _clothing_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_accent_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_accent_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
|
||||
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
|
||||
# Note: hair highlight is always auto-derived (display-only swatch, no override).
|
||||
var _eyebrow_tint_auto: bool = true
|
||||
var _facial_hair_tint_auto: bool = true
|
||||
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
|
||||
var _clothing_accent_auto: Dictionary = {} # slot -> bool
|
||||
var _accessory_secondary_auto: Dictionary = {} # slot -> bool
|
||||
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
|
||||
var _clothing_accent_auto: Dictionary = {} # slot -> bool
|
||||
var _accessory_secondary_auto: Dictionary = {} # slot -> bool
|
||||
|
||||
# --- Color picker modal state ---
|
||||
var _modal_callback: Callable
|
||||
@@ -171,7 +247,7 @@ var _modal_original_color: Color = Color.WHITE
|
||||
var _modal_preview_swatch: Button = null # the swatch button being edited
|
||||
var _modal_hex_input: LineEdit = null
|
||||
var _modal_swatch_btns: Array[Button] = [] # 54 palette swatches
|
||||
var _recent_colors: Array[Color] = [] # persisted recent custom colors
|
||||
var _recent_colors: Array[Color] = [] # persisted recent custom colors
|
||||
var _modal_recent_btns: Array[Button] = []
|
||||
|
||||
# --- Dock container references (set during tab build; avoid fragile child-index traversal) ---
|
||||
@@ -207,25 +283,31 @@ var _tab_container: TabContainer = null
|
||||
|
||||
# --- @onready references to .tscn nodes ---
|
||||
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
|
||||
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||||
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||||
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||||
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||||
@onready
|
||||
var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||||
@onready
|
||||
var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||||
@onready
|
||||
var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||||
@onready
|
||||
var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||||
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
||||
@onready var _footer_back: Button = $Footer/BackBtn
|
||||
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
|
||||
@onready var _footer_start: Button = $Footer/StartBtn
|
||||
@onready var _modal_root: Control = $ColorPickerModal
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_manifest() -> void:
|
||||
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("CharacterCreation: manifest not found at %s — using empty defaults" % MANIFEST_PATH)
|
||||
push_warning(
|
||||
"CharacterCreation: manifest not found at %s — using empty defaults" % MANIFEST_PATH
|
||||
)
|
||||
_manifest = {}
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
@@ -277,12 +359,16 @@ func _ready() -> void:
|
||||
if has_hair:
|
||||
tab_builders.append({"name": "Hair", "build": _build_hair_tab, "always": false})
|
||||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||||
var has_clothing: bool = clothing_data is Dictionary and not (clothing_data as Dictionary).is_empty()
|
||||
var has_clothing: bool = (
|
||||
clothing_data is Dictionary and not (clothing_data as Dictionary).is_empty()
|
||||
)
|
||||
if has_clothing:
|
||||
tab_builders.append({"name": "Clothing", "build": _build_clothing_tab, "always": false})
|
||||
var has_accessories := not _manifest_array("accessories").is_empty()
|
||||
if has_accessories:
|
||||
tab_builders.append({"name": "Accessories", "build": _build_accessories_tab, "always": false})
|
||||
tab_builders.append(
|
||||
{"name": "Accessories", "build": _build_accessories_tab, "always": false}
|
||||
)
|
||||
tab_builders.append({"name": "Debug", "build": _build_debug_tab, "always": true})
|
||||
|
||||
for tb in tab_builders:
|
||||
@@ -337,6 +423,7 @@ func _ready() -> void:
|
||||
# Camera management (D-158)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _update_camera_angle() -> void:
|
||||
var pitch_deg := CAM_PITCHES[_cam_pitch_idx]
|
||||
var pitch_rad := deg_to_rad(-pitch_deg) # negative = looking downward
|
||||
@@ -359,8 +446,10 @@ func _cam_zoom_toward_cursor(_screen_pos: Vector2, zoom_delta: float) -> void:
|
||||
if _char_visual and _char_visual._skeleton:
|
||||
var head_idx := _char_visual._skeleton.find_bone("Head")
|
||||
if head_idx >= 0:
|
||||
var head_pos := _char_visual._skeleton.global_transform \
|
||||
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||||
var head_pos := (
|
||||
_char_visual._skeleton.global_transform
|
||||
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||||
)
|
||||
# Blend from body center toward head as zoom increases
|
||||
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom
|
||||
_cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend
|
||||
@@ -384,6 +473,7 @@ func _on_cam_angle_toggle() -> void:
|
||||
# Rotation (D-155: cardinal only, Q=CCW, E=CW)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_rotate_left() -> void:
|
||||
_facing_idx = (_facing_idx + 1) % CARDINAL_DIRS.size()
|
||||
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
|
||||
@@ -398,6 +488,7 @@ func _on_rotate_right() -> void:
|
||||
# Preview refresh
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _refresh_preview() -> void:
|
||||
if _char_visual and is_instance_valid(_char_visual):
|
||||
_char_visual.load_descriptor(_descriptor)
|
||||
@@ -408,6 +499,7 @@ func _refresh_preview() -> void:
|
||||
# Tab: Body (Task #2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_body_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -475,10 +567,13 @@ func _build_body_tab(tab: Control) -> void:
|
||||
var eye_row := HBoxContainer.new()
|
||||
eye_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(eye_row)
|
||||
var eye_swatch := _make_color_swatch(_descriptor.eye_color, "Iris",
|
||||
var eye_swatch := _make_color_swatch(
|
||||
_descriptor.eye_color,
|
||||
"Iris",
|
||||
func(c: Color) -> void:
|
||||
_descriptor.eye_color = c
|
||||
_refresh_preview())
|
||||
_refresh_preview()
|
||||
)
|
||||
eye_row.add_child(eye_swatch)
|
||||
|
||||
_update_body_type_btns()
|
||||
@@ -527,6 +622,7 @@ func _update_body_type_btns() -> void:
|
||||
# Tab: Head (Task #3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_head_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -564,10 +660,13 @@ func _build_head_tab(tab: Control) -> void:
|
||||
var eye_row := HBoxContainer.new()
|
||||
eye_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(eye_row)
|
||||
var eye_swatch := _make_color_swatch(_descriptor.eye_color, "Iris",
|
||||
var eye_swatch := _make_color_swatch(
|
||||
_descriptor.eye_color,
|
||||
"Iris",
|
||||
func(c: Color) -> void:
|
||||
_descriptor.eye_color = c
|
||||
_refresh_preview())
|
||||
_refresh_preview()
|
||||
)
|
||||
eye_row.add_child(eye_swatch)
|
||||
|
||||
_update_head_btns()
|
||||
@@ -589,6 +688,7 @@ func _update_head_btns() -> void:
|
||||
# Tab: Hair (Task #4)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_hair_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -669,7 +769,9 @@ func _update_hair_btns() -> void:
|
||||
for hid: String in _hair_item_btns:
|
||||
_set_item_selected(_hair_item_btns[hid], hid == _descriptor.hair_id)
|
||||
for i in FACIAL_HAIR_OPTIONS.size():
|
||||
_set_item_selected(_facial_hair_btns[i], FACIAL_HAIR_OPTIONS[i] == _descriptor.facial_hair_id)
|
||||
_set_item_selected(
|
||||
_facial_hair_btns[i], FACIAL_HAIR_OPTIONS[i] == _descriptor.facial_hair_id
|
||||
)
|
||||
# Eyebrow buttons removed — eyebrows come from body segment only
|
||||
|
||||
|
||||
@@ -684,21 +786,25 @@ func _build_hair_color_dock() -> Control:
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
dock.add_child(row)
|
||||
|
||||
_hair_primary_swatch = _make_color_swatch(Color(0.55, 0.35, 0.20), "Primary",
|
||||
func(c): _on_hair_primary_changed(c))
|
||||
_hair_primary_swatch = _make_color_swatch(
|
||||
Color(0.55, 0.35, 0.20), "Primary", func(c): _on_hair_primary_changed(c)
|
||||
)
|
||||
row.add_child(_hair_primary_swatch)
|
||||
|
||||
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
|
||||
_hair_highlight_swatch = _make_display_swatch(
|
||||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight")
|
||||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight"
|
||||
)
|
||||
row.add_child(_hair_highlight_swatch)
|
||||
|
||||
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
|
||||
func(c): _on_eyebrow_tint_changed(c))
|
||||
_eyebrow_tint_swatch = _make_color_swatch(
|
||||
_descriptor.hair_tint, "Brows ●", func(c): _on_eyebrow_tint_changed(c)
|
||||
)
|
||||
row.add_child(_eyebrow_tint_swatch)
|
||||
|
||||
_facial_hair_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Facial ●",
|
||||
func(c): _on_facial_hair_tint_changed(c))
|
||||
_facial_hair_tint_swatch = _make_color_swatch(
|
||||
_descriptor.hair_tint, "Facial ●", func(c): _on_facial_hair_tint_changed(c)
|
||||
)
|
||||
row.add_child(_facial_hair_tint_swatch)
|
||||
|
||||
return dock
|
||||
@@ -743,6 +849,7 @@ func _on_facial_hair_tint_changed(color: Color) -> void:
|
||||
# Tab: Clothing (Task #5)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_clothing_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -851,23 +958,25 @@ func _rebuild_clothing_color_dock(container: Control) -> void:
|
||||
container.add_child(row)
|
||||
|
||||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||||
var tints: Array = _descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else [] as Array
|
||||
var tints: Array = (
|
||||
_descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||||
as Array
|
||||
)
|
||||
var primary: Color = tints[0] as Color if tints.size() > 0 else Color(0.7, 0.65, 0.6)
|
||||
var secondary: Color = tints[1] as Color if tints.size() > 1 else _derive_secondary(primary)
|
||||
var accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
|
||||
|
||||
var p_swatch := _make_color_swatch(primary, "Primary",
|
||||
func(c): _on_clothing_primary_changed(c))
|
||||
var p_swatch := _make_color_swatch(primary, "Primary", func(c): _on_clothing_primary_changed(c))
|
||||
row.add_child(p_swatch)
|
||||
_clothing_primary_swatches[_active_clothing_slot] = p_swatch
|
||||
|
||||
var s_swatch := _make_color_swatch(secondary, "Secondary ●",
|
||||
func(c): _on_clothing_secondary_changed(c))
|
||||
var s_swatch := _make_color_swatch(
|
||||
secondary, "Secondary ●", func(c): _on_clothing_secondary_changed(c)
|
||||
)
|
||||
row.add_child(s_swatch)
|
||||
_clothing_secondary_swatches[_active_clothing_slot] = s_swatch
|
||||
|
||||
var a_swatch := _make_color_swatch(accent, "Accent ●",
|
||||
func(c): _on_clothing_accent_changed(c))
|
||||
var a_swatch := _make_color_swatch(accent, "Accent ●", func(c): _on_clothing_accent_changed(c))
|
||||
row.add_child(a_swatch)
|
||||
_clothing_accent_swatches[_active_clothing_slot] = a_swatch
|
||||
|
||||
@@ -902,7 +1011,9 @@ func _on_clothing_primary_changed(color: Color) -> void:
|
||||
tints[0] = color
|
||||
_descriptor.clothing_tints[item_id] = tints
|
||||
if _clothing_secondary_auto.get(_active_clothing_slot, true):
|
||||
_set_swatch_color(_clothing_secondary_swatches.get(_active_clothing_slot), _derive_secondary(color))
|
||||
_set_swatch_color(
|
||||
_clothing_secondary_swatches.get(_active_clothing_slot), _derive_secondary(color)
|
||||
)
|
||||
_refresh_preview()
|
||||
|
||||
|
||||
@@ -953,6 +1064,7 @@ func _update_clothing_item_btns() -> void:
|
||||
# Tab: Accessories (Task #6)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_accessories_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -1032,17 +1144,22 @@ func _rebuild_accessory_color_dock(container: Control) -> void:
|
||||
container.add_child(row)
|
||||
|
||||
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
|
||||
var tints_arr: Array = _descriptor.accessory_tints.get(item_id, []) as Array if not item_id.is_empty() else [] as Array
|
||||
var tints_arr: Array = (
|
||||
_descriptor.accessory_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||||
as Array
|
||||
)
|
||||
var primary: Color = tints_arr[0] as Color if not tints_arr.is_empty() else Color.WHITE
|
||||
var secondary: Color = _derive_secondary(primary)
|
||||
|
||||
var p_swatch := _make_color_swatch(primary, "Primary",
|
||||
func(c): _on_accessory_primary_changed(c))
|
||||
var p_swatch := _make_color_swatch(
|
||||
primary, "Primary", func(c): _on_accessory_primary_changed(c)
|
||||
)
|
||||
row.add_child(p_swatch)
|
||||
_accessory_primary_swatches[_active_accessory_slot] = p_swatch
|
||||
|
||||
var s_swatch := _make_color_swatch(secondary, "Secondary ●",
|
||||
func(c): _on_accessory_secondary_changed(c))
|
||||
var s_swatch := _make_color_swatch(
|
||||
secondary, "Secondary ●", func(c): _on_accessory_secondary_changed(c)
|
||||
)
|
||||
row.add_child(s_swatch)
|
||||
_accessory_secondary_swatches[_active_accessory_slot] = s_swatch
|
||||
|
||||
@@ -1103,6 +1220,7 @@ func _update_accessory_item_btns() -> void:
|
||||
# Screenshot & automated testing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _schedule_screenshot() -> void:
|
||||
_screenshot_pending = true
|
||||
_screenshot_frame_count = 0
|
||||
@@ -1125,10 +1243,10 @@ func _take_screenshot(suffix: String = "") -> void:
|
||||
_char_visual.set_facing(CARDINAL_DIRS[_screenshot_cardinal_idx])
|
||||
suffix = dir_name
|
||||
|
||||
var filename := "charcreator_%s_%s.png" % [
|
||||
_descriptor.body_type_key(),
|
||||
suffix if not suffix.is_empty() else "default"
|
||||
]
|
||||
var filename := (
|
||||
"charcreator_%s_%s.png"
|
||||
% [_descriptor.body_type_key(), suffix if not suffix.is_empty() else "default"]
|
||||
)
|
||||
var path := SCREENSHOT_DIR + filename
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
img.save_png(path)
|
||||
@@ -1212,6 +1330,7 @@ func _load_test_config() -> void:
|
||||
# Debug tab — segment visibility toggles
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_debug_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -1308,6 +1427,7 @@ func _on_debug_toggle_outlines(pressed: bool) -> void:
|
||||
# Color picker modal (Task #7) — D-165 palette
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_color_picker_modal() -> void:
|
||||
var modal := _modal_root
|
||||
|
||||
@@ -1496,6 +1616,7 @@ func _update_modal_recent_btns() -> void:
|
||||
# Input handling (Task #8 keyboard nav)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -1547,7 +1668,9 @@ func _input(event: InputEvent) -> void:
|
||||
KEY_TAB:
|
||||
var count := _tab_container.get_tab_count()
|
||||
if key.shift_pressed:
|
||||
_tab_container.current_tab = ((_tab_container.current_tab - 1) % count + count) % count
|
||||
_tab_container.current_tab = (
|
||||
((_tab_container.current_tab - 1) % count + count) % count
|
||||
)
|
||||
else:
|
||||
_tab_container.current_tab = (_tab_container.current_tab + 1) % count
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -1557,6 +1680,7 @@ func _input(event: InputEvent) -> void:
|
||||
# Game flow (Task #8)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_back() -> void:
|
||||
creation_cancelled.emit()
|
||||
|
||||
@@ -1569,6 +1693,7 @@ func _on_start() -> void:
|
||||
# Randomize
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_randomize() -> void:
|
||||
# Body type — pick from manifest only
|
||||
var available_types: Array = _manifest_array("body_types")
|
||||
@@ -1595,7 +1720,9 @@ func _on_randomize() -> void:
|
||||
_descriptor.facial_hair_id = ""
|
||||
|
||||
# Eye color — random natural tones
|
||||
_descriptor.eye_color = Color.from_hsv(randf() * 0.15 + 0.05, 0.3 + randf() * 0.5, 0.2 + randf() * 0.5)
|
||||
_descriptor.eye_color = Color.from_hsv(
|
||||
randf() * 0.15 + 0.05, 0.3 + randf() * 0.5, 0.2 + randf() * 0.5
|
||||
)
|
||||
|
||||
# Sync auto tints
|
||||
_eyebrow_tint_auto = true
|
||||
@@ -1615,6 +1742,7 @@ func _on_randomize() -> void:
|
||||
# Shared helpers — skin tone dock
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_skin_tone_dock() -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
@@ -1659,6 +1787,7 @@ func _update_skin_tone_btns() -> void:
|
||||
# Shared helpers — color swatches
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect).
|
||||
func _get_swatch_color(btn: Button) -> Color:
|
||||
for child in btn.get_children():
|
||||
@@ -1698,9 +1827,7 @@ func _make_color_swatch(color: Color, label_text: String, callback: Callable) ->
|
||||
vbox.add_child(lbl)
|
||||
|
||||
# Use _get_swatch_color to read current color at click time (avoids stale closure).
|
||||
btn.pressed.connect(func():
|
||||
_open_color_picker(_get_swatch_color(btn), btn, callback)
|
||||
)
|
||||
btn.pressed.connect(func(): _open_color_picker(_get_swatch_color(btn), btn, callback))
|
||||
return btn
|
||||
|
||||
|
||||
@@ -1754,6 +1881,7 @@ func _set_swatch_color(btn: Button, color: Color) -> void:
|
||||
# Shared helpers — layout builders
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _make_tab_vbox(tab: Control) -> VBoxContainer:
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -1772,15 +1900,16 @@ func _make_search_bar(tab_idx: int) -> LineEdit:
|
||||
search.placeholder_text = UIStrings.get_text("character_creation.search_placeholder")
|
||||
search.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
search.add_theme_font_size_override("font_size", 12)
|
||||
search.text_changed.connect(func(q: String):
|
||||
_tab_search[tab_idx] = q
|
||||
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
|
||||
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
|
||||
_apply_search_filter(_clothing_grids[_active_clothing_slot], q)
|
||||
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
|
||||
_apply_search_filter(_accessory_grids[_active_accessory_slot], q)
|
||||
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
|
||||
_apply_search_filter(_tab_grids[tab_idx], q)
|
||||
search.text_changed.connect(
|
||||
func(q: String):
|
||||
_tab_search[tab_idx] = q
|
||||
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
|
||||
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
|
||||
_apply_search_filter(_clothing_grids[_active_clothing_slot], q)
|
||||
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
|
||||
_apply_search_filter(_accessory_grids[_active_accessory_slot], q)
|
||||
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
|
||||
_apply_search_filter(_tab_grids[tab_idx], q)
|
||||
)
|
||||
return search
|
||||
|
||||
@@ -1849,7 +1978,9 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void:
|
||||
var lower_q := query.to_lower()
|
||||
for child in grid.get_children():
|
||||
if child is Button:
|
||||
child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||||
child.visible = (
|
||||
lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||||
)
|
||||
|
||||
|
||||
func _get_clothing_ids_for_slot(slot: String) -> Array:
|
||||
@@ -1872,21 +2003,31 @@ func _get_accessory_ids_for_slot(slot: String) -> Array:
|
||||
# Accessory items from manifest, filtered by slot name prefix convention.
|
||||
var all_ids: Array = _manifest_array("accessories")
|
||||
match slot:
|
||||
"hat": return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
|
||||
"goggles": return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
|
||||
"mask": return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
|
||||
"backpack": return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
|
||||
"belt": return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
|
||||
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
|
||||
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
|
||||
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
|
||||
_: return all_ids # gdlint:ignore = max-returns
|
||||
"hat":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
|
||||
"goggles":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
|
||||
"mask":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
|
||||
"backpack":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
|
||||
"belt":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
|
||||
"wrist_l", "wrist_r":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
|
||||
"earring_l", "earring_r":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
|
||||
"necklace":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
|
||||
_:
|
||||
return all_ids # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Color derivation helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _derive_hair_highlight(primary: Color) -> Color:
|
||||
return primary.lightened(0.3)
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ extends Control
|
||||
const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
|
||||
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
|
||||
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
|
||||
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
|
||||
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
|
||||
const FONT_SIZE := 11
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(8, 6)
|
||||
@@ -79,13 +79,17 @@ func _draw() -> void:
|
||||
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
|
||||
|
||||
# Calculate box width from longest line
|
||||
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
var max_width: float = (
|
||||
font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
for r in results:
|
||||
var desc: String = r.get("description", r.get("id", ""))
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
|
||||
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
var line_width: float = (
|
||||
font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
if line_width > max_width:
|
||||
max_width = line_width
|
||||
|
||||
@@ -96,7 +100,15 @@ func _draw() -> void:
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
header_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
header_color
|
||||
)
|
||||
|
||||
# Condition lines
|
||||
for r in results:
|
||||
@@ -107,11 +119,20 @@ func _draw() -> void:
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var color: Color = MET_COLOR if is_met else UNMET_COLOR
|
||||
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
prefix + desc,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
color
|
||||
)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_evaluator():
|
||||
return _evaluator
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ extends Control
|
||||
## Settings-toggled; enabled state persisted in user://settings.cfg.
|
||||
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
|
||||
|
||||
signal pause_requested # D-088: pause sim while console is open
|
||||
signal pause_requested # D-088: pause sim while console is open
|
||||
signal unpause_requested # D-088: unpause sim when console closes
|
||||
|
||||
const PREFS_PATH := "user://settings.cfg"
|
||||
@@ -97,6 +97,7 @@ func _update_panel_layout() -> void:
|
||||
|
||||
# -- Input handling --
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
@@ -155,6 +156,7 @@ func is_open() -> bool:
|
||||
|
||||
# -- Command input --
|
||||
|
||||
|
||||
func _on_input_submitted(text: String) -> void:
|
||||
var trimmed := text.strip_edges()
|
||||
_input_line.clear()
|
||||
@@ -199,7 +201,9 @@ func _dispatch(line: String) -> void:
|
||||
z = int(parts[3])
|
||||
else:
|
||||
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
|
||||
_send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}})
|
||||
_send_debug(
|
||||
{"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}
|
||||
)
|
||||
else:
|
||||
var loc := " ".join(PackedStringArray(parts.slice(1)))
|
||||
_send_debug({"TeleportToLocation": loc})
|
||||
@@ -226,17 +230,23 @@ func _dispatch(line: String) -> void:
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.DEBUG_COMMAND,
|
||||
"action_data": kind,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
var err := (
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.DEBUG_COMMAND,
|
||||
"action_data": kind,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
if err != OK:
|
||||
_append_text("send error: %s" % error_string(err), ERROR_COLOR)
|
||||
|
||||
|
||||
# -- Response display --
|
||||
|
||||
|
||||
## Append a server debug response to the output log. Auto-opens console if closed
|
||||
## (only if console is enabled — respect user's settings toggle).
|
||||
func append_response(response: Dictionary) -> void:
|
||||
@@ -250,6 +260,7 @@ func append_response(response: Dictionary) -> void:
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
|
||||
func _append_text(text: String, color: Color) -> void:
|
||||
var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped])
|
||||
@@ -261,24 +272,27 @@ func _append_text(text: String, color: Color) -> void:
|
||||
|
||||
func _print_help() -> void:
|
||||
_append_text(
|
||||
"Commands:\n"
|
||||
+ " ticks <n> — fast-forward N ticks\n"
|
||||
+ " contaminate — skip to contamination phase\n"
|
||||
+ " tp <x> <y> [z] — teleport to tile position\n"
|
||||
+ " tp <location> — teleport to named location\n"
|
||||
+ " activate — force contamination activate\n"
|
||||
+ " triangle <id> — force triangle activation\n"
|
||||
+ " npc <entity_id> — inspect NPC state\n"
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ " help — this list",
|
||||
(
|
||||
"Commands:\n"
|
||||
+ " ticks <n> — fast-forward N ticks\n"
|
||||
+ " contaminate — skip to contamination phase\n"
|
||||
+ " tp <x> <y> [z] — teleport to tile position\n"
|
||||
+ " tp <location> — teleport to named location\n"
|
||||
+ " activate — force contamination activate\n"
|
||||
+ " triangle <id> — force triangle activation\n"
|
||||
+ " npc <entity_id> — inspect NPC state\n"
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ " help — this list"
|
||||
),
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# -- Command history --
|
||||
|
||||
|
||||
func _history_up() -> void:
|
||||
if _history.is_empty():
|
||||
return
|
||||
@@ -299,6 +313,7 @@ func _history_down() -> void:
|
||||
|
||||
# -- Settings --
|
||||
|
||||
|
||||
func set_enabled(enabled: bool) -> void:
|
||||
_enabled = enabled
|
||||
if not _enabled and _open:
|
||||
|
||||
@@ -37,7 +37,7 @@ const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||
# Vision cone geometry (radians)
|
||||
# Forward: ±60° around facing direction (120° total)
|
||||
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||
const CONE_ARC_STEPS: int = 20
|
||||
|
||||
@@ -65,17 +65,18 @@ var _npc_paths: Dictionary = {}
|
||||
var _last_tick_processed: int = -1
|
||||
|
||||
# Tick timing ring
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_dev_mode = OS.is_debug_build()
|
||||
visible = false
|
||||
# Position set in main.tscn: below merged HUD status panel (starts at y:16)
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
# Clear NPC path history on session change to prevent entity ID collisions
|
||||
GameState.connect("game_id_changed", _on_game_id_changed)
|
||||
@@ -147,6 +148,7 @@ func _update_npc_paths() -> void:
|
||||
# Draw dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -159,6 +161,7 @@ func _draw() -> void:
|
||||
# Panel 1: Stats text (top-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw_stats_panel() -> void:
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
@@ -173,7 +176,9 @@ func _draw_stats_panel() -> void:
|
||||
right_lines.append(["facing", GameState.player_facing])
|
||||
|
||||
left_lines.append(["stance", GameState.player_stance])
|
||||
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
|
||||
right_lines.append(
|
||||
["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"]
|
||||
)
|
||||
|
||||
left_lines.append(["entities", str(GameState.visible_entities.size())])
|
||||
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
|
||||
@@ -190,7 +195,9 @@ func _draw_stats_panel() -> void:
|
||||
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
|
||||
|
||||
var gt := GameState.game_time
|
||||
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
var time_str := (
|
||||
"%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
)
|
||||
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
|
||||
left_lines.append(["time", time_str])
|
||||
right_lines.append(["tick_rate", rate_str])
|
||||
@@ -210,14 +217,26 @@ func _draw_stats_panel() -> void:
|
||||
var right_value_w: float = 0.0
|
||||
|
||||
for line in left_lines:
|
||||
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_label_w = max(
|
||||
left_label_w,
|
||||
font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
left_value_w = max(
|
||||
left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
for line in right_lines:
|
||||
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_label_w = max(
|
||||
right_label_w,
|
||||
font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
right_value_w = max(
|
||||
right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
|
||||
var header_text := "F3 DEBUG"
|
||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
var header_w := (
|
||||
font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
)
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
@@ -226,21 +245,57 @@ func _draw_stats_panel() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
header_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE + 1,
|
||||
HEADER_COLOR
|
||||
)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
left_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
LABEL_COLOR
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x + left_label_w, y),
|
||||
left_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
VALUE_COLOR
|
||||
)
|
||||
if i < right_lines.size():
|
||||
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(right_x, y),
|
||||
right_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
LABEL_COLOR
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(right_x + right_label_w, y),
|
||||
right_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
VALUE_COLOR
|
||||
)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
|
||||
@@ -248,6 +303,7 @@ func _draw_stats_panel() -> void:
|
||||
# Panel 2: World overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw_world_overlays() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
@@ -291,9 +347,15 @@ func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(
|
||||
_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR
|
||||
)
|
||||
draw_colored_polygon(
|
||||
_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR
|
||||
)
|
||||
draw_colored_polygon(
|
||||
_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR
|
||||
)
|
||||
|
||||
# Forward arc boundary ring
|
||||
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||
@@ -303,7 +365,9 @@ func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
|
||||
|
||||
# Build a filled polygon fan from center through an arc
|
||||
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||
func _arc_polygon(
|
||||
center: Vector2, radius: float, angle_from: float, angle_to: float
|
||||
) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
pts.append(center)
|
||||
for i in range(CONE_ARC_STEPS + 1):
|
||||
@@ -339,8 +403,14 @@ func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||
var b_screen := _w2s(path[i], canvas_xf)
|
||||
var alpha := float(i) / float(path.size())
|
||||
draw_line(a_screen, b_screen,
|
||||
Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||
draw_line(
|
||||
a_screen,
|
||||
b_screen,
|
||||
Color(
|
||||
NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha
|
||||
),
|
||||
1.5
|
||||
)
|
||||
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||
|
||||
|
||||
@@ -377,17 +447,25 @@ func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||
entity_screen.x - text_w / 2.0 - 3.0,
|
||||
tag_baseline - FONT_SIZE + 2.0,
|
||||
text_w + 6.0,
|
||||
FONT_SIZE)
|
||||
FONT_SIZE
|
||||
)
|
||||
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||
label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE - 1,
|
||||
TAG_TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3: Tick timing sparkline (bottom-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw_tick_graph() -> void:
|
||||
if _tick_deltas.size() < 2:
|
||||
return
|
||||
@@ -398,10 +476,15 @@ func _draw_tick_graph() -> void:
|
||||
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||
|
||||
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE - 1,
|
||||
LABEL_COLOR
|
||||
)
|
||||
|
||||
var chart_top := box_y + label_h
|
||||
var chart_left := box_x + 4.0
|
||||
@@ -418,9 +501,12 @@ func _draw_tick_graph() -> void:
|
||||
# Warn threshold dashed line
|
||||
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||
draw_dashed_line(
|
||||
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||
Vector2(chart_left, warn_y),
|
||||
Vector2(chart_left + chart_w, warn_y),
|
||||
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||
1.0, 4.0)
|
||||
1.0,
|
||||
4.0
|
||||
)
|
||||
|
||||
# Sparkline
|
||||
var n := _tick_deltas.size()
|
||||
@@ -441,25 +527,40 @@ func _draw_tick_graph() -> void:
|
||||
for d in _tick_deltas:
|
||||
avg_ms += float(d)
|
||||
avg_ms /= float(_tick_deltas.size())
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||
"avg %.0fms" % avg_ms,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE - 1,
|
||||
LABEL_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||
static func _facing_to_angle(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return -PI / 2.0
|
||||
"Northeast": return -PI / 4.0
|
||||
"East": return 0.0
|
||||
"Southeast": return PI / 4.0
|
||||
"South": return PI / 2.0
|
||||
"Southwest": return PI * 3.0 / 4.0
|
||||
"West": return PI
|
||||
"Northwest": return -PI * 3.0 / 4.0
|
||||
_: return -PI / 2.0
|
||||
"North":
|
||||
return -PI / 2.0
|
||||
"Northeast":
|
||||
return -PI / 4.0
|
||||
"East":
|
||||
return 0.0
|
||||
"Southeast":
|
||||
return PI / 4.0
|
||||
"South":
|
||||
return PI / 2.0
|
||||
"Southwest":
|
||||
return PI * 3.0 / 4.0
|
||||
"West":
|
||||
return PI
|
||||
"Northwest":
|
||||
return -PI * 3.0 / 4.0
|
||||
_:
|
||||
return -PI / 2.0
|
||||
|
||||
@@ -13,7 +13,7 @@ extends Control
|
||||
signal option_selected(response_id: String, text: String)
|
||||
signal dialogue_dismissed # Walk-away or conversation end
|
||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||
signal unpause_requested # D-061: auto-unpause
|
||||
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
|
||||
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
|
||||
@@ -25,8 +25,8 @@ const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||
|
||||
const FADE_IN: float = 0.2
|
||||
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
|
||||
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
|
||||
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||
@@ -38,8 +38,14 @@ const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness a
|
||||
|
||||
# D-064: movement actions that trigger walk-away
|
||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||
&"move_north", &"move_south", &"move_east", &"move_west",
|
||||
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest",
|
||||
&"move_north",
|
||||
&"move_south",
|
||||
&"move_east",
|
||||
&"move_west",
|
||||
&"move_northeast",
|
||||
&"move_southeast",
|
||||
&"move_southwest",
|
||||
&"move_northwest",
|
||||
]
|
||||
|
||||
# -- Log state --
|
||||
@@ -54,7 +60,6 @@ var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode re
|
||||
# Populated from server events; drives retroactive re-render when NPC names resolve.
|
||||
var _entity_display: Dictionary = {}
|
||||
|
||||
|
||||
# -- Option state --
|
||||
var _option_controls: Array[Control] = []
|
||||
var _option_response_ids: Array[String] = []
|
||||
@@ -68,8 +73,8 @@ var _npc_name: String = ""
|
||||
# v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid
|
||||
# palette exhaustion (8 entries) across long sessions with 9+ NPCs.
|
||||
var _npc_entity_colors: Dictionary = {} # entity_id -> Color
|
||||
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
|
||||
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
|
||||
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
|
||||
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
|
||||
|
||||
# -- UI state --
|
||||
var _active_tween: Tween = null
|
||||
@@ -86,7 +91,8 @@ var _entry_fade: float = 5.0
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
@onready
|
||||
var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -114,15 +120,17 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# 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 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
|
||||
if event is InputEventKey and event.pressed:
|
||||
for action in _WALK_AWAY_ACTIONS:
|
||||
@@ -136,11 +144,18 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
# -- Theme loading --
|
||||
|
||||
|
||||
func _load_theme() -> void:
|
||||
# Default NPC palette in case file not found
|
||||
_npc_colors = [
|
||||
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"),
|
||||
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"),
|
||||
Color("#4a9ebb"),
|
||||
Color("#6bc9a6"),
|
||||
Color("#e8c547"),
|
||||
Color("#d49e5d"),
|
||||
Color("#b586d4"),
|
||||
Color("#d45d5d"),
|
||||
Color("#5daa7d"),
|
||||
Color("#7daccc"),
|
||||
]
|
||||
|
||||
if not FileAccess.file_exists(THEME_PATH):
|
||||
@@ -180,6 +195,7 @@ func _load_theme() -> void:
|
||||
|
||||
# -- Responsive layout --
|
||||
|
||||
|
||||
func _update_layout() -> void:
|
||||
var vp := get_viewport_rect().size
|
||||
var max_h := vp.y * MAX_HEIGHT_RATIO
|
||||
@@ -191,14 +207,21 @@ func _update_layout() -> void:
|
||||
|
||||
# -- Log entry management --
|
||||
|
||||
|
||||
## Append a dialogue line to the log.
|
||||
## speaker/target: display names. text: the spoken line.
|
||||
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
|
||||
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
|
||||
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
|
||||
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
|
||||
func append_line(speaker: String, target: String, text: String,
|
||||
is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
|
||||
func append_line(
|
||||
speaker: String,
|
||||
target: String,
|
||||
text: String,
|
||||
is_passive: bool = false,
|
||||
speaker_entity_id: int = -1,
|
||||
target_entity_id: int = -1
|
||||
) -> void:
|
||||
var pinned := not is_passive and _in_player_conversation
|
||||
var entry: Dictionary = {
|
||||
"speaker": speaker,
|
||||
@@ -294,10 +317,13 @@ func append_dialogue_response(npc_name: String, text: String, entity_id: int = -
|
||||
|
||||
# -- Active player conversation --
|
||||
|
||||
|
||||
## Show dialogue with NPC speech and response options.
|
||||
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
|
||||
## npc_entity_id: entity ID of the NPC for stable color assignment (#573).
|
||||
func show_dialogue(npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1) -> void:
|
||||
func show_dialogue(
|
||||
npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1
|
||||
) -> void:
|
||||
_npc_name = npc_name
|
||||
_npc_entity_id = npc_entity_id
|
||||
_cancel_beat()
|
||||
@@ -379,6 +405,7 @@ func has_active_entries() -> bool:
|
||||
|
||||
# -- Options --
|
||||
|
||||
|
||||
func _show_options(options: Array) -> void:
|
||||
var sorted_opts: Array = options.duplicate()
|
||||
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
|
||||
@@ -398,12 +425,15 @@ func _show_options(options: Array) -> void:
|
||||
rtl.fit_content = true
|
||||
rtl.scroll_active = false
|
||||
rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
rtl.add_theme_color_override("default_color", Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)) # Slight warm tint for confrontation weight
|
||||
rtl.add_theme_color_override(
|
||||
"default_color",
|
||||
Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)
|
||||
) # Slight warm tint for confrontation weight
|
||||
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
|
||||
@@ -419,9 +449,14 @@ func _show_options(options: Array) -> void:
|
||||
ctrl = label
|
||||
|
||||
var idx := i
|
||||
ctrl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
ctrl.gui_input.connect(
|
||||
func(event: InputEvent):
|
||||
if (
|
||||
event is InputEventMouseButton
|
||||
and event.pressed
|
||||
and event.button_index == MOUSE_BUTTON_LEFT
|
||||
):
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
|
||||
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
|
||||
@@ -438,7 +473,9 @@ func _on_option_pressed(index: int) -> void:
|
||||
return
|
||||
var rid: String = _option_response_ids[index] if index < _option_response_ids.size() else ""
|
||||
var text: String = _option_texts[index] if index < _option_texts.size() else ""
|
||||
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
|
||||
var is_confront: bool = (
|
||||
_option_is_confrontation[index] if index < _option_is_confrontation.size() else false
|
||||
)
|
||||
|
||||
# Append player's chosen response to the log
|
||||
append_player_line(_npc_name, text)
|
||||
@@ -452,6 +489,7 @@ func _on_option_pressed(index: int) -> void:
|
||||
|
||||
# -- Confrontation beat (D-063) --
|
||||
|
||||
|
||||
func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
for ctrl in _option_controls:
|
||||
if is_instance_valid(ctrl):
|
||||
@@ -462,15 +500,18 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
||||
confrontation_monologue.emit(
|
||||
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
|
||||
)
|
||||
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
|
||||
|
||||
_beat_tween = create_tween()
|
||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||
_beat_tween.tween_callback(func():
|
||||
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||
option_selected.emit(response_id, text)
|
||||
_end_player_conversation()
|
||||
_beat_tween.tween_callback(
|
||||
func():
|
||||
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||
option_selected.emit(response_id, text)
|
||||
_end_player_conversation()
|
||||
)
|
||||
|
||||
|
||||
@@ -483,6 +524,7 @@ func _cancel_beat() -> void:
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
|
||||
## Rebuild the dialogue log BBCode from all non-expired entries.
|
||||
func _rebuild_log() -> void:
|
||||
if not dialogue_log:
|
||||
@@ -550,7 +592,9 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
target_color = _npc_entity_colors[tg_eid]
|
||||
else:
|
||||
target_color = _color_for_name(entry.get("target", "?"))
|
||||
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
|
||||
involves_player = (
|
||||
(entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
|
||||
)
|
||||
|
||||
var text: String = _escape_bbcode(entry.text)
|
||||
var is_passive: bool = entry.is_passive
|
||||
@@ -570,13 +614,14 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
|
||||
if involves_player and not is_passive:
|
||||
# Just "Speaker: text" or "You: text"
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, txc, text
|
||||
]
|
||||
return (
|
||||
"%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [prefix, sc, speaker, txc, text]
|
||||
)
|
||||
# Full "Speaker → Target: text" for overheard
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, ac, tc, target, txc, text
|
||||
]
|
||||
return (
|
||||
"%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]"
|
||||
% [prefix, sc, speaker, ac, tc, target, txc, text]
|
||||
)
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
@@ -644,6 +689,7 @@ static func _color_with_alpha(base: Color, alpha: float) -> String:
|
||||
|
||||
# -- Entry expiry --
|
||||
|
||||
|
||||
## Remove fully expired entries. Mark dirty if any fading entries exist.
|
||||
## Pinned entries (active conversation) skip expiry entirely (Araminta #2).
|
||||
func _expire_entries() -> void:
|
||||
@@ -689,6 +735,7 @@ func _expire_entries() -> void:
|
||||
|
||||
# -- Visibility --
|
||||
|
||||
|
||||
## No-op — panel is always visible as a permanent insert UI element.
|
||||
func _ensure_visible() -> void:
|
||||
pass
|
||||
|
||||
@@ -9,7 +9,7 @@ extends Control
|
||||
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
|
||||
## Only one examine result is shown at a time — new result replaces old.
|
||||
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.35
|
||||
|
||||
@@ -27,6 +27,7 @@ var _active: bool = false
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
modulate.a = 0.0
|
||||
@@ -48,8 +49,12 @@ func show_result(result: Dictionary) -> void:
|
||||
|
||||
# Apply confidence-based alpha to the insert color
|
||||
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
|
||||
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b, alpha)
|
||||
var col := Color(
|
||||
Constants.INSERT_COLOR_TEXT.r,
|
||||
Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b,
|
||||
alpha
|
||||
)
|
||||
text_label.add_theme_color_override("default_color", col)
|
||||
text_label.text = text
|
||||
|
||||
@@ -69,9 +74,10 @@ func _start_fade_out() -> void:
|
||||
return
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func():
|
||||
visible = false
|
||||
_active = false
|
||||
t.tween_callback(
|
||||
func():
|
||||
visible = false
|
||||
_active = false
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ extends Control
|
||||
## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json.
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
|
||||
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
|
||||
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
|
||||
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
|
||||
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
|
||||
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
|
||||
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
|
||||
const FONT_SIZE := 13
|
||||
const PADDING := Vector2(10, 6)
|
||||
const STATS_PATH := "user://dev/gauntlet-stats.json"
|
||||
@@ -109,13 +109,28 @@ func _draw() -> void:
|
||||
# Timer text
|
||||
var y_offset := PADDING.y + text_size.y
|
||||
var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
|
||||
draw_string(font, Vector2(PADDING.x, y_offset), timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, TIMER_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y_offset),
|
||||
timer_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
TIMER_COLOR
|
||||
)
|
||||
|
||||
# PB text (different color)
|
||||
if not pb_text.is_empty():
|
||||
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
|
||||
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x + timer_size.x, y_offset),
|
||||
pb_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
pb_color
|
||||
)
|
||||
|
||||
|
||||
static func _format_time(seconds: float) -> String:
|
||||
@@ -154,9 +169,15 @@ func print_session_summary() -> void:
|
||||
for room_id in _session_rooms:
|
||||
var entry: Dictionary = _session_rooms[room_id]
|
||||
var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--"
|
||||
var pb_str := _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
|
||||
print(" Room %s: %d attempts, session best %s, all-time PB %s" % [
|
||||
room_id, entry["attempts"], best_str, pb_str])
|
||||
var pb_str := (
|
||||
_format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
|
||||
)
|
||||
print(
|
||||
(
|
||||
" Room %s: %d attempts, session best %s, all-time PB %s"
|
||||
% [room_id, entry["attempts"], best_str, pb_str]
|
||||
)
|
||||
)
|
||||
print("================================")
|
||||
|
||||
|
||||
@@ -170,14 +191,18 @@ func finalize() -> void:
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_timer_seconds() -> float:
|
||||
return _timer_seconds
|
||||
|
||||
|
||||
func is_timer_running() -> bool:
|
||||
return _timer_running
|
||||
|
||||
|
||||
func get_current_room_id() -> Variant:
|
||||
return _current_room_id
|
||||
|
||||
|
||||
func get_personal_best(for_room_id: String) -> float:
|
||||
return _personal_bests.get(for_room_id, INF)
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"]
|
||||
|
||||
; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator
|
||||
; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator (bottom at y:246), 8px gap
|
||||
[node name="GauntletHUD" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -160.0
|
||||
offset_top = 48.0
|
||||
offset_top = 254.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 74.0
|
||||
offset_bottom = 280.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_gauntlet")
|
||||
|
||||
@@ -8,10 +8,10 @@ extends Node
|
||||
## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector").
|
||||
## Settings dialog reads classification strings and power-state methods to configure the UI.
|
||||
|
||||
const RAM_PASS_THRESHOLD_MB := 2000.0
|
||||
const RAM_PASS_THRESHOLD_MB := 2000.0
|
||||
const RAM_MARGINAL_THRESHOLD_MB := 1600.0
|
||||
const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently
|
||||
const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note
|
||||
const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently
|
||||
const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note
|
||||
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
|
||||
|
||||
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
|
||||
@@ -28,9 +28,9 @@ var inference_suspended: bool = false
|
||||
# User's preference at the moment battery suspension activated (to restore on plug-in).
|
||||
var _pre_battery_pref: bool = true
|
||||
|
||||
|
||||
# -- Lifecycle ----------------------------------------------------------------
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
assert(PlatformInfo != null, "PlatformInfo must load before HardwareDetector")
|
||||
load_ai_pref()
|
||||
@@ -40,6 +40,7 @@ func _ready() -> void:
|
||||
|
||||
# -- Layer 1: RAM classification ----------------------------------------------
|
||||
|
||||
|
||||
## Layer 1 — RAM classification.
|
||||
## Accepts free MB as input; returns "pass" | "marginal" | "fail".
|
||||
func classify_ram(free_mb: float) -> String:
|
||||
@@ -54,11 +55,15 @@ func classify_ram(free_mb: float) -> String:
|
||||
## Returns: {classification: String, free_mb: float}
|
||||
func check_ram() -> Dictionary:
|
||||
PlatformInfo.refresh_memory()
|
||||
return {"classification": classify_ram(PlatformInfo.free_memory_mb), "free_mb": PlatformInfo.free_memory_mb}
|
||||
return {
|
||||
"classification": classify_ram(PlatformInfo.free_memory_mb),
|
||||
"free_mb": PlatformInfo.free_memory_mb
|
||||
}
|
||||
|
||||
|
||||
# -- Layer 2: TPT benchmark classification ------------------------------------
|
||||
|
||||
|
||||
## Layer 2 — TPT benchmark classification.
|
||||
## Accepts tokens/sec as input; returns "green" | "yellow" | "red".
|
||||
func classify_tpt(tps: float) -> String:
|
||||
@@ -90,6 +95,7 @@ func read_benchmark_cache() -> Variant:
|
||||
|
||||
# -- Layer 3: degradation + battery -------------------------------------------
|
||||
|
||||
|
||||
## Layer 3 — Ongoing TPT degradation classification.
|
||||
## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable).
|
||||
## Returns "ok" | "yellow".
|
||||
@@ -130,6 +136,7 @@ func is_inference_suspended() -> bool:
|
||||
|
||||
# -- Startup pref load --------------------------------------------------------
|
||||
|
||||
|
||||
## Load AI dialogue preference from settings.cfg and apply to GameState.
|
||||
## Called from _ready() to restore the toggle state across session restarts.
|
||||
## Spec: D-138 §8 — toggle persists via ConfigFile (client-local) + server SQLite (#627).
|
||||
@@ -144,6 +151,7 @@ func load_ai_pref() -> void:
|
||||
|
||||
# -- Battery suspend/resume ---------------------------------------------------
|
||||
|
||||
|
||||
func _on_power_profile_changed(old_profile: int, new_profile: int) -> void:
|
||||
if new_profile == PlatformInfo.PowerProfile.BATTERY:
|
||||
_suspend_for_battery()
|
||||
@@ -176,34 +184,49 @@ func _resume_from_battery() -> void:
|
||||
func _send_settings_change(enabled: bool) -> void:
|
||||
if SimBridge.state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -- Status message -----------------------------------------------------------
|
||||
|
||||
|
||||
## Compose a human-readable status message for the settings dialog.
|
||||
## classification: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT).
|
||||
## Returns empty string when no message is needed.
|
||||
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
|
||||
func status_message(
|
||||
ram_classification: String, tpt_classification: String, free_mb: float, tps: float
|
||||
) -> String:
|
||||
if ram_classification == "fail":
|
||||
return (
|
||||
"AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available."
|
||||
(
|
||||
"AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available."
|
||||
+ " Close other applications and try again, or leave the setting off — the game is complete either way."
|
||||
) % free_mb
|
||||
)
|
||||
% free_mb
|
||||
)
|
||||
if ram_classification == "marginal":
|
||||
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
|
||||
match tpt_classification:
|
||||
"yellow":
|
||||
return (
|
||||
"Running at %.0f t/s — pre-voicing will work for main characters and key scenes."
|
||||
(
|
||||
"Running at %.0f t/s — pre-voicing will work for main characters and key scenes."
|
||||
+ " Background NPCs may show base text until the queue catches up."
|
||||
) % tps
|
||||
)
|
||||
% tps
|
||||
)
|
||||
"red":
|
||||
return (
|
||||
"Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours."
|
||||
) % tps
|
||||
("Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours.")
|
||||
% tps
|
||||
)
|
||||
return ""
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
extends Control
|
||||
|
||||
# HUD — displays player health, perception mode, time, etc.
|
||||
## HUD — implant-styled status panel (D-169, D-170).
|
||||
## Merged panel: Time, Health, Perception mode. Time row replaces standalone TimeDisplay (#786).
|
||||
|
||||
var _panel: ImplantPanel
|
||||
var _time_row: ImplantDataRow
|
||||
var _health_row: ImplantDataRow
|
||||
var _perception_row: ImplantDataRow
|
||||
|
||||
@onready var health_label: Label = $MarginContainer/VBoxContainer/HealthLabel
|
||||
@onready var perception_label: Label = $MarginContainer/VBoxContainer/PerceptionLabel
|
||||
@onready var time_label: Label = $MarginContainer/VBoxContainer/TimeLabel
|
||||
|
||||
func _ready() -> void:
|
||||
print("HUD: Initialized")
|
||||
# Remove the old raw MarginContainer/labels if present
|
||||
var old := get_node_or_null("MarginContainer")
|
||||
if old:
|
||||
old.queue_free()
|
||||
|
||||
var theme_res := load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "StatusPanel"
|
||||
_panel.theme_resource = theme_res
|
||||
_panel.custom_minimum_size.x = 200.0
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.position = Vector2(16, 16) # top-left; replaces standalone TimeDisplay
|
||||
add_child(_panel)
|
||||
|
||||
_time_row = ImplantDataRow.new("--:--")
|
||||
_health_row = ImplantDataRow.new("Health: 100")
|
||||
_perception_row = ImplantDataRow.new("Mode: Baseline")
|
||||
|
||||
_panel.add_component(_time_row)
|
||||
_panel.add_component(_health_row)
|
||||
_panel.add_component(_perception_row)
|
||||
|
||||
|
||||
# Update HUD from snapshot data
|
||||
func update_from_hud_data(data: Dictionary) -> void:
|
||||
# Update perception mode display
|
||||
if not _perception_row:
|
||||
return
|
||||
if data.has("perception_mode"):
|
||||
var prefix := UIStrings.get_text("hud.perception_mode_prefix")
|
||||
if prefix.is_empty():
|
||||
perception_label.text = data.perception_mode.capitalize()
|
||||
_perception_row.text = data.perception_mode.capitalize()
|
||||
else:
|
||||
perception_label.text = prefix + ": " + data.perception_mode.capitalize()
|
||||
_perception_row.text = prefix + ": " + data.perception_mode.capitalize()
|
||||
|
||||
# Update time display (per D-031)
|
||||
if data.has("time"):
|
||||
var prefix := UIStrings.get_text("hud.time_prefix")
|
||||
if prefix.is_empty():
|
||||
time_label.text = data.time
|
||||
else:
|
||||
time_label.text = prefix + ": " + data.time
|
||||
|
||||
# Update player health (from player data, not hud_data)
|
||||
func update_health(health: int) -> void:
|
||||
health_label.text = UIStrings.get_text("hud.health") + ": " + str(health)
|
||||
if not _health_row:
|
||||
return
|
||||
_health_row.text = UIStrings.get_text("hud.health") + ": " + str(health)
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not _time_row:
|
||||
return
|
||||
var gt: Dictionary = GameState.game_time
|
||||
if gt.is_empty():
|
||||
return
|
||||
var tod: int = int(gt.get("time_of_day", 0))
|
||||
var day: int = int(gt.get("day", 0))
|
||||
var phase: String = str(gt.get("day_phase", ""))
|
||||
_time_row.text = "%s · %s · D%d" % [Constants.format_game_time(tod), phase, day + 1]
|
||||
|
||||
|
||||
## Returns the time row text. Used by tests after standalone TimeDisplay was removed.
|
||||
func get_time_text() -> String:
|
||||
return _time_row.text if _time_row else "--:--"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
@@ -13,31 +13,9 @@ grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_hud")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 100.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 16
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="HealthLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Health: 100"
|
||||
|
||||
[node name="PerceptionLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Mode: Baseline"
|
||||
|
||||
[node name="TimeLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Time: 08:00"
|
||||
|
||||
; #674: Star map insert — concentric hop-ring view, hidden by default, toggled via keybind
|
||||
; #674: Star map — full-screen insert overlay, hidden until player activates (M key / insert UI).
|
||||
; Toggled via star_map.toggle_visible() from main.gd.
|
||||
; TODO: migrate to HudGroups.open_app("implant/map/starchart") per D-170.
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[gd_resource type="Resource" script_class="ImplantTheme" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/implant/implant_theme.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
text_primary = Color(0.784, 0.816, 0.878, 1)
|
||||
text_dim = Color(0.4, 0.467, 0.533, 1)
|
||||
accent_active = Color(0.941, 0.816, 0.376, 1)
|
||||
accent_positive = Color(0.267, 0.667, 0.4, 1)
|
||||
accent_negative = Color(0.667, 0.267, 0.267, 1)
|
||||
accent_warning = Color(0.667, 0.533, 0.267, 1)
|
||||
panel_bg = Color(0.05, 0.08, 0.14, 0.92)
|
||||
separator = Color(0.78, 0.82, 0.88, 0.12)
|
||||
hover_bg = Color(0.78, 0.82, 0.88, 0.08)
|
||||
line_height = 18
|
||||
sep_above = 6
|
||||
sep_below = 12
|
||||
padding = 12
|
||||
border_width = 1.0
|
||||
font_header = 15
|
||||
font_body = 11
|
||||
font_small = 10
|
||||
font_caption = 9
|
||||
@@ -0,0 +1,31 @@
|
||||
@tool
|
||||
class_name ImplantDataRow
|
||||
extends Label
|
||||
## Single-line data display — text with optional color override.
|
||||
|
||||
var _color_override: Color = Color.TRANSPARENT # transparent = use theme default
|
||||
|
||||
|
||||
func _init(text: String = "", color: Color = Color.TRANSPARENT) -> void:
|
||||
self.text = text
|
||||
_color_override = color
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
clip_text = true
|
||||
text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
size_flags_horizontal = Control.SIZE_FILL
|
||||
|
||||
|
||||
func set_content(text_value: String, color: Color = Color.TRANSPARENT) -> void:
|
||||
text = text_value
|
||||
_color_override = color
|
||||
if _color_override.a > 0.0:
|
||||
add_theme_color_override("font_color", _color_override)
|
||||
|
||||
|
||||
func apply_implant_theme(t: ImplantTheme) -> void:
|
||||
add_theme_font_size_override("font_size", t.font_body)
|
||||
if _color_override.a > 0.0:
|
||||
add_theme_color_override("font_color", _color_override)
|
||||
else:
|
||||
add_theme_color_override("font_color", t.text_dim)
|
||||
custom_minimum_size.y = t.line_height
|
||||
@@ -0,0 +1,40 @@
|
||||
@tool
|
||||
class_name ImplantHeader
|
||||
extends VBoxContainer
|
||||
## Panel header — title + optional subtitle.
|
||||
|
||||
var _title_label: Label
|
||||
var _subtitle_label: Label
|
||||
|
||||
|
||||
func _init(title: String = "", subtitle: String = "") -> void:
|
||||
_title_label = Label.new()
|
||||
_title_label.text = title
|
||||
_title_label.name = "Title"
|
||||
|
||||
_subtitle_label = Label.new()
|
||||
_subtitle_label.text = subtitle
|
||||
_subtitle_label.name = "Subtitle"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_theme_constant_override("separation", 0)
|
||||
size_flags_horizontal = Control.SIZE_FILL
|
||||
_title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_subtitle_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(_title_label)
|
||||
add_child(_subtitle_label)
|
||||
|
||||
|
||||
func set_content(title: String, subtitle: String = "") -> void:
|
||||
_title_label.text = title
|
||||
_subtitle_label.text = subtitle
|
||||
_subtitle_label.visible = not subtitle.is_empty()
|
||||
|
||||
|
||||
func apply_implant_theme(t: ImplantTheme) -> void:
|
||||
_title_label.add_theme_font_size_override("font_size", t.font_header)
|
||||
_title_label.add_theme_color_override("font_color", t.text_primary)
|
||||
|
||||
_subtitle_label.add_theme_font_size_override("font_size", t.font_small)
|
||||
_subtitle_label.add_theme_color_override("font_color", t.text_dim)
|
||||
@@ -0,0 +1,78 @@
|
||||
@tool
|
||||
class_name ImplantPanel
|
||||
extends PanelContainer
|
||||
## Root container for any implant UI overlay (D-169).
|
||||
## Add ImplantHeader, ImplantSeparator, ImplantDataRow, ImplantTextBlock
|
||||
## as children — they auto-style from the shared theme.
|
||||
|
||||
@export var theme_resource: ImplantTheme:
|
||||
set(value):
|
||||
theme_resource = value
|
||||
_apply_theme()
|
||||
|
||||
var _vbox: VBoxContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Constrain panel to its custom_minimum_size width
|
||||
size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||
clip_contents = true
|
||||
|
||||
_vbox = VBoxContainer.new()
|
||||
_vbox.name = "Content"
|
||||
add_child(_vbox)
|
||||
_apply_theme()
|
||||
|
||||
|
||||
func _apply_theme() -> void:
|
||||
if not theme_resource:
|
||||
return
|
||||
|
||||
# Panel background
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = theme_resource.panel_bg
|
||||
style.border_color = theme_resource.separator
|
||||
style.border_width_top = int(theme_resource.border_width)
|
||||
style.border_width_bottom = int(theme_resource.border_width)
|
||||
style.border_width_left = int(theme_resource.border_width)
|
||||
style.border_width_right = int(theme_resource.border_width)
|
||||
style.content_margin_top = theme_resource.padding
|
||||
style.content_margin_bottom = theme_resource.padding
|
||||
style.content_margin_left = theme_resource.padding
|
||||
style.content_margin_right = theme_resource.padding
|
||||
add_theme_stylebox_override("panel", style)
|
||||
|
||||
# VBox spacing
|
||||
if _vbox:
|
||||
_vbox.add_theme_constant_override("separation", 0)
|
||||
|
||||
# Propagate theme to existing children
|
||||
for child in get_implant_children():
|
||||
if child.has_method("apply_implant_theme"):
|
||||
child.apply_implant_theme(theme_resource)
|
||||
|
||||
|
||||
## Add an implant component to the panel.
|
||||
func add_component(component: Control) -> void:
|
||||
if not _vbox:
|
||||
_vbox = VBoxContainer.new()
|
||||
_vbox.name = "Content"
|
||||
add_child(_vbox)
|
||||
_vbox.add_child(component)
|
||||
if theme_resource and component.has_method("apply_implant_theme"):
|
||||
component.apply_implant_theme(theme_resource)
|
||||
|
||||
|
||||
## Get all implant component children.
|
||||
func get_implant_children() -> Array:
|
||||
if not _vbox:
|
||||
return []
|
||||
return _vbox.get_children()
|
||||
|
||||
|
||||
## Clear all components.
|
||||
func clear() -> void:
|
||||
if not _vbox:
|
||||
return
|
||||
for child in _vbox.get_children():
|
||||
child.queue_free()
|
||||
@@ -0,0 +1,23 @@
|
||||
@tool
|
||||
class_name ImplantSeparator
|
||||
extends Control
|
||||
## Thin horizontal separator line with themed spacing.
|
||||
|
||||
var _theme: ImplantTheme
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func apply_implant_theme(t: ImplantTheme) -> void:
|
||||
_theme = t
|
||||
custom_minimum_size.y = t.sep_above + t.sep_below
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _theme:
|
||||
return
|
||||
var y: float = _theme.sep_above
|
||||
draw_line(Vector2(0, y), Vector2(size.x, y), _theme.separator, 1.0)
|
||||
@@ -0,0 +1,30 @@
|
||||
@tool
|
||||
class_name ImplantTextBlock
|
||||
extends RichTextLabel
|
||||
## Multi-line wrapping text block for narrative content (GTTR excerpts, etc.).
|
||||
|
||||
var _max_lines: int = -1
|
||||
var _text_alpha: float = 0.75
|
||||
|
||||
|
||||
func _init(content: String = "", max_lines: int = -1) -> void:
|
||||
_max_lines = max_lines
|
||||
text = content.replace("**", "") # strip markdown bold
|
||||
bbcode_enabled = false
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
size_flags_horizontal = Control.SIZE_FILL
|
||||
|
||||
|
||||
func set_content(content: String, max_lines: int = -1) -> void:
|
||||
text = content.replace("**", "") # strip markdown bold
|
||||
_max_lines = max_lines
|
||||
|
||||
|
||||
func apply_implant_theme(t: ImplantTheme) -> void:
|
||||
add_theme_font_size_override("normal_font_size", t.font_small)
|
||||
add_theme_color_override(
|
||||
"default_color", Color(t.text_primary.r, t.text_primary.g, t.text_primary.b, _text_alpha)
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
class_name ImplantTheme
|
||||
extends Resource
|
||||
## Shared styling resource for all implant UI panels (D-169).
|
||||
## Swap this resource at runtime to change the implant's visual identity
|
||||
## (hardware variants, upgrades, faction overlays).
|
||||
|
||||
# ── Colors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
## Primary readable text
|
||||
@export var text_primary: Color = Color("#c8d0e0")
|
||||
## Labels, metadata, captions
|
||||
@export var text_dim: Color = Color("#667788")
|
||||
## Selected, active, interactive highlight
|
||||
@export var accent_active: Color = Color("#f0d060")
|
||||
## Good state, opportunity, profit
|
||||
@export var accent_positive: Color = Color("#44aa66")
|
||||
## Danger, loss, hostile
|
||||
@export var accent_negative: Color = Color("#aa4444")
|
||||
## Caution, degraded, unknown
|
||||
@export var accent_warning: Color = Color("#aa8844")
|
||||
## Panel background
|
||||
@export var panel_bg: Color = Color(0.05, 0.08, 0.14, 0.92)
|
||||
## Panel border and separator lines
|
||||
@export var separator: Color = Color(0.78, 0.82, 0.88, 0.12)
|
||||
## Hover state background
|
||||
@export var hover_bg: Color = Color(0.78, 0.82, 0.88, 0.08)
|
||||
|
||||
# ── Spacing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
## Line height for single-line labels (baseline to baseline)
|
||||
@export var line_height: int = 18
|
||||
## Space from last text baseline to separator line
|
||||
@export var sep_above: int = 6
|
||||
## Space from separator line to next text baseline
|
||||
@export var sep_below: int = 12
|
||||
## Panel internal padding
|
||||
@export var padding: int = 12
|
||||
## Border width
|
||||
@export var border_width: float = 1.0
|
||||
|
||||
# ── Font sizes ───────────────────────────────────────────────────────────────
|
||||
|
||||
@export var font_header: int = 15
|
||||
@export var font_body: int = 11
|
||||
@export var font_small: int = 10
|
||||
@export var font_caption: int = 9
|
||||
@@ -42,6 +42,7 @@ func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
@@ -113,14 +114,21 @@ func _rebuild_labels() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = " %s " % verb.get("label", "")
|
||||
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.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.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))
|
||||
@@ -155,7 +163,9 @@ func _update_screen_position() -> void:
|
||||
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 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
|
||||
@@ -184,17 +194,19 @@ func _hide() -> void:
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
# Guard: tween callback from previous _hide() may fire after labels already freed
|
||||
_active_tween.tween_callback(func():
|
||||
visible = false
|
||||
for lbl in _verb_labels:
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_verb_labels.clear()
|
||||
_active_tween.tween_callback(
|
||||
func():
|
||||
visible = false
|
||||
for lbl in _verb_labels:
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_verb_labels.clear()
|
||||
)
|
||||
|
||||
|
||||
# -- Lazy sync for getters (tests may set GameState without calling update) --
|
||||
|
||||
|
||||
func _ensure_synced() -> void:
|
||||
if _current_target_id == -1 and not GameState.nearby_interactions.is_empty():
|
||||
update_from_state()
|
||||
@@ -202,6 +214,7 @@ func _ensure_synced() -> void:
|
||||
|
||||
# -- Public API (QA test contract) ------------------------------------------
|
||||
|
||||
|
||||
func get_visible_verb_count() -> int:
|
||||
_ensure_synced()
|
||||
return _verb_items.size()
|
||||
@@ -254,7 +267,8 @@ 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)
|
||||
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM
|
||||
)
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
extends PanelContainer
|
||||
extends Control
|
||||
|
||||
# Interaction prompt — displays available interaction verb for nearby entity.
|
||||
# Server-driven: shows when GameState.nearby_interactions is non-empty,
|
||||
@@ -17,12 +17,29 @@ var _current_target_id: int = -1
|
||||
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
|
||||
var _insert_active: bool = true
|
||||
|
||||
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||
var _panel: ImplantPanel
|
||||
var _row: ImplantDataRow
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
modulate.a = 0.0
|
||||
visible = false
|
||||
_is_showing = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
var theme_res := load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "PromptPanel"
|
||||
_panel.theme_resource = theme_res
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(_panel)
|
||||
|
||||
_row = ImplantDataRow.new("", theme_res.text_primary if theme_res else Color.WHITE)
|
||||
_row.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_panel.add_component(_row)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed)
|
||||
@@ -36,6 +53,7 @@ func _process(_delta: float) -> void:
|
||||
elif _is_showing:
|
||||
_hide_prompt()
|
||||
|
||||
|
||||
func _show_prompt(interaction: Dictionary) -> void:
|
||||
var target_id: int = interaction.get("entity_id", -1)
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
@@ -49,8 +67,8 @@ func _show_prompt(interaction: Dictionary) -> void:
|
||||
var verb_label: String = verbs[0].get("label", "")
|
||||
var display_text: String = "E - %s" % verb_label
|
||||
|
||||
if _current_target_id != target_id or prompt_label.text != display_text:
|
||||
prompt_label.text = display_text
|
||||
if _current_target_id != target_id or _row.text != display_text:
|
||||
_row.set_content(display_text)
|
||||
_current_target_id = target_id
|
||||
|
||||
if not _is_showing:
|
||||
@@ -61,6 +79,7 @@ func _show_prompt(interaction: Dictionary) -> void:
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _hide_prompt() -> void:
|
||||
if not _is_showing:
|
||||
return
|
||||
@@ -72,10 +91,12 @@ func _hide_prompt() -> void:
|
||||
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
_active_tween.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
## Returns the current interaction target entity ID, or -1 if no interaction.
|
||||
func get_interaction_target() -> int:
|
||||
return _current_target_id
|
||||
|
||||
|
||||
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
|
||||
## Cursor shape changes still fire on cursor_renderer.gd.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
|
||||
@@ -2,27 +2,17 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/interaction_prompt.gd" id="1_prompt"]
|
||||
|
||||
[node name="InteractionPrompt" type="PanelContainer"]
|
||||
; Interaction prompt — implant-styled (D-169), positioned above DialogueBox, 16px gap
|
||||
[node name="InteractionPrompt" type="Control"]
|
||||
anchors_preset = 7
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 200.0
|
||||
offset_top = -60.0
|
||||
offset_top = -264.0
|
||||
offset_right = -200.0
|
||||
offset_bottom = -222.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_prompt")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 8
|
||||
|
||||
[node name="PromptLabel" type="Label" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
horizontal_alignment = 1
|
||||
text = ""
|
||||
|
||||
@@ -67,10 +67,7 @@ func _draw() -> void:
|
||||
|
||||
var col: int = slot_idx % GRID_COLS
|
||||
var row: int = slot_idx / GRID_COLS
|
||||
var pos := Vector2(
|
||||
col * (SLOT_SIZE + SLOT_GAP),
|
||||
row * (SLOT_SIZE + SLOT_GAP)
|
||||
)
|
||||
var pos := Vector2(col * (SLOT_SIZE + SLOT_GAP), row * (SLOT_SIZE + SLOT_GAP))
|
||||
var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE))
|
||||
|
||||
# Slot background
|
||||
@@ -89,13 +86,22 @@ func _draw() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := 11
|
||||
var text_size := font.get_string_size(item_name, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
|
||||
var text_pos := pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
|
||||
var text_pos := (
|
||||
pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
|
||||
)
|
||||
draw_string(font, text_pos, item_name, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, SLOT_TEXT)
|
||||
|
||||
# Hotkey number (top-left corner)
|
||||
var hotkey := str(slot_idx + 1)
|
||||
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
|
||||
draw_string(
|
||||
font,
|
||||
pos + Vector2(3, HOTKEY_SIZE + 2),
|
||||
hotkey,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
HOTKEY_SIZE,
|
||||
SLOT_TEXT_DIM
|
||||
)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
@@ -125,6 +131,7 @@ func _select_slot(slot_idx: int) -> void:
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_slot_count() -> int:
|
||||
return _slots.size()
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ var _last_rendered_tick: int = -1
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
||||
@onready var entries_container: VBoxContainer = \
|
||||
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
@onready
|
||||
var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
@@ -118,12 +119,16 @@ func _add_entity_entry(entity: Dictionary) -> void:
|
||||
header_rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
|
||||
var rel_color: Color = Constants.color_for_relationship(relationship)
|
||||
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
|
||||
var rel_label: String = UIStrings.get_text(
|
||||
"relationship_states.%s.label" % relationship.to_lower()
|
||||
)
|
||||
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
|
||||
rel_label = relationship # fallback if key missing
|
||||
|
||||
var state_color: Color = _state_color(state)
|
||||
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
|
||||
var conf_label: String = UIStrings.get_text(
|
||||
"knowledge_panel.confidence_%s" % confidence.to_lower()
|
||||
)
|
||||
var src_label: String = _resolve_source_label(source)
|
||||
|
||||
# Build BBCode:
|
||||
@@ -133,13 +138,15 @@ func _add_entity_entry(entity: Dictionary) -> void:
|
||||
|
||||
var header_bbcode: String
|
||||
if state == "Contradicted":
|
||||
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_bbcode = (
|
||||
"[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]"
|
||||
% [name_hex, name_str, rel_hex, rel_label]
|
||||
)
|
||||
else:
|
||||
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_bbcode = (
|
||||
"[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]"
|
||||
% [name_hex, name_str, rel_hex, rel_label]
|
||||
)
|
||||
header_rtl.text = header_bbcode
|
||||
entries_container.add_child(header_rtl)
|
||||
|
||||
@@ -198,9 +205,9 @@ func _entity_name_for_id(entity_id: int) -> String:
|
||||
func _state_color(state: String) -> Color:
|
||||
match state:
|
||||
"Contradicted":
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
"Stale":
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # active — normal
|
||||
|
||||
|
||||
@@ -156,8 +156,15 @@ static func _format_save_entry(save: Dictionary) -> String:
|
||||
if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6:
|
||||
var d := parts[0]
|
||||
var t := parts[1]
|
||||
return "%s-%s-%s %s:%s:%s" % [
|
||||
d.substr(0, 4), d.substr(4, 2), d.substr(6, 2),
|
||||
t.substr(0, 2), t.substr(2, 2), t.substr(4, 2),
|
||||
]
|
||||
return (
|
||||
"%s-%s-%s %s:%s:%s"
|
||||
% [
|
||||
d.substr(0, 4),
|
||||
d.substr(4, 2),
|
||||
d.substr(6, 2),
|
||||
t.substr(0, 2),
|
||||
t.substr(2, 2),
|
||||
t.substr(4, 2),
|
||||
]
|
||||
)
|
||||
return game_id
|
||||
|
||||
@@ -29,28 +29,67 @@ const CARDINAL_TICK_LEN: float = 4.0
|
||||
|
||||
# Colors — insert palette from Constants, tuned for the circular minimap frame
|
||||
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
|
||||
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
|
||||
|
||||
var _insert_active: bool = true
|
||||
# D-169: StyleBoxFlat matching ImplantPanel aesthetic — drawn as container frame behind the circle.
|
||||
var _container_style: StyleBoxFlat = null
|
||||
|
||||
# Dirty flag — only queue_redraw() when player position or POI list changes.
|
||||
var _dirty: bool = true
|
||||
var _last_player_pos: Vector2 = Vector2(INF, INF)
|
||||
var _last_poi_count: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(true)
|
||||
_init_container_style()
|
||||
|
||||
|
||||
func _init_container_style() -> void:
|
||||
var t := load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
if not t:
|
||||
return
|
||||
_container_style = StyleBoxFlat.new()
|
||||
_container_style.bg_color = t.panel_bg
|
||||
_container_style.border_color = t.separator
|
||||
_container_style.set_border_width_all(int(t.border_width))
|
||||
_container_style.set_content_margin_all(0)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _insert_active:
|
||||
if not _insert_active:
|
||||
return
|
||||
var pos := GameState.player_position
|
||||
var poi_count := GameState.discovered_pois.size()
|
||||
if pos != _last_player_pos or poi_count != _last_poi_count:
|
||||
_last_player_pos = pos
|
||||
_last_poi_count = poi_count
|
||||
_dirty = true
|
||||
if _dirty:
|
||||
_dirty = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Called from main.gd when GameState.insert_active changes.
|
||||
## Hides the minimap overlay when the neural insert is inactive.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
visible = active
|
||||
if active:
|
||||
_dirty = true
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# D-169: ImplantPanel-style container frame behind the circular minimap.
|
||||
# Gives the minimap the same panel border aesthetic as other implant components.
|
||||
if _container_style:
|
||||
_container_style.draw(get_canvas_item(), Rect2(Vector2.ZERO, get_rect().size))
|
||||
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center := sz / 2.0
|
||||
# Outer radius: fill control with 1px edge padding
|
||||
@@ -68,8 +107,16 @@ func _draw() -> void:
|
||||
# --- Player dot at center ---
|
||||
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
|
||||
# Soft bloom ring
|
||||
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
|
||||
draw_arc(
|
||||
center,
|
||||
PLAYER_DOT_RADIUS + 1.5,
|
||||
0.0,
|
||||
TAU,
|
||||
32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22),
|
||||
1.0,
|
||||
true
|
||||
)
|
||||
|
||||
# --- POIs ---
|
||||
var pois: Array = GameState.discovered_pois
|
||||
@@ -118,7 +165,9 @@ func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
draw_line(
|
||||
center + n_dir * (outer_r - NORTH_TICK_LEN),
|
||||
center + n_dir * outer_r,
|
||||
COLOR_NORTH, FRAME_WIDTH + 0.5, true
|
||||
COLOR_NORTH,
|
||||
FRAME_WIDTH + 0.5,
|
||||
true
|
||||
)
|
||||
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
|
||||
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
|
||||
@@ -126,7 +175,9 @@ func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
draw_line(
|
||||
center + dir * (outer_r - CARDINAL_TICK_LEN),
|
||||
center + dir * outer_r,
|
||||
COLOR_CARDINAL, FRAME_WIDTH, true
|
||||
COLOR_CARDINAL,
|
||||
FRAME_WIDTH,
|
||||
true
|
||||
)
|
||||
|
||||
|
||||
@@ -136,10 +187,14 @@ func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
|
||||
# Diamond for danger
|
||||
var s: float = POI_DOT_RADIUS + 1.0
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
|
||||
]),
|
||||
PackedVector2Array(
|
||||
[
|
||||
pos + Vector2(0.0, -s),
|
||||
pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s),
|
||||
pos + Vector2(-s, 0.0)
|
||||
]
|
||||
),
|
||||
PackedColorArray([color, color, color, color])
|
||||
)
|
||||
"evidence", "note", "clue":
|
||||
@@ -163,10 +218,10 @@ func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
|
||||
func _category_color(category: String) -> Color:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
"evidence", "note", "clue":
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
"contact", "npc", "person":
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
|
||||
@@ -13,28 +13,30 @@ extends Control
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
const MAX_VISIBLE: int = 3
|
||||
const MAX_QUEUE: int = 5
|
||||
const MAX_QUEUE: int = 5
|
||||
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
|
||||
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented": { # detective
|
||||
"lattice_augmented":
|
||||
{ # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline": { # smuggler
|
||||
"lattice_baseline":
|
||||
{ # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
@@ -67,7 +69,9 @@ func _process(delta: float) -> void:
|
||||
if next.get("is_notification", false):
|
||||
_show_notification_line(next.text)
|
||||
else:
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
_show_line(
|
||||
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
|
||||
)
|
||||
|
||||
|
||||
# Display a monologue line.
|
||||
@@ -86,20 +90,30 @@ func show_notification(text: String) -> void:
|
||||
_show_notification_line(text)
|
||||
else:
|
||||
var entry := {
|
||||
text = text, duration = _NOTIFICATION_DURATION, priority = 1,
|
||||
is_urgent = false, lattice_profile = "", is_notification = true
|
||||
text = text,
|
||||
duration = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
is_urgent = false,
|
||||
lattice_profile = "",
|
||||
is_notification = true
|
||||
}
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append(entry)
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
else:
|
||||
var lowest := _lowest_priority_idx()
|
||||
if 1 >= _queue[lowest].priority:
|
||||
_queue[lowest] = entry
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
|
||||
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
func show_monologue(
|
||||
text: String, duration: float, priority: int = 2, is_urgent: bool = false
|
||||
) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var profile := GameState.lattice_profile
|
||||
@@ -114,11 +128,12 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _show_notification_line(text: String) -> void:
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
@@ -130,10 +145,10 @@ func _show_notification_line(text: String) -> void:
|
||||
container.add_child(label)
|
||||
_vbox.add_child(container)
|
||||
var slot := {
|
||||
node = container,
|
||||
node = container,
|
||||
expire_timer = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
tween = null,
|
||||
priority = 1,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
@@ -143,15 +158,17 @@ func _show_notification_line(text: String) -> void:
|
||||
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC)
|
||||
|
||||
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
func _show_line(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
var slot := {
|
||||
node = line_node,
|
||||
node = line_node,
|
||||
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
||||
priority = priority,
|
||||
tween = null,
|
||||
priority = priority,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
@@ -174,22 +191,36 @@ func _retire_slot(slot: Dictionary) -> void:
|
||||
tween.tween_callback(node.queue_free)
|
||||
|
||||
|
||||
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
func _enqueue(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append({
|
||||
text = text, duration = duration, priority = priority,
|
||||
is_urgent = is_urgent, lattice_profile = lattice_profile
|
||||
})
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.append(
|
||||
{
|
||||
text = text,
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
else:
|
||||
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
||||
var lowest := _lowest_priority_idx()
|
||||
if priority >= _queue[lowest].priority:
|
||||
_queue[lowest] = {
|
||||
text = text, duration = duration, priority = priority,
|
||||
is_urgent = is_urgent, lattice_profile = lattice_profile
|
||||
text = text,
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
# else: incoming line is strictly lower priority — silently drop; no sort needed
|
||||
|
||||
|
||||
@@ -203,13 +234,16 @@ func _lowest_priority_idx() -> int:
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
|
||||
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
|
||||
var color: Color = (
|
||||
palette.get("urgent", _FALLBACK_URGENT)
|
||||
if is_urgent
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
)
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
|
||||
var label := RichTextLabel.new()
|
||||
|
||||
@@ -8,33 +8,33 @@ extends Control
|
||||
|
||||
signal closed
|
||||
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
|
||||
const TITLE_COLOR := Color("#4a9ebb")
|
||||
const STATUS_GREEN := Color("#6bc9a6")
|
||||
const STATUS_GREEN := Color("#6bc9a6")
|
||||
const STATUS_YELLOW := Color("#e8c547")
|
||||
const STATUS_RED := Color("#c84040")
|
||||
const STATUS_RED := Color("#c84040")
|
||||
const FONT_SIZE := 14
|
||||
const FONT_SIZE_SMALL := 11
|
||||
|
||||
## Shared client preferences file — avoids compile-time dependency on DebugConsole for path.
|
||||
const SETTINGS_CFG_PATH := "user://settings.cfg"
|
||||
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_HEIGHT := 500 # +36 debug console, +88 AI dialogue section
|
||||
const PADDING := 20
|
||||
const PADDING := 20
|
||||
const ROW_HEIGHT := 36
|
||||
|
||||
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
|
||||
const BUS_ROWS: Array = [
|
||||
["Music", "Music"],
|
||||
["Ambient", "Ambient"],
|
||||
["World SFX", "WorldSFX"],
|
||||
["Music", "Music"],
|
||||
["Ambient", "Ambient"],
|
||||
["World SFX", "WorldSFX"],
|
||||
["Player Actions", "PlayerActions"],
|
||||
["UI Sounds", "UISounds"],
|
||||
["UI Sounds", "UISounds"],
|
||||
]
|
||||
|
||||
var _active: bool = false
|
||||
@@ -77,10 +77,7 @@ func is_open() -> bool:
|
||||
|
||||
func _build_ui() -> void:
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_pos := Vector2(
|
||||
(vp_size.x - BOX_WIDTH) / 2.0,
|
||||
(vp_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_pos := Vector2((vp_size.x - BOX_WIDTH) / 2.0, (vp_size.y - BOX_HEIGHT) / 2.0)
|
||||
|
||||
_container = VBoxContainer.new()
|
||||
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
|
||||
@@ -121,9 +118,10 @@ func _build_ui() -> void:
|
||||
db_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
hbox.add_child(db_label)
|
||||
|
||||
slider.value_changed.connect(func(value: float) -> void:
|
||||
AudioManager.set_volume(bus_name, value)
|
||||
db_label.text = _format_db(value)
|
||||
slider.value_changed.connect(
|
||||
func(value: float) -> void:
|
||||
AudioManager.set_volume(bus_name, value)
|
||||
db_label.text = _format_db(value)
|
||||
)
|
||||
|
||||
# #581: Debug Console toggle
|
||||
@@ -148,10 +146,10 @@ func _build_ui() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
debug_check.button_pressed = true
|
||||
if cfg.load(DebugConsole.PREFS_PATH) == OK:
|
||||
debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true)
|
||||
debug_check.toggled.connect(func(enabled: bool) -> void:
|
||||
debug_console_toggled.emit(enabled)
|
||||
)
|
||||
debug_check.button_pressed = cfg.get_value(
|
||||
DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true
|
||||
)
|
||||
debug_check.toggled.connect(func(enabled: bool) -> void: debug_console_toggled.emit(enabled))
|
||||
debug_hbox.add_child(debug_check)
|
||||
|
||||
# #646: AI Dialogue section divider
|
||||
@@ -223,7 +221,8 @@ func _build_ui() -> void:
|
||||
|
||||
# Status message label — only shown when non-empty
|
||||
var status_msg: String = HardwareDetector.status_message(
|
||||
ram_class, tpt_class, ram_result["free_mb"], tpt_tps)
|
||||
ram_class, tpt_class, ram_result["free_mb"], tpt_tps
|
||||
)
|
||||
var ai_status_label := Label.new()
|
||||
ai_status_label.text = status_msg
|
||||
ai_status_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL)
|
||||
@@ -247,16 +246,22 @@ func _build_ui() -> void:
|
||||
_ai_battery_warning_label.visible = _ai_inference_suspended
|
||||
_container.add_child(_ai_battery_warning_label)
|
||||
|
||||
_ai_check_node.toggled.connect(func(enabled: bool) -> void:
|
||||
GameState.ai_enhanced_dialogue_enabled = enabled
|
||||
_save_ai_pref(enabled)
|
||||
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
ai_dialogue_toggled.emit(enabled)
|
||||
_ai_check_node.toggled.connect(
|
||||
func(enabled: bool) -> void:
|
||||
GameState.ai_enhanced_dialogue_enabled = enabled
|
||||
_save_ai_pref(enabled)
|
||||
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
ai_dialogue_toggled.emit(enabled)
|
||||
)
|
||||
|
||||
# Spacer
|
||||
@@ -284,12 +289,13 @@ func _destroy_ui() -> void:
|
||||
if _container:
|
||||
_container.queue_free()
|
||||
_container = null
|
||||
_ai_check_node = null # freed with _container
|
||||
_ai_check_node = null # freed with _container
|
||||
_ai_battery_warning_label = null
|
||||
|
||||
|
||||
# -- #646: AI Dialogue testable API -------------------------------------------
|
||||
|
||||
|
||||
## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138).
|
||||
## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support.
|
||||
func get_ai_dialogue_label_text() -> String:
|
||||
@@ -354,8 +360,7 @@ func _draw() -> void:
|
||||
|
||||
# Dialog box
|
||||
var box_pos := Vector2(
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0,
|
||||
(viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
||||
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
||||
@@ -363,10 +368,15 @@ func _draw() -> void:
|
||||
|
||||
# Title
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
box_pos + Vector2(PADDING, PADDING + 18),
|
||||
"SETTINGS",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE + 2,
|
||||
TITLE_COLOR
|
||||
)
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
|
||||
@@ -1,51 +1,102 @@
|
||||
extends Control
|
||||
|
||||
## D-053: Stance indicator — shows current movement stance on HUD.
|
||||
## Sprint/Walk/Careful/Crouch. Color-coded for quick read.
|
||||
## Lives on UILayer (z-layer 7).
|
||||
## D-053: Stance indicator — implant-styled badge (D-169).
|
||||
## Sprint/Walk/Careful/Crouch. Icon (D-086) + color-coded label.
|
||||
|
||||
const STANCE_COLORS := {
|
||||
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
|
||||
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
|
||||
"Careful": Color("#6bc9a6"), # Green — quiet, observant
|
||||
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
|
||||
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
|
||||
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
|
||||
"Careful": Color("#6bc9a6"), # Green — quiet, observant
|
||||
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
|
||||
}
|
||||
|
||||
const STANCE_DEFAULT_COLOR := Color("#c8d0e0")
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
|
||||
const FONT_SIZE := 13
|
||||
const PADDING := Vector2(10, 6)
|
||||
const STANCE_ICONS := {
|
||||
"Sprint": "res://assets/icons/icon_stance_sprint.svg",
|
||||
"Walk": "res://assets/icons/icon_stance_walk.svg",
|
||||
"Careful": "res://assets/icons/icon_stance_careful.svg",
|
||||
"Crouch": "res://assets/icons/icon_stance_crouch.svg",
|
||||
}
|
||||
|
||||
var _current_stance: String = "Walk"
|
||||
var _panel: ImplantPanel
|
||||
var _icon: TextureRect
|
||||
var _label: Label
|
||||
var _icon_mat: ShaderMaterial
|
||||
var _icon_cache: Dictionary = {} # stance name → Texture2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var theme_res := load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
var icon_shader := load("res://shaders/icon_tint.gdshader") as Shader
|
||||
if not theme_res:
|
||||
push_error("StanceIndicator: failed to load implant theme")
|
||||
return
|
||||
if not icon_shader:
|
||||
push_error("StanceIndicator: failed to load icon_tint shader")
|
||||
return
|
||||
|
||||
# Preload all stance icons at startup
|
||||
for stance_name in STANCE_ICONS:
|
||||
var tex := load(STANCE_ICONS[stance_name]) as Texture2D
|
||||
if tex:
|
||||
_icon_cache[stance_name] = tex
|
||||
else:
|
||||
push_error("StanceIndicator: failed to load icon for %s" % stance_name)
|
||||
|
||||
_panel = ImplantPanel.new()
|
||||
_panel.name = "StancePanel"
|
||||
_panel.theme_resource = theme_res
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_panel)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 4)
|
||||
hbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
_icon = TextureRect.new()
|
||||
_icon.expand_mode = TextureRect.EXPAND_KEEP_SIZE
|
||||
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_icon.custom_minimum_size = Vector2(20.0, 20.0) # D-086: 20×20 viewBox
|
||||
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_icon_mat = ShaderMaterial.new()
|
||||
_icon_mat.shader = icon_shader
|
||||
_icon_mat.set_shader_parameter("tint_color", STANCE_COLORS["Walk"])
|
||||
_icon.material = _icon_mat
|
||||
hbox.add_child(_icon)
|
||||
|
||||
_label = Label.new()
|
||||
_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_label.add_theme_font_size_override("font_size", theme_res.font_body)
|
||||
hbox.add_child(_label)
|
||||
|
||||
_panel.add_component(hbox)
|
||||
_apply_stance("Walk")
|
||||
|
||||
|
||||
func _apply_stance(stance: String) -> void:
|
||||
_current_stance = stance
|
||||
var color: Color = STANCE_COLORS.get(stance, Color("#c8d0e0"))
|
||||
|
||||
if _icon:
|
||||
var tex: Texture2D = _icon_cache.get(stance)
|
||||
if tex:
|
||||
_icon.texture = tex
|
||||
if _icon_mat:
|
||||
_icon_mat.set_shader_parameter("tint_color", color)
|
||||
|
||||
if _label:
|
||||
_label.text = stance
|
||||
_label.add_theme_color_override("font_color", color)
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
var stance: String = GameState.player_stance
|
||||
if stance == _current_stance:
|
||||
return
|
||||
_current_stance = stance
|
||||
queue_redraw()
|
||||
_apply_stance(stance)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
var text := _current_stance
|
||||
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
|
||||
var box_size := text_size + PADDING * 2
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
|
||||
|
||||
# Stance text
|
||||
var color: Color = STANCE_COLORS.get(_current_stance, STANCE_DEFAULT_COLOR)
|
||||
draw_string(font, PADDING + Vector2(0, text_size.y), text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
func get_current_stance() -> String:
|
||||
return _current_stance
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/stance_indicator.gd" id="1_stance"]
|
||||
|
||||
; D-053: Stance indicator — top-right HUD element
|
||||
; D-053: Stance indicator — below minimap (bottom at y:176), 24px gap.
|
||||
; Control height 46px: border(1) + padding(12) + icon(20) + padding(12) + border(1).
|
||||
[node name="StanceIndicator" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -100.0
|
||||
offset_top = 16.0
|
||||
offset_top = 200.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 42.0
|
||||
offset_bottom = 246.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_stance")
|
||||
|
||||
@@ -16,9 +16,9 @@ const DATA_PATH := "res://data/star_map_data.json"
|
||||
|
||||
# Layout
|
||||
const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control
|
||||
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
|
||||
const RING_SPACING: float = 22.0 # pixels between hop rings
|
||||
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
|
||||
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
|
||||
const RING_SPACING: float = 22.0 # pixels between hop rings
|
||||
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
|
||||
|
||||
# Dot sizing
|
||||
const DOT_RADIUS_HUB: float = 4.5
|
||||
@@ -35,12 +35,9 @@ const HIT_RADIUS: float = 10.0 # click tolerance
|
||||
const EDGE_WIDTH: float = 0.8
|
||||
const EDGE_SELECTED_ALPHA: float = 0.55
|
||||
|
||||
# Info popup — expanded with wiki/GTTR content (#780)
|
||||
# Info popup — uses ImplantPanel component library (D-169)
|
||||
const POPUP_WIDTH: float = 300.0
|
||||
const POPUP_MARGIN: float = 16.0
|
||||
const POPUP_PADDING: float = 12.0
|
||||
const POPUP_LINE_H: float = 17.0
|
||||
const POPUP_GTTR_FONT_SIZE: int = 10
|
||||
const POPUP_GTTR_MAX_LINES: int = 7
|
||||
|
||||
# Colors — sector palette from wireframe
|
||||
@@ -51,7 +48,6 @@ const COLOR_GATEWAY: Color = Color("#f0d060")
|
||||
const COLOR_SELECTION: Color = Color("#f0d060")
|
||||
const COLOR_TEXT: Color = Color("#c8d0e0")
|
||||
const COLOR_TEXT_DIM: Color = Color("#667788")
|
||||
const COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92)
|
||||
|
||||
const SECTOR_COLORS: Dictionary = {
|
||||
"core": Color("#c8d0e0"),
|
||||
@@ -79,8 +75,8 @@ const SECTOR_ANGLE_CENTER: Dictionary = {
|
||||
"west_reach": PI,
|
||||
}
|
||||
const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc
|
||||
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
|
||||
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
|
||||
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
|
||||
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
|
||||
|
||||
# Pan/zoom
|
||||
const ZOOM_MIN: float = 0.3
|
||||
@@ -91,7 +87,7 @@ const ZOOM_STEP: float = 0.15
|
||||
var _nodes: Array = []
|
||||
var _edges: Array = []
|
||||
var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center)
|
||||
var _node_lookup: Dictionary = {} # system_id -> node dict
|
||||
var _node_lookup: Dictionary = {} # system_id -> node dict
|
||||
var _selected_system: String = ""
|
||||
var _hovered_system: String = ""
|
||||
|
||||
@@ -103,11 +99,29 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
var _data_loaded: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
var _info_panel: ImplantPanel # D-169: component-based info panel
|
||||
var _implant_theme: ImplantTheme
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
# D-169: Load implant theme and create info panel
|
||||
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
|
||||
_info_panel = ImplantPanel.new()
|
||||
_info_panel.name = "InfoPanel"
|
||||
_info_panel.theme_resource = _implant_theme
|
||||
_info_panel.custom_minimum_size.x = POPUP_WIDTH
|
||||
_info_panel.size.x = POPUP_WIDTH
|
||||
_info_panel.visible = false
|
||||
_info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_info_panel)
|
||||
|
||||
# D-170: Register with HUD layer groups
|
||||
HudGroups.register(self, "implant/map/starchart")
|
||||
HudGroups.app_changed.connect(_on_app_changed)
|
||||
|
||||
_load_data()
|
||||
if _data_loaded:
|
||||
_compute_layout()
|
||||
@@ -122,19 +136,27 @@ func _process(_delta: float) -> void:
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes.
|
||||
## Only force-hides when insert is inactive. Does NOT auto-show — star map is
|
||||
## modal (player opens via toggle_visible()), not always-on like the minimap.
|
||||
## Called from main.gd when insert state changes.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active:
|
||||
visible = false
|
||||
if not active and HudGroups.is_app_active("implant/map/starchart"):
|
||||
HudGroups.close_app()
|
||||
|
||||
|
||||
## Toggle visibility (e.g., from a keybind or button).
|
||||
## Toggle via HUD layer system (D-170).
|
||||
func toggle_visible() -> void:
|
||||
visible = not visible
|
||||
if visible:
|
||||
HudGroups.toggle_app("implant/map/starchart")
|
||||
|
||||
|
||||
## Respond to app layer changes (D-170).
|
||||
func _on_app_changed(app_path: String, mode: int) -> void:
|
||||
if app_path != "implant/map/starchart":
|
||||
return
|
||||
if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT:
|
||||
visible = true
|
||||
_dirty = true
|
||||
else:
|
||||
visible = false
|
||||
|
||||
|
||||
## Return the currently selected system data, or empty dict.
|
||||
@@ -156,6 +178,7 @@ func get_edge_count() -> int:
|
||||
# Data loading
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_data() -> void:
|
||||
if not FileAccess.file_exists(DATA_PATH):
|
||||
push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH)
|
||||
@@ -181,6 +204,7 @@ func _load_data() -> void:
|
||||
# Layout — place systems on concentric rings by hop distance
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _compute_layout() -> void:
|
||||
_node_positions.clear()
|
||||
|
||||
@@ -246,7 +270,9 @@ func _compute_layout() -> void:
|
||||
var jitter: float = _system_hash(node["system_id"]) * 0.08
|
||||
angle += jitter
|
||||
# Slight radial variation to avoid perfect circles
|
||||
var r_var: float = radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
|
||||
var r_var: float = (
|
||||
radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
|
||||
)
|
||||
_node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var
|
||||
|
||||
|
||||
@@ -261,13 +287,20 @@ func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool:
|
||||
func _sector_sort_key(node: Dictionary) -> float:
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
match sector:
|
||||
"core": return 0.0
|
||||
"north_reach": return 1.0
|
||||
"east_reach": return 2.0
|
||||
"south_reach": return 3.0
|
||||
"west_reach": return 4.0
|
||||
"deep_frontier": return 5.0
|
||||
_: return 6.0 # gdlint:ignore = max-returns
|
||||
"core":
|
||||
return 0.0
|
||||
"north_reach":
|
||||
return 1.0
|
||||
"east_reach":
|
||||
return 2.0
|
||||
"south_reach":
|
||||
return 3.0
|
||||
"west_reach":
|
||||
return 4.0
|
||||
"deep_frontier":
|
||||
return 5.0
|
||||
_:
|
||||
return 6.0 # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
## Deterministic float in [-1, 1] from a string key.
|
||||
@@ -280,6 +313,7 @@ func _system_hash(key: String) -> float:
|
||||
# Drawing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _data_loaded:
|
||||
return
|
||||
@@ -307,9 +341,21 @@ func _draw() -> void:
|
||||
if _selected_system != "":
|
||||
_draw_selection(center)
|
||||
|
||||
# Info panel for selected system
|
||||
if _selected_system != "":
|
||||
_draw_info_panel(sz)
|
||||
# Info panel positioned in top-right, clamped to screen (D-169)
|
||||
if _info_panel:
|
||||
_info_panel.visible = _selected_system != ""
|
||||
if _info_panel.visible:
|
||||
# Force layout so size.y is accurate for clamping
|
||||
_info_panel.reset_size()
|
||||
var px: float = sz.x - POPUP_WIDTH - POPUP_MARGIN
|
||||
var py: float = POPUP_MARGIN
|
||||
# Clamp to keep panel fully on screen
|
||||
var panel_h: float = _info_panel.size.y
|
||||
if panel_h > 0.0 and py + panel_h > sz.y - POPUP_MARGIN:
|
||||
py = sz.y - panel_h - POPUP_MARGIN
|
||||
px = maxf(POPUP_MARGIN, px)
|
||||
py = maxf(POPUP_MARGIN, py)
|
||||
_info_panel.position = Vector2(px, py)
|
||||
|
||||
# Title
|
||||
_draw_title()
|
||||
@@ -333,8 +379,12 @@ func _draw_sector_labels(center: Vector2) -> void:
|
||||
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
||||
var font := get_theme_default_font()
|
||||
var font_size: int = 10
|
||||
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color)
|
||||
var text_size: Vector2 = font.get_string_size(
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size
|
||||
)
|
||||
draw_string(
|
||||
font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color
|
||||
)
|
||||
|
||||
|
||||
func _draw_systems(center: Vector2) -> void:
|
||||
@@ -359,10 +409,42 @@ func _draw_systems(center: Vector2) -> void:
|
||||
|
||||
# Hover highlight
|
||||
if sid == _hovered_system and sid != _selected_system:
|
||||
draw_arc(pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true)
|
||||
draw_arc(
|
||||
pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true
|
||||
)
|
||||
|
||||
draw_circle(pos, radius, color)
|
||||
|
||||
# System name label — centered below dot, zoom-based LoD
|
||||
var label: String = node.get("proper_name", "")
|
||||
if not label.is_empty() and label != sid:
|
||||
var show_label := false
|
||||
if sid == _selected_system or sid == _hovered_system:
|
||||
show_label = true # always show selected/hovered
|
||||
elif _zoom >= 2.0:
|
||||
show_label = true # zoomed in: show all
|
||||
elif _zoom >= 1.2:
|
||||
show_label = topology in ["hub", "junction", ""] # medium: hubs + junctions + gateway
|
||||
# else: zoomed out, only selected/hovered
|
||||
|
||||
if show_label:
|
||||
var label_color: Color
|
||||
if sid == _selected_system or sid == _hovered_system:
|
||||
label_color = COLOR_TEXT
|
||||
else:
|
||||
label_color = COLOR_TEXT_DIM
|
||||
var font := get_theme_default_font()
|
||||
var label_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
|
||||
draw_string(
|
||||
font,
|
||||
pos + Vector2(-label_size.x / 2.0, radius + 10.0),
|
||||
label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
9,
|
||||
label_color,
|
||||
)
|
||||
|
||||
|
||||
func _draw_edges(center: Vector2) -> void:
|
||||
# Only draw edges connected to the selected system (full web is unreadable at 301 systems)
|
||||
@@ -383,156 +465,110 @@ func _draw_selection(center: Vector2) -> void:
|
||||
if not _node_positions.has(_selected_system):
|
||||
return
|
||||
var pos: Vector2 = center + _node_positions[_selected_system] * _zoom
|
||||
# Selection ring only — label is drawn by _draw_systems()
|
||||
draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true)
|
||||
|
||||
# Draw label next to selection
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
var label: String = node.get("proper_name", _selected_system)
|
||||
if label.is_empty():
|
||||
label = _selected_system
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
|
||||
|
||||
## Rebuild the info panel with components for the selected system (D-169).
|
||||
func _rebuild_info_panel() -> void:
|
||||
if not _info_panel:
|
||||
return
|
||||
_info_panel.clear()
|
||||
|
||||
func _draw_info_panel(sz: Vector2) -> void:
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
if node.is_empty():
|
||||
_info_panel.visible = false
|
||||
return
|
||||
|
||||
var font := get_theme_default_font()
|
||||
var panel_w: float = POPUP_WIDTH
|
||||
var pad: float = POPUP_PADDING
|
||||
var inner_w: float = panel_w - pad * 2.0
|
||||
|
||||
# Measure GTTR text height first so we can size the panel
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
# Strip markdown bold markers for display
|
||||
gttr = gttr.replace("**", "")
|
||||
var gttr_block_h: float = 0.0
|
||||
if not gttr.is_empty():
|
||||
var gttr_size: Vector2 = font.get_multiline_string_size(
|
||||
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
|
||||
POPUP_GTTR_MAX_LINES)
|
||||
gttr_block_h = gttr_size.y + 6.0
|
||||
|
||||
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
|
||||
var fixed_h: float = (
|
||||
POPUP_LINE_H * 2.0 # name + id
|
||||
+ 4.0 # separator
|
||||
+ POPUP_LINE_H * 3.0 # star+hop, corridor, bodies+pop
|
||||
+ 4.0 # separator
|
||||
+ gttr_block_h
|
||||
+ 4.0 # separator (before adjacent)
|
||||
+ POPUP_LINE_H # adjacent systems label
|
||||
+ pad * 2.0
|
||||
)
|
||||
var panel_h: float = fixed_h
|
||||
|
||||
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
|
||||
var x: float = panel_pos.x + pad
|
||||
var y: float = panel_pos.y + pad + POPUP_LINE_H # baseline offset
|
||||
|
||||
# Background + border
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
|
||||
|
||||
# ── System name ──────────────────────────────────────────────────────────
|
||||
# ── Header ───────────────────────────────────────────────────────────────
|
||||
var sys_name: String = node.get("proper_name", "")
|
||||
if sys_name.is_empty():
|
||||
sys_name = node.get("system_id", "Unknown")
|
||||
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
|
||||
y += POPUP_LINE_H
|
||||
_info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", "")))
|
||||
|
||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
_info_panel.add_component(ImplantSeparator.new())
|
||||
|
||||
# Separator
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
|
||||
# ── Stats block ──────────────────────────────────────────────────────────
|
||||
# ── Stats ────────────────────────────────────────────────────────────────
|
||||
var star_type: String = node.get("star_type", "")
|
||||
var hop: int = int(node.get("hop_distance", 0))
|
||||
var stat_line_1: String
|
||||
if star_type.is_empty():
|
||||
stat_line_1 = "Hop %d from Gateway" % hop
|
||||
else:
|
||||
stat_line_1 = "%s star · hop %d" % [star_type, hop]
|
||||
draw_string(font, Vector2(x, y), stat_line_1, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
if not star_type.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new(star_type + " star"))
|
||||
|
||||
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
|
||||
var hop: int = int(node.get("hop_distance", 0))
|
||||
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
||||
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
|
||||
y += POPUP_LINE_H
|
||||
_info_panel.add_component(
|
||||
ImplantDataRow.new("%s corridor (hop %d)" % [sector_str, hop], sector_color)
|
||||
)
|
||||
|
||||
# Bodies — single line
|
||||
var bodies: String = node.get("bodies", "")
|
||||
if not bodies.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new(bodies))
|
||||
|
||||
# Population + GDP
|
||||
var population: String = node.get("population", "")
|
||||
var stat_line_3: String
|
||||
if not bodies.is_empty() and not population.is_empty():
|
||||
stat_line_3 = "%s · pop %s" % [bodies, population]
|
||||
elif not bodies.is_empty():
|
||||
stat_line_3 = bodies
|
||||
elif not population.is_empty():
|
||||
stat_line_3 = "Population: " + population
|
||||
else:
|
||||
stat_line_3 = "%d aperture%s · %s" % [
|
||||
int(node.get("aperture_count", 0)),
|
||||
"s" if int(node.get("aperture_count", 0)) != 1 else "",
|
||||
node.get("gate_topology", "")]
|
||||
draw_string(font, Vector2(x, y), stat_line_3, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
if not population.is_empty():
|
||||
_info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
|
||||
_info_panel.add_component(ImplantDataRow.new("pop " + population))
|
||||
_info_panel.add_component(ImplantDataRow.new("GDP —"))
|
||||
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
if not gttr.is_empty():
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
draw_multiline_string(font, Vector2(x, y), gttr, HORIZONTAL_ALIGNMENT_LEFT,
|
||||
inner_w, POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES,
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
|
||||
y += gttr_block_h
|
||||
_info_panel.add_component(ImplantSeparator.new())
|
||||
_info_panel.add_component(ImplantTextBlock.new(gttr, POPUP_GTTR_MAX_LINES))
|
||||
|
||||
# ── Adjacent systems ──────────────────────────────────────────────────────
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
# ── Adjacent systems ─────────────────────────────────────────────────────
|
||||
var adj: Array = node.get("adjacent_systems", [])
|
||||
if adj.is_empty():
|
||||
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
||||
else:
|
||||
if not adj.is_empty():
|
||||
_info_panel.add_component(ImplantSeparator.new())
|
||||
var adj_names: Array = []
|
||||
for neighbor_id: String in adj:
|
||||
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
|
||||
var n_name: String = neighbor.get("proper_name", "")
|
||||
adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
|
||||
var adj_line: String = " · ".join(adj_names)
|
||||
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
|
||||
inner_w, 10, 2, COLOR_TEXT_DIM)
|
||||
_info_panel.add_component(ImplantTextBlock.new(" · ".join(adj_names)))
|
||||
|
||||
|
||||
func _draw_title() -> void:
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT)
|
||||
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(16, 28),
|
||||
"THE REACH — NAVIGATOR",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
16,
|
||||
COLOR_TEXT
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(16, 44),
|
||||
"Concord Assembly Gate Network — %d Systems" % _nodes.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
10,
|
||||
COLOR_TEXT_DIM
|
||||
)
|
||||
|
||||
|
||||
func _dot_radius(topology: String) -> float:
|
||||
match topology:
|
||||
"hub": return DOT_RADIUS_HUB
|
||||
"junction": return DOT_RADIUS_JUNCTION
|
||||
"dead_end": return DOT_RADIUS_DEAD_END
|
||||
_: return DOT_RADIUS_DEFAULT
|
||||
"hub":
|
||||
return DOT_RADIUS_HUB
|
||||
"junction":
|
||||
return DOT_RADIUS_JUNCTION
|
||||
"dead_end":
|
||||
return DOT_RADIUS_DEAD_END
|
||||
_:
|
||||
return DOT_RADIUS_DEFAULT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input — selection, pan, zoom
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
@@ -588,6 +624,7 @@ func _find_nearest_system(pos: Vector2) -> String:
|
||||
func _handle_click(pos: Vector2) -> void:
|
||||
var nearest := _find_nearest_system(pos)
|
||||
_selected_system = nearest
|
||||
_rebuild_info_panel()
|
||||
_dirty = true
|
||||
|
||||
|
||||
|
||||