Compare commits

...
75 Commits
Author SHA1 Message Date
jpmschweitzer 11b29baefe Merge branch 'sprint-32/client' 2026-04-06 16:45:14 +02:00
jpmschweitzerandClaude Opus 4.6 168c5e0d9b fix(ui): restore STANCE_ICONS constant removed during refactor
Startup crash — preload loop referenced STANCE_ICONS but the constant
was accidentally dropped when rewriting the icon cache logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 16:43:49 +02:00
jpmschweitzerandClaude Opus 4.6 746ed05e2d fix(ui): address PR #118 review — 5 items
- Preload stance icons at _ready() into cache Dictionary
- Null-guard shader + theme loads with push_error() + early return
- Guard _icon_mat.set_shader_parameter inside if _icon_mat:
- Icon size 18→20px per D-086, container height 44→46px to fit
- Add _apply_stance test coverage (known + unknown stance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 16:41:27 +02:00
jpmschweitzer 6ce708a15a Merge branch 'sprint-32/copy' 2026-04-06 16:33:05 +02:00
jpmschweitzerandClaude Opus 4.6 bb61d860d6 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 16:30:57 +02:00
jpmschweitzerandClaude Opus 4.6 a14828e02a refactor(ui): add stance icons to implant badge (#787)
Replace text-only stance labels with SVG icons + tint shader per D-086.
Alpha-mask shader swaps color at runtime via ShaderMaterial parameter.
Icons: walk, crouch, careful, sprint at 18px inside ImplantPanel row.
Also fix stale UILayer/HUD test path in test_ui_framework_sprint15.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 16:30:22 +02:00
jpmschweitzerandClaude Opus 4.6 16fb644a12 fix(wiki): remove placeholder economic fields from GJ-280B stub
GJ-280B is a stub pending full generation — economic profile fields
were speculative and should not be present until the system is properly
authored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:27:33 +02:00
jpmschweitzerandClaude Opus 4.6 07abad2df5 fix(wiki): address PR #116 review — TOML key mismatches, broken link, profile corrections
- Fix 16 lore_archetype keys in tier1.toml to match lore.toml definitions
- Fix 4 commodity ID mismatches (medical_re-embodiment, fine_dining, vehicles, entertainment)
- Add pharmaceutical_manufacturer archetype to lore.toml
- Fix station_module_constructor note (Cygni B → Sova Station Works)
- Create GJ-280B stub (Sova Station Works HQ system)
- Add Station Sova cross-reference to sova-station-works.md
- Fix GJ-251 and GJ-144 economic exports (financial_services contradictions)
- Split corporation index into Narrative Profiles and Tier 1 sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:25:44 +02:00
jpmschweitzer d986bfea30 Merge branch 'sprint-32/visual' 2026-04-06 16:23:07 +02:00
jpmschweitzerandClaude Opus 4.6 0c3eddee39 fix(assets): address PR #117 review comments
- Remove tracked kallast_terrain.npy (HIGH — binary in git)
- Amend D-086: stance + interaction icons delivered, not deferred
- Create ticket #818 for icon_tint.gdshader (client team)
- Fix gas giant profile table: suppress gravity/land%/hydrosphere
- Fix atmosphere_color: null when atmosphere is none
- Fix gas_giant_ringed display as gas_giant in profile table
- Re-scaffold + regenerate Ran system with fixes

.import sidecars: not applicable — gitignored by design (client/**/*.import).
Godot auto-generates on first run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 16:19:53 +02:00
jpmschweitzerandClaude Opus 4.6 f73d5ba69f data(wiki): 25 Tier-1 corporation profiles (#797)
Adds 25 new named corporations covering D-175 priority gaps: financial
services (Groombridge Settlement House, Compact Financial Exchange,
Meridian Risk), re-embodiment (Somatic Futures, Blue Collective), east
reach anchors, Compact distributors, freight, mining, medical, lattice,
and more. Each corp has lore + behavioral archetype, political tension,
administered silence, currency preference, and branded products.

Includes TOML records in wiki/economics/corporations/tier1.toml and
narrative wiki pages in wiki/corporations/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:05:25 +02:00
jpmschweitzerandClaude Opus 4.6 1e4a44da18 data(wiki): corporation archetype taxonomy (#798)
Adds wiki/economics/archetypes/ with 28 lore archetypes (WHAT/WHERE)
and 6 behavioral archetypes (HOW) as TOML templates. These are the
generation templates for Tier-2 and Tier-3 corporations per D-175.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:04:53 +02:00
jpmschweitzerandClaude Opus 4.6 199770ee41 data(wiki): add Bodies/Population rows + economic profiles to 301 systems (#790, #781)
Adds Bodies and Population rows to System Profile tables across all 301
star-system wiki pages. Adds Industries, Exports, and Imports economic
profile rows derived from systems.db economic_base + body type + topology.
Includes 9 body/station name changes from cultural diversity remediation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:03:56 +02:00
jpmschweitzerandClaude Opus 4.6 941a7218d2 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:00:45 +02:00
jpmschweitzerandClaude Opus 4.6 db3daecfaf chore(config): add JSON syntax validation to pre-push hook
Validates changed JSON files using python3 -m json.tool (zero
dependencies). Same diff-based scoping as existing checks — only
files changed vs remote are validated. Blocks push on syntax errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:00:29 +02:00
jpmschweitzerandClaude Opus 4.6 c57488a86b fix(skills): filter NULL teams from sprint worktree creation
Tickets with no team assignment caused start-sprint.sh to create
a spurious "sprint-N/None" worktree and Ptyxis tab. Filter at both
the SQL level (AND team IS NOT NULL) and Python level (if r['team']).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:00:21 +02:00
jpmschweitzerandClaude Opus 4.6 6286cf5d2c chore(assets): fix ruff lint warnings in planet-gen scripts
Remove unused imports (os, sys, math, ImageFilter, gaussian_filter,
MAX_BIOME_ID) flagged by ruff F401.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:57:05 +02:00
jpmschweitzerandClaude Sonnet 4.6 c5c6922385 content(wiki): cultural diversity sweep — apply #784 plan (#793)
20 changes across 18 systems implementing the approved remediation plan
from the cultural diversity audit (docs/design/cultural-diversity-audit.md).

DB changes (15 bodies/stations in systems.db):
- Thai bodies: Saikaew (GJ 635B), Nakhon Sung (GJ 667C), Doi Kaeo (GJ 117)
- Khmer bodies: Banteay (GJ 434), Srok Thmei (GJ 866A)
- Ukrainian: Chornozem body + Svoboda station (GJ 729)
- Caribbean: Lavenille body (GJ 873), Accompong Works station (GJ 406)
- Malay: Pulau Baru body (GJ 555), Balai station (GJ 449)
- Scientists: Huygens Platform (GJ 251), Ramanujan Station (GJ 699),
  van Leeuwenhoek Station (GJ 820B), Turing Survey (GJ 663A)

Wiki calibration note additions:
- GJ 635B/667C/117/434: Thai cultural presence (survey teams, founding cohorts)
- GJ 866A/873: Khmer/Caribbean community naming origin
- GJ 729: Ukrainian founding cooperative in west_reach
- GJ 1245A/75: Igbo/Nigerian heritage explicitly acknowledged
- GJ 172: Malay kelong dual-heritage in Kaelong name
- GJ 601A: Russian secondary founding stream in Ostmark

Horizon stations not renamed. No corridor reassignments.
Conservative: systems with existing identity untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 15:56:38 +02:00
jpmschweitzerandClaude Opus 4.6 c32cdf11d4 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:55:38 +02:00
jpmschweitzerandClaude Opus 4.6 c3d6230eed feat(assets): generate Ran system bodies — 10 planets/moons with assets
First system through the planet generator pipeline. Per-body wiki
pages with YAML frontmatter at wiki/star-systems/GJ-144/bodies/.
Each body has: index.md (definition + profile), heightmap.png
(4096×2048), globe.png (512×512), and markers.json (rivers, oceans,
mountain ranges).

Bodies: GJ144b-g + moons. Includes temperate (Kallast, Vethis),
frozen, barren moons, and a ringed gas giant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:54:58 +02:00
jpmschweitzerandClaude Opus 4.6 1c6c5b9978 feat(assets): heightmap pipeline spike — prototype + test inputs
PO-built prototype (FBM terrain, Whittaker biomes, procedural globe)
with test body definitions for all planet types. Replaces pyplatec
approach. Spike validates the pipeline architecture for batch #817.

Includes handover doc, 9 test body definitions, and updated spike
pipeline documentation. Stale pyplatec outputs removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:54:35 +02:00
jpmschweitzerandClaude Opus 4.6 f84275edf3 feat(assets): planet generator pipeline — heightmap + globe from wiki data
Full terrain-to-render pipeline at tooling/planet-gen/:
- body_definition_parser: reads system index.md → body definitions
- planet_simulation: FBM + Voronoi ridges, Whittaker biome classification,
  D8 river routing, physics-driven craters (atmo × tectonics scaling)
- render_heightmap: 4096×2048 cartographic maps with hillshade
- planet_renderer: 512×512 globe with terrain UV mapping, clouds, rings
- biomes.toml: externalized color/classification tables (single source)
- scaffold_bodies: creates per-body index.md with YAML frontmatter
- batch: unattended processing with error handling, resume, determinism check

Supports all body types: temperate, arid, frozen, volcanic, barren,
oceanic, gas giant (banded + ringed), and moons (half-size, grey, cratered).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:54:15 +02:00
jpmschweitzerandClaude Opus 4.6 f968ee23f1 chore(config): add scipy + Pillow deps, gitignore terrain/spike data
scipy 1.17.1 and Pillow 12.2.0 — both CVE-audited clean against
NVD + OSV (2026-04-06). Required by planet generator pipeline.
Gitignore generated terrain.npz, spike intermediates, and tmp files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:53:55 +02:00
jpmschweitzerandClaude Opus 4.6 35ad862387 fix(assets): revise health cross icon and careful stance silhouette
Health icon: replaced overlapping outlined rects (railroad crossing
effect) with a single solid polygon path. Careful stance: redesigned
from diagonal-arm to horizontal T-shape for clear differentiation
from walk at 20px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:53:34 +02:00
jpmschweitzerandClaude Opus 4.6 879ef6092f fix(ui): address PR #115 review — 8 items
- Fix stale test path UILayer/HUD → InsertOverlay/HUD
- Fix D-record citation D-051 → D-049 in main.tscn
- Add null guards to hud.gd update methods (pre-_ready safety)
- Minimap: dirty-flag queue_redraw instead of per-frame
- Remove redundant _panel.size.x, debug print
- Fix DebugOverlay/GauntletHUD positioning comments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:45:47 +02:00
jpmschweitzerandClaude Opus 4.6 8c239d522c chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 12:52:34 +02:00
jpmschweitzerandClaude Opus 4.6 b2b67d52f8 refactor(ui): add implant container frame to minimap (#787)
Draw ImplantTheme-styled StyleBoxFlat behind the circular minimap for
visual consistency with other implant components. Stance badge icons
remain blocked by #795 (visual team).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 12:52:06 +02:00
jpmschweitzerandClaude Opus 4.6 283518d332 refactor(ui): convert HUD status + interaction prompt to implant (#786, #788)
HUD: merge TimeDisplay into ImplantPanel (time/health/perception in one
panel), remove dead tscn nodes, move HUD to InsertOverlay for D-051 bloom.
Interaction prompt: convert from PanelContainer+StyleBoxFlat to Control
with ImplantPanel+ImplantDataRow, fix panel height for 42px minimum.
Tests updated for new scene structure and public API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 12:51:58 +02:00
jpmschweitzerandClaude Opus 4.6 3c7231e275 fix(ui): resolve HUD layout overlaps (#792)
- DebugOverlay: position comment updated, tscn is source of truth
- GauntletHUD: moved from y:48 to y:252 (below StanceIndicator)
- StanceIndicator: offset_top 192→200, height 26→44px for ImplantPanel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 12:51:48 +02:00
jpmschweitzerandClaude Sonnet 4.6 c9541362f9 fix(assets): clean up stale grain_belt comment in settlement scorer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:19:42 +02:00
jpmschweitzerandClaude Sonnet 4.6 f64ee9dded feat(assets): annotated heightmap pipeline — Kallast spike #778
Validates full heightmap pipeline: pyplatec tectonics → erosion →
dynamic sea level → terrain classification (13 classes) → D8 river
network → settlement placement → geographic PNG + settlement JSON.

Key technical decisions:
- Dynamic sea level via np.percentile (pyplatec output is right-skewed;
  fixed fraction gives ~0.2% land, not 40%)
- Terrain classes as fractions of land_range (not fixed offsets)
- Two-layer model: geographic PNG + human-layer JSON sidecar
- Rivers painted AFTER LANCZOS upscale via NEAREST neighbor mask
  (painting before blurs rivers into invisibility)
- grain_belt removed — reclassified as grassland (natural terrain)
- Irrigation overlay removed — human activity, lives in JSON sidecar

Output: 4096×2048px (1024×512 simulation grid, 5.2s total runtime).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:19:23 +02:00
jpmschweitzerandClaude Sonnet 4.6 1cffc83dd7 feat(assets): procedural planet renders for wiki/GTTR — #779
7 planet type PNGs (512×512px RGBA) covering all biome_summary values:
temperate, temperate_terminator, oceanic, arid, frozen, volcanic, barren.

Pure Python ray-sphere renderer (spikes/planet-renders/generate_planets.py)
— numpy/PIL only, no Godot dependency, ~2s for all 7 types. Seeded from
body_id for reproducibility. Resolves Q-064 (Godot 3D planet plugin
evaluation — superseded by headless Python approach).

Assets at client/assets/planets/, 512×512 RGBA, displayed at 240×240 in
the body-info-panel navigator and GTTR arrival window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:19:06 +02:00
jpmschweitzerandClaude Sonnet 4.6 b5cb93203b feat(assets): implant HUD icon set v0.1 — #795
13 SVG icons covering stance indicators (walk/careful/sprint/crouch),
status badges (health, perception), interaction prompts (talk/observe/
follow/examine), and inventory silhouettes (manifest, access token,
comm log).

All D-086 compliant: 1px stroke, #c8d0e0 chrome, round linecaps,
minimal anchor points, no icon fonts, runtime-replaceable via
ShaderMaterial. Sizes: 20×20 (stance/status), 16×16 (actions),
40×40 (inventory). Unblocks client #787.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:18:56 +02:00
jpmschweitzerandClaude Opus 4.6 f1b97d4a88 fix(economics): address 17 PR review comments on #114
Blocking fixes:
- V-13: legal_services demand_model compliance→market (build-breaker)
- D-184: political flag names match TOML (commission_certified etc.)
- D-184: professional services list matches TOML
- D-173: elasticity count 5→4, cross-ref to D-184
- Workshop outcomes: fix self-contradicting fusion fuel statement
- R-011: move to end of file (was between D-183 and D-184)

Should-fix:
- D-166 domain link scope.md→architecture.md
- Sprint briefing counts updated (13→17 D-records, #801 marked done)
- Burnelli-Sheldon added to agent roster + briefing created
- questions-architecture.md footer tally updated
- medical_services demand_model utility→market
- timber display name drop "/Biomass"

Polish:
- D-171 Groombridge clarified (clearing house, not co-issuer)
- hospitality description has Reach texture
- commission_certification shadow_viable=false rationale in description
- Ticket #801 description updated
- Wiki pages regenerated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:18:41 +02:00
jpmschweitzerandClaude Opus 4.6 69d0659066 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:01:00 +02:00
jpmschweitzerandClaude Opus 4.6 ac05dbd21f data(economics): commodity catalog — 36 types, 21 production chains
Workshop #801 deliverable: commodities.toml (36 commodities across
5 tiers), production_chains.toml (21 Leontief recipes including
2 substitution routes), schema.md (SQL DDL + validation rules).

Key design choices: brands are not commodities (separate layer),
water→fuel at 8:1 yield, 3 political sub-flags replacing single
boolean, gate energy-over-gate as commercial service. Wiki stub
pages generated for all 36 commodities.

Tickets #811–#815 created for follow-up work. #801 closed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:00:33 +02:00
jpmschweitzerandClaude Opus 4.6 9b1258568e docs(decisions): economics design workshop #796 — D-171 through D-187
Workshop #796 produced 13 D-records (D-171–D-183) defining the Phase 2
economics layer: three-currency system, commodity taxonomy, shadow
economy, corporation tiers, productivity seeding, simulation
architecture, TOML data pipeline.

Workshop #801 added D-184–D-187: commodity catalog (36 types), brands
are not commodities, gate transmission levels (mass+data+energy),
fusion fuel as intermediate (8:1 water yield).

R-011 filed (single currency rejected). Burnelli-Sheldon agent added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:00:18 +02:00
jpmschweitzerandClaude Opus 4.6 b4e4c65830 docs(meta): update Sprint 32 briefings after workshop #796
Post-workshop briefing updates: joint notes with dependency chain,
planning team task list, copy/visual team scope adjustments, TEAM.md
roster update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:00:03 +02:00
jpmschweitzerandClaude Opus 4.6 3d1ff6ad2e chore(meta): plan Sprint 32: Landfall
14 tickets across 4 teams + planning track:
- Copy (4): cultural audit #784 (analysis only), batch edits #793
  (blocked), system profiles #790, economic profiles #781
- Visual (4): heightmap spike #778, batch heightmaps #794 (blocked),
  screenshots #779, icon design #795
- Client (4): HUD layout #792, status/minimap/interaction → implant
  #786/#787/#788. #787 blocked by icon design.
- Planning (1): Phase 2 economics kickoff #796

New tickets: #793, #794, #795, #796

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 15:40:27 +02:00
jpmschweitzerandClaude Opus 4.6 050aeafc01 chore(kit): remove worktree-update skill, fix pr-review severity tiers
- Delete worktree-update skill template (replaced by sprint branches)
- Remove worktree-update references from settings, manifests, tests
- Update customization-examples to sprint-branch language
- Remove three-tier severity from pr-review template (every comment
  is actionable, no suggestion tier)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 15:21:20 +02:00
jpmschweitzerandClaude Opus 4.6 706b4565d4 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 15:17:14 +02:00
jpmschweitzerandClaude Opus 4.6 5683b1dc4b chore(config): fix skill contradictions, gitignore .obsidian, docs cleanup
- sprint-start: remove three-tier severity (critical/warning/suggestion),
  align with pr-review policy (every comment is actionable)
- sprint-start: resolve {team_scope_dirs} dangling placeholder
- .gitignore: add .obsidian/ directory
- DEVOPS.md: remove "pending setup" from gdlint (now enforced)
- Remove GEMINI-SCAN.md one-off scan artifact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 15:16:51 +02:00
jpmschweitzerandClaude Opus 4.6 cfddcfb117 docs(briefings): extract shared PROJECT_STATE.md, purge stale boilerplate
Create PROJECT_STATE.md at repo root with cascade phase table,
decision count, and current sprint. All 18 briefings now reference
this file instead of duplicating the project state paragraph.

Also removes stale content from briefings:
- "v0.2 direction: generator-first" (contradicts D-166 cascade)
- "Sprint 25 generator spike confirmed as critical path" (Phase 5)
- Agent-specific generator prerequisite claims
- Gestalt "v0.2 Pivot" section reframed as "Key Design Pivots"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 15:16:39 +02:00
jpmschweitzerandClaude Opus 4.6 1254e41dc5 docs(decisions): amend stale D-records, close resolved Q-records
Amend 7 high-priority D-records with supersession notes:
- D-012: map size superseded by D-094 (256x256)
- D-044: outline color scoped to perception mode per D-154
- D-061: width corrected from 640px to 1200px per D-076
- D-086: smuggler scope removed per D-117/D-122
- D-090: marked [SUPERSEDED] by D-117 (tycoon replaces smuggler/detective)
- D-035: character enum marked obsolete post-D-117
- D-119: sequencing superseded by D-166 cascade

Close 4 resolved scope questions (Q-002, Q-004, Q-005, Q-007).
Fix questions.md index — was 61, actually 96 (33 architecture Qs missing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 15:16:27 +02:00
jpmschweitzerandClaude Opus 4.6 ef072416ba docs(architecture): update CLAUDE.md, project structure, D-170 for implant UI system
- CLAUDE.md: added Implant UI component library section (D-169, D-170)
- project-structure.md: added client/ui/implant/, hud_groups.gd, gameplay_renderer.gd
- D-170: corrected implementation note from visibility toggling to z-index layering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:43:10 +02:00
jpmschweitzerandClaude Opus 4.6 8421ac4d27 chore(config): gitignore settings.db WAL files, sync Cargo.lock version
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:38:55 +02:00
jpmschweitzerandClaude Opus 4.6 eeac6405d6 fix(ui): HUD layout pass — resolve overlapping elements (#792)
- HUD status panel moved to y=80 (below TimeDisplay at 16-70)
- Removed redundant time row from HUD (TimeDisplay handles it)
- Debug overlay starts at y=150 (below HUD status panel)
- Stance indicator moved below minimap (y=192, was overlapping at y=16)
- Interaction prompt moved to y=-240 (above dialogue box at -200)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:35:37 +02:00
jpmschweitzerandClaude Opus 4.6 26a63d3899 feat(ui): convert HUD status, stance, interaction prompt to implant theme (D-169)
- HUD: programmatically builds ImplantPanel with health/mode/time rows,
  replaces raw MarginContainer/VBoxContainer/Labels
- Stance indicator: ImplantPanel with color-coded ImplantDataRow,
  replaces custom _draw() with hardcoded bg/text
- Interaction prompt: applies implant theme StyleBox + colors to
  existing PanelContainer layout

Minimap left as-is — its circular frame is a distinct diegetic element,
not rectangular panel chrome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:25:33 +02:00
jpmschweitzerandClaude Opus 4.6 a7fbf7a0d9 feat(ui): zoom-based LoD for star map labels
Labels now appear based on zoom level and system importance:
- Always: selected + hovered
- Zoom >= 1.2: hubs, junctions, gateway
- Zoom >= 2.0: all named systems
- Below 1.2: only selected/hovered

Prevents label soup at overview zoom levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:20:00 +02:00
jpmschweitzerandClaude Opus 4.6 3439b05e88 feat(ui): show all system names on star map, centered below dots
All named systems get a label below their dot. text_dim for default,
text_primary for selected/hovered. Removed duplicate label from
_draw_selection (now handled by _draw_systems).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:17:31 +02:00
jpmschweitzerandClaude Opus 4.6 9cdc86d65d fix(data): star map bodies + population from systems.db, not wiki
Generator now queries bodies/stations tables directly for habitable
count, inhabited count, and total population. All 301 systems have
data — unsettled systems show "0 habitable · 0 inhabited" and "0".
Fixed stale worktree path to systems.db.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:11:39 +02:00
jpmschweitzerandClaude Opus 4.6 3f611ae0f7 fix(ui): star map info — hop merged into corridor line, bodies on one line
"CORE corridor (hop 1)" with sector color. Bodies back to single line
"1 habitable · 1 inhabited" as originally formatted in the data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:03:25 +02:00
jpmschweitzerandClaude Opus 4.6 5400044afe feat(ui): star map info panel — one item per line, GDP placeholder
Layout now shows each field on its own line:
  star type / hop / corridor / habitable / inhabited / pop / GDP
GDP shows "—" placeholder until economics data is available.
Population shows full number without ellipsis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:57:49 +02:00
jpmschweitzerandClaude Opus 4.6 34a21d4cd0 feat(rendering): GameplayRenderer base class — occlusion for all renderers (D-170)
New GameplayRenderer extends Node2D with built-in occlusion support.
Subclasses override _gameplay_process() and _gameplay_draw() instead
of _process() and _draw() — these are skipped when a fullscreen
implant app covers gameplay.

Migrated all 5 gameplay renderers:
- CursorRenderer: hover detection + bracket drawing paused
- EntityRenderer: position lerping paused
- FogEntities: fog dot rendering paused
- SoundIndicatorRenderer: directional arrows paused
- WorldRenderer: tile/fog/entity updates paused

No more manual occlusion wiring — the base class handles everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:51:30 +02:00
jpmschweitzerandClaude Opus 4.6 a01f7c9cfb fix(ui): cursor z-index 100, adjacent wraps, panel clamp with reset_size
- CursorRenderer z_index=100 so it renders above all HUD/implant layers
- Adjacent systems uses ImplantTextBlock (wraps) instead of ImplantDataRow (clips)
- Panel calls reset_size() before clamping to get accurate height on first frame

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:34:34 +02:00
jpmschweitzerandClaude Opus 4.6 0d46180e39 fix(ui): remove cursor_renderer from gameplay group
CursorRenderer must always render on top — shouldn't have its z-index
managed by HudGroups. It's a world-space renderer, not a HUD element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:30:35 +02:00
jpmschweitzerandClaude Opus 4.6 7fb155c9a8 fix(ui): restore StarMap node in hud.tscn
Accidentally deleted when exploring reparenting options. The z-index
model means it stays as a child of HUD — no reparenting needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:28:31 +02:00
jpmschweitzerandClaude Opus 4.6 e4fafb66d5 style(client): gdformat main.gd
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:26:01 +02:00
jpmschweitzerandClaude Opus 4.6 ffef2b3d54 fix(ui): gdlint ordering — signals before enums, signal uses int for Mode
Moved signals above enum per gdlint class-definitions-order rule.
app_changed signal uses int instead of Mode type to avoid the
forward-reference that forced wrong ordering. Callers compare
against HudGroups.Mode.FULLSCREEN etc unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:25:22 +02:00
jpmschweitzerandClaude Opus 4.6 2e01081de7 refactor(ui): HudGroups z-index model + gameplay occlusion (D-170)
Replaced visibility toggling with z-index layer management. Nothing
gets hidden — fullscreen apps render on top, gameplay stays underneath.
Supports future hybrid layouts (insert-mode map alongside gameplay).

Added gameplay_occluded signal: WorldRenderer skips tile/fog/entity
updates when a fullscreen implant app covers it. Prevents wasted
render work behind opaque overlays. Other renderers can connect to
the same signal.

Modes: GAMEPLAY (z=0), INSERT (z=10), FULLSCREEN (z=20), MODAL (z=30).
Star map uses app_changed signal to toggle its own visibility based
on whether its app is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:21:31 +02:00
jpmschweitzerandClaude Opus 4.6 09e0c522bd fix(client): split PowerProfile enum to fix last gdlint warning
gdformat collapsed enum + 3 inline comments into one 300-char line.
Split to multi-line enum with doc comments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:10:13 +02:00
jpmschweitzerandClaude Opus 4.6 88c7407cb6 style(client): gdformat all 52 GDScript files — zero format warnings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:09:10 +02:00
jpmschweitzerandClaude Opus 4.6 572e66f026 fix(ui): remove Node2D cursor_renderer from Control-typed HudGroups array
CursorRenderer extends Node2D, not Control — can't be registered in
HudGroups. Removed from gameplay group. Also dropped typed array
annotation to avoid runtime type mismatch errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:07:34 +02:00
jpmschweitzerandClaude Opus 4.6 22d74ab6ad refactor(ui): star map registers as implant/map/starchart
Deeper taxonomy: map is a family of views (starchart, system, station,
planet, location), not one app. Same HudGroups mechanics — just a
more specific path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:05:26 +02:00
jpmschweitzerandClaude Opus 4.6 5ad2452213 feat(ui): implant app hierarchy in HudGroups (D-170)
Implant apps use hierarchical groups: implant/map, implant/wiki,
implant/journal, etc. Apps are mutually exclusive with each other
and with gameplay. Opening implant/map hides gameplay + any other
implant app. Closing returns to gameplay.

API: open_app(), close_app(), toggle_app(), is_app_active().
Star map uses implant/map. Future apps register their own group.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:04:19 +02:00
jpmschweitzerandClaude Opus 4.6 f037bcaa81 feat(ui): HUD visibility groups — gameplay vs implant layers (D-170)
New HudGroups autoload manages show/hide of related HUD elements as
groups. Exclusive groups (gameplay, implant) are mutually exclusive —
opening the star map hides stance indicator, minimap, interaction
prompts, etc. Closing it restores them.

Gameplay nodes registered in main.gd _ready(). Star map registers as
implant group and uses toggle_group() instead of direct visibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:01:28 +02:00
jpmschweitzerandClaude Opus 4.6 44cc8cda4c fix(ui): data rows clip to one line, panel clamped to screen
ImplantDataRow uses clip_text + ellipsis instead of wrapping — each
info item stays on one line. Panel position clamped so bottom edge
never exceeds screen bounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:57:35 +02:00
jpmschweitzerandClaude Opus 4.6 f0c6e97742 fix(ui): correct Godot 4.6 size flag constants
SIZE_FLAGS_FILL → SIZE_FILL, SIZE_FLAGS_SHRINK_END → SIZE_SHRINK_END.
The _FLAGS_ prefix was removed in Godot 4.6.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:54:11 +02:00
jpmschweitzerandClaude Opus 4.6 6ad1c8255f fix(ui): implant panel width constraint and text wrapping
Panel now clips content and constrains children to its width.
Components set autowrap and SIZE_FLAGS_FILL so text wraps within
the panel instead of overflowing. Bold markdown markers stripped
in ImplantTextBlock constructor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:51:25 +02:00
jpmschweitzerandClaude Opus 4.6 962596fd57 feat(ui): implant component library + star map refactor (D-169)
New component library at client/ui/implant/:
- ImplantTheme: shared Resource with colors, spacing, font sizes
- ImplantPanel: PanelContainer root with themed background/border
- ImplantHeader: title + subtitle
- ImplantSeparator: themed horizontal rule with above/below spacing
- ImplantDataRow: single-line text with optional color override
- ImplantTextBlock: RichTextLabel for wrapping narrative text
- default_implant.tres: default theme resource

Star map info panel refactored from 140 lines of manual draw_string
calls to 50 lines of component composition via _rebuild_info_panel().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:47:23 +02:00
jpmschweitzerandClaude Opus 4.6 4dd95ba50b docs(decisions): D-169 Implant UI component library — Control node tree
Control nodes with shared Theme resource, not _draw() components.
Dynamic implant system (hardware variants, upgrades, faction overlays)
requires layout automation and theme swapping that _draw() can't provide.

Component set: Panel, Header, Separator, DataRow, TextBlock, StatusBadge,
ProgressBar, TabRow, Expandable. Semantic color roles defined.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:43:05 +02:00
jpmschweitzerandClaude Opus 4.6 8d02b110ae fix(ui): star map info panel spacing — split separator padding
Separators now have explicit above (6px) and below (12px) spacing
relative to text baselines. Previously a single 8px block didn't
account for baseline positioning, causing text to merge with
separator lines. Line height bumped to 18px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:35:05 +02:00
jpmschweitzerandClaude Opus 4.6 73ed0be22c chore(process): quality and polish is the golden standard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:31:44 +02:00
jpmschweitzerandClaude Opus 4.6 b14c19c65e chore(process): all review comments actionable + pushback flow
Remove severity tiers from PR reviews. Every comment must be addressed
before merge — no "suggestion" category that gets skipped. Teams can
push back on specific comments with a technical rationale; team lead
evaluates and can retract. Bar: "the reviewer was wrong" not "we don't
want to."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:30:21 +02:00
jpmschweitzerandClaude Opus 4.6 d2ea687ec3 fix(ui): star map info panel — dynamic height measurement
Panel now measures all variable content (GTTR text, adjacent systems)
before calculating height. Fixes clipping on hub systems with many
neighbors and long GTTR excerpts. Unified separator spacing (8px).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:27:45 +02:00
609 changed files with 31279 additions and 1807 deletions
+1
View File
@@ -26,6 +26,7 @@ All agents read their briefing file at `docs/briefings/{name}.md` before startin
| Agent | File | Role | Model | When to use | | Agent | File | Role | Model | When to use |
|-------|------|------|-------|-------------| |-------|------|------|-------|-------------|
| `troblum` | troblum.md | Technical Consultant / Tyre's sparring partner | sonnet | Evaluation sidequests alongside Tyre | | `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 | | `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 | | `hoshe` | hoshe.md | QA Engineer / Tester | sonnet | Testing, test plans, bug reports, verification |
+42
View File
@@ -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).
+8
View File
@@ -2,6 +2,14 @@
``` ```
client/ # Godot 4 client 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 server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests tests/ # Integration and end-to-end tests
+30 -9
View File
@@ -151,9 +151,9 @@ reviewer that was spawned (2 for code/visual/audio, 3 for copy):
### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES] ### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES]
[Summary] [Summary]
| # | File | Severity | Issue | | # | File | Issue |
|---|------|----------|-------| |---|------|-------|
| 1 | path:line | critical/warning/suggestion | description | | 1 | path:line | description |
### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES] ### <Reviewer Name> (<Focus>): [APPROVE | REQUEST_CHANGES]
... ...
@@ -187,13 +187,18 @@ Respond with:
2. Summary: 2-3 sentence overall assessment 2. Summary: 2-3 sentence overall assessment
3. Comments: List of specific issues, each with: 3. Comments: List of specific issues, each with:
- File path and approximate location - File path and approximate location
- Severity: critical / warning / suggestion
- Description of the issue - Description of the issue
Verdict rules: Verdict rules:
- Any **critical** or **warning** issue → REQUEST_CHANGES - **Any comment at all → REQUEST_CHANGES.** Every issue is actionable.
- Only **suggestion**-level issues (or none) → APPROVE There is no "suggestion" tier that gets skipped. If it's worth
If no issues found, say APPROVE with a brief positive summary. 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 ## 6. Posting results to Gitea
@@ -236,8 +241,24 @@ review outcome:
**CHANGES_REQUESTED:** **CHANGES_REQUESTED:**
The sprint-start lifecycle (step 9c) handles dispatching review The sprint-start lifecycle (step 9c) handles dispatching review
comments to agents. After presenting results, remind the lead: comments to agents. After presenting results, remind the lead:
"Review requested changes. Create tasks from the warnings/critical "Review requested changes. Create tasks from each issue and dispatch
issues and dispatch to idle agents, then re-push and re-review." 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:** **APPROVED:**
The sprint-start lifecycle (step 9c) handles shutdown. After The sprint-start lifecycle (step 9c) handles shutdown. After
+5 -6
View File
@@ -359,7 +359,7 @@ Task(
RULES (NON-NEGOTIABLE): RULES (NON-NEGOTIABLE):
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`. 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/ You may read (but not modify): docs/, decisions/, wiki/, .claude/
Do NOT modify files belonging to other teams. Do NOT modify files belonging to other teams.
@@ -530,21 +530,20 @@ comments):
**If CHANGES_REQUESTED:** **If CHANGES_REQUESTED:**
1. Parse the review comment table (from the Gitea PR comment or the 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 - File path and approximate line
- Severity (critical / warning / suggestion)
- Description - Description
2. Create a task per warning/critical issue: 2. Create a task per issue:
``` ```
TaskCreate( TaskCreate(
subject: "Review: {short description}", subject: "Review: {short description}",
description: "{full issue description from review table, including 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}" 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 3. Dispatch to idle agents: send each a message via SendMessage telling
them to check TaskList for new review-fix tasks. Agents claim and them to check TaskList for new review-fix tasks. Agents claim and
@@ -31,7 +31,7 @@ else
fi fi
# Query teams with open tickets # 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 if [ -z "$TEAMS" ]; then
echo "Sprint $SPRINT has no open tickets. Opening main only." echo "Sprint $SPRINT has no open tickets. Opening main only."
+25
View File
@@ -113,6 +113,31 @@ else
echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')" echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')"
fi 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 if [ "$ERRORS" -gt 0 ]; then
echo "" echo ""
echo "pre-push: $ERRORS check(s) failed. Push aborted." echo "pre-push: $ERRORS check(s) failed. Push aborted."
+12
View File
@@ -3,6 +3,8 @@
# Runtime databases (not tracked — created on startup) # Runtime databases (not tracked — created on startup)
server/settings.db server/settings.db
server/settings.db-shm
server/settings.db-wal
# Build and cache # Build and cache
.cache/ .cache/
@@ -32,6 +34,15 @@ renderer/output/*.png
spikes/**/.godot/ spikes/**/.godot/
spikes/**/*.import spikes/**/*.import
spikes/**/*.uid 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) # Asset downloads (vendor archives, not game content)
docs/assets/downloads/ docs/assets/downloads/
@@ -57,6 +68,7 @@ Thumbs.db
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
.obsidian/
*.swp *.swp
*.swo *.swo
+47
View File
@@ -6,6 +6,53 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased] ## [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 ## [v0.1.31] — 2026-04-05
### Added ### Added
+12
View File
@@ -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. - 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. - `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 ### 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: **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:
-251
View File
@@ -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.
+22
View File
@@ -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.*
+1
View File
@@ -14,6 +14,7 @@
| **GESTALT** | Systems Design & Fun Factor | Mechanics, balance, interesting decisions. "Is this fun to play?" | | **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. | | **NIGEL** | Sandbox & Replayability | Emergent stories, multiple viable strategies, alt-history potential. |
| **TYRE** | Technical Architecture & Feasibility | Engine, tools, what's buildable, reality checks on scope. | | **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. | | **QATUX** | Documenter & Librarian | Maintains decisions, discussions, briefings, Qdrant search index. Archives rounds, updates docs. |
## Specialist Team (task-focused, not in regular discussions) ## 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

+18
View File
@@ -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

+22
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd" FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd"
SessionManager="*res://scripts/autoloads/session_manager.gd" SessionManager="*res://scripts/autoloads/session_manager.gd"
HudGroups="*res://scripts/autoloads/hud_groups.gd"
HardwareDetector="*res://ui/hardware_detector.gd" HardwareDetector="*res://ui/hardware_detector.gd"
[audio] [audio]
+9 -10
View File
@@ -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/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] [ext_resource type="Script" path="res://scripts/rendering/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/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"] [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="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/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/journal_panel.tscn" id="25_journal"]
[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"] [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="."] [node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10 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 ; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")] [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) ; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")] [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) --- ; --- 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="."] [node name="UILayer" type="CanvasLayer" parent="."]
layer = 20 layer = 20
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")] [node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
; D-053: Stance indicator — top-right, color-coded ; D-053: Stance indicator — top-right, color-coded
@@ -170,12 +168,13 @@ layer = 20
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")] [node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
; #511: F3 debug overlay — real-time game state, toggled by F3 ; #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"] [node name="DebugOverlay" type="Control" parent="UILayer"]
anchors_preset = 0 anchors_preset = 0
offset_left = 16 offset_left = 16
offset_top = 120 offset_top = 150
offset_right = 400 offset_right = 400
offset_bottom = 400 offset_bottom = 430
mouse_filter = 2 mouse_filter = 2
script = ExtResource("22_debug") script = ExtResource("22_debug")
+31 -16
View File
@@ -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). # 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. # The tick gate is the caller's responsibility — AudioManager only manages bus volumes.
const DIP_SPECS := { const DIP_SPECS := {
"dialogue": { "dialogue":
"ease_in": 0.3, "ease_out": 0.5, {
"buses": { "Ambient": -7.0 }, "ease_in": 0.3,
"ease_out": 0.5,
"buses": {"Ambient": -7.0},
}, },
"confrontation": { "confrontation":
"ease_in": 0.5, "ease_out": 1.0, {
"buses": { "Ambient": -11.0, "WorldSFX": -5.0 }, "ease_in": 0.5,
"ease_out": 1.0,
"buses": {"Ambient": -11.0, "WorldSFX": -5.0},
"filter_hz": 800.0, "filter_hz": 800.0,
}, },
"listening_focus": { "listening_focus":
"ease_in": 0.5, "ease_out": 0.5, {
"buses": { "WorldSFX": 2.5 }, "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() # 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. # at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
const ZONE_ASSETS: Dictionary = { const ZONE_ASSETS: Dictionary = {
"hub": "amb_hub_layer", "hub": "amb_hub_layer",
"workplace": "amb_hub_layer", "workplace": "amb_hub_layer",
"bar": "amb_bar_layer", "bar": "amb_bar_layer",
"corridor": "amb_corridor_layer", "corridor": "amb_corridor_layer",
} }
const PREFS_PATH := "user://audio_prefs.cfg" 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). # Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
# Missing assets no-op gracefully (D-038 fallback pattern). # Missing assets no-op gracefully (D-038 fallback pattern).
const SOUND_EVENT_ASSETS: Dictionary = { const SOUND_EVENT_ASSETS: Dictionary = {
"Footstep": "sfx_footstep_metal_walk", "Footstep": "sfx_footstep_metal_walk",
"FootstepWalk": "sfx_footstep_metal_walk", "FootstepWalk": "sfx_footstep_metal_walk",
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "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 "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
"FootstepSprint": "sfx_footstep_metal_run", "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 # Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
@@ -110,6 +116,7 @@ func _ready() -> void:
# --- Bus setup --- # --- Bus setup ---
func _setup_buses() -> void: func _setup_buses() -> void:
for bus_name in BUSES: for bus_name in BUSES:
if AudioServer.get_bus_index(bus_name) < 0: if AudioServer.get_bus_index(bus_name) < 0:
@@ -130,6 +137,7 @@ func _setup_buses() -> void:
# --- Asset registry (D-068 directory-scan pattern) --- # --- Asset registry (D-068 directory-scan pattern) ---
func _scan_registry() -> void: func _scan_registry() -> void:
_scan_dir("res://assets/audio/") _scan_dir("res://assets/audio/")
print("AudioManager: %d assets registered" % _registry.size()) print("AudioManager: %d assets registered" % _registry.size())
@@ -166,6 +174,7 @@ func get_registry_size() -> int:
# --- Playback: non-spatial --- # --- Playback: non-spatial ---
## Play a one-shot sound on the given bus. No-ops if asset not in registry. ## 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: func play(asset_key: String, bus: String = BUS_UI_SOUNDS) -> void:
var stream := _get_stream(asset_key) 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) --- # --- Playback: spatial (D-018 close-range) ---
## Play a one-shot spatial sound at a world position (pixels). ## Play a one-shot spatial sound at a world position (pixels).
## Added to current_scene for correct AudioListener2D positioning. ## Added to current_scene for correct AudioListener2D positioning.
func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD_SFX) -> void: 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) --- # --- Audio dip profiles (D-069) ---
## Apply a dip profile. Interrupts any active dip, restoring unaffected buses. ## Apply a dip profile. Interrupts any active dip, restoring unaffected buses.
## Profiles: "dialogue", "confrontation", "listening_focus" ## Profiles: "dialogue", "confrontation", "listening_focus"
func apply_dip(profile: String) -> void: func apply_dip(profile: String) -> void:
@@ -318,6 +329,7 @@ func get_active_dip() -> String:
# --- Volume control (player sliders, D-069 proportional base) --- # --- Volume control (player sliders, D-069 proportional base) ---
## Set the player's volume preference for a bus. During active dip, immediately ## Set the player's volume preference for a bus. During active dip, immediately
## recalculates effective volume (proportional to new base). ## recalculates effective volume (proportional to new base).
func set_volume(bus: String, volume_db: float) -> void: 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) --- # --- Volume persistence (user://audio_prefs.cfg) ---
func _load_prefs() -> void: func _load_prefs() -> void:
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
if cfg.load(PREFS_PATH) != OK: if cfg.load(PREFS_PATH) != OK:
@@ -366,6 +379,7 @@ func _save_prefs() -> void:
# --- Zone crossfade (D-073) --- # --- Zone crossfade (D-073) ---
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot. ## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers. ## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
## Interruptible — mid-crossfade zone change tweens from current position. ## Interruptible — mid-crossfade zone change tweens from current position.
@@ -415,6 +429,7 @@ func _kill_zone_tweens() -> void:
# --- Internal helpers --- # --- Internal helpers ---
func _get_stream(asset_key: String) -> AudioStream: func _get_stream(asset_key: String) -> AudioStream:
return _registry.get(asset_key) as AudioStream return _registry.get(asset_key) as AudioStream
+13 -11
View File
@@ -7,14 +7,14 @@ extends Node
# Fog texture byte values — visibility and exploration layers. # Fog texture byte values — visibility and exploration layers.
# Used by fog shader to distinguish visual treatment per tile. # Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD) # 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). const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569).
# Retained — tests still reference it. # 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_UNEXPLORED: int = 0 # Never seen — total darkness
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog 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_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. # 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. # 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) # bar: #2a1f15 (warm amber-dark — social, inhabited)
# corridor: #1a1a1a (neutral dark — transitional, maintenance) # corridor: #1a1a1a (neutral dark — transitional, maintenance)
const ZONE_TINTS: Dictionary = { 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 "workplace": Color(0.102, 0.122, 0.180), # same as hub
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark "bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral 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 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_g := int(tint_color.g * 255.0)
var new_b := int(tint_color.b * 255.0) var new_b := int(tint_color.b * 255.0)
# Only update if different from current (avoid spurious texture uploads) # 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 + 0] = new_r
_tint_bytes[tint_idx + 1] = new_g _tint_bytes[tint_idx + 1] = new_g
_tint_bytes[tint_idx + 2] = new_b _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: if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
return tile_bounds return tile_bounds
return map_bounds.merge(tile_bounds) return map_bounds.merge(tile_bounds)
+2 -1
View File
@@ -18,7 +18,7 @@ var current_tick: int = 0
var player_position: Vector2 = Vector2.ZERO var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = [] var visible_entities: Array = []
var visible_tiles: 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) var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585)
# — visible in fog but not explored # — visible in fog but not explored
@@ -162,6 +162,7 @@ var stationary_ticks: int = 0
# Empty string when zone_id field absent. # Empty string when zone_id field absent.
var current_zone_id: String = "" var current_zone_id: String = ""
func apply_snapshot(snapshot: Dictionary) -> void: func apply_snapshot(snapshot: Dictionary) -> void:
# Autoload parse-order: class_name types are not registered when autoloads compile. # 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. # load() returns the cached resource after the first call — essentially free per-tick.
+146
View File
@@ -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
+96 -48
View File
@@ -14,10 +14,21 @@ extends Node
# Server cooldown is authoritative, but the client throttle prevents # Server cooldown is authoritative, but the client throttle prevents
# flooding and gives correct movement feel in test mode. # flooding and gives correct movement feel in test mode.
enum Action { enum Action {
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST, MOVE_NORTH,
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST, MOVE_NORTHEAST,
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, MOVE_EAST,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, 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 BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
SET_FACING, # D-054: facing octant update (no movement) 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 SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
LOAD_GAME, # #554: F6 quickload — sends LoadGame 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 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 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) 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) DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
} }
# Minimum milliseconds between movement commands, per stance. # Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable. # Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := { const MOVE_INTERVAL_MS := {
"Sprint": 200, # 5/sec — fast but trackable "Sprint": 200, # 5/sec — fast but trackable
"Walk": 400, # 2.5/sec — comfortable walking pace "Walk": 400, # 2.5/sec — comfortable walking pace
"Careful": 600, # ~1.7/sec — deliberate, scanning "Careful": 600, # ~1.7/sec — deliberate, scanning
"Crouch": 800, # 1.25/sec — creeping "Crouch": 800, # 1.25/sec — creeping
} }
var input_queue: Array[Dictionary] = [] 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. # 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. # Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North 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_sent_octant: String = "North" # Track to avoid redundant sends
var _last_move_msec: int = 0 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) # D-054: Send facing octant to server when it changes (even without movement)
if facing_octant != _last_sent_octant: if facing_octant != _last_sent_octant:
_last_sent_octant = facing_octant _last_sent_octant = facing_octant
input_queue.append({ (
"action": Action.SET_FACING, input_queue
"timestamp_msec": Time.get_ticks_msec(), . append(
"action_data": {"facing": facing_octant}, {
}) "action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
}
)
)
# Poll held WASD keys # Poll held WASD keys
var raw_dir := Vector2i.ZERO var raw_dir := Vector2i.ZERO
@@ -88,10 +104,15 @@ func _process(_delta: float) -> void:
# D-054: Transform WASD input relative to mouse facing # D-054: Transform WASD input relative to mouse facing
var world_dir := _wasd_to_world_dir(raw_dir) var world_dir := _wasd_to_world_dir(raw_dir)
var action: Action = _dir_to_action(world_dir) var action: Action = _dir_to_action(world_dir)
input_queue.append({ (
"action": action, input_queue
"timestamp_msec": now, . append(
}) {
"action": action,
"timestamp_msec": now,
}
)
)
# Discrete actions: fire once on key press (not held). # 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) # Quantize to nearest 45° (PI/4)
var octant := roundi(angle / (PI / 4.0)) var octant := roundi(angle / (PI / 4.0))
match octant: match octant:
0: return Vector2i(1, 0) # East 0:
1: return Vector2i(1, 1) # Southeast return Vector2i(1, 0) # East
2, -6: return Vector2i(0, 1) # South 1:
3, -5: return Vector2i(-1, 1) # Southwest return Vector2i(1, 1) # Southeast
4, -4: return Vector2i(-1, 0) # West 2, -6:
-3, 5: return Vector2i(-1, -1) # Northwest return Vector2i(0, 1) # South
-2: return Vector2i(0, -1) # North 3, -5:
-1: return Vector2i(1, -1) # Northeast return Vector2i(-1, 1) # Southwest
_: return Vector2i.ZERO 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. # 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: static func _angle_to_octant(angle: float) -> String:
var octant := roundi(angle / (PI / 4.0)) var octant := roundi(angle / (PI / 4.0))
match octant: match octant:
0: return "East" 0:
1: return "Southeast" return "East"
2, -6: return "South" 1:
3, -5: return "Southwest" return "Southeast"
4, -4: return "West" 2, -6:
-3, 5: return "Northwest" return "South"
-2: return "North" 3, -5:
-1: return "Northeast" return "Southwest"
_: return "East" 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. # Map a direction vector to the corresponding movement Action.
# Handles all 8 directions via composite W+D, W+A, etc. # Handles all 8 directions via composite W+D, W+A, etc.
static func _dir_to_action(dir: Vector2i) -> Action: static func _dir_to_action(dir: Vector2i) -> Action:
match dir: match dir:
Vector2i(0, -1): return Action.MOVE_NORTH Vector2i(0, -1):
Vector2i(1, -1): return Action.MOVE_NORTHEAST return Action.MOVE_NORTH
Vector2i(1, 0): return Action.MOVE_EAST Vector2i(1, -1):
Vector2i(1, 1): return Action.MOVE_SOUTHEAST return Action.MOVE_NORTHEAST
Vector2i(0, 1): return Action.MOVE_SOUTH Vector2i(1, 0):
Vector2i(-1, 1): return Action.MOVE_SOUTHWEST return Action.MOVE_EAST
Vector2i(-1, 0): return Action.MOVE_WEST Vector2i(1, 1):
Vector2i(-1, -1): return Action.MOVE_NORTHWEST return Action.MOVE_SOUTHEAST
_: return Action.MOVE_NORTH 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
+65 -65
View File
@@ -16,7 +16,6 @@ extends Node
## Paths: Read-once OS path constants, populated in _ready(). ## Paths: Read-once OS path constants, populated in _ready().
## Diag: get_diagnostics() — flat dict of all properties for bug reports. ## Diag: get_diagnostics() — flat dict of all properties for bug reports.
# -- Power profile ------------------------------------------------------------ # -- Power profile ------------------------------------------------------------
## Emitted when the detected power profile changes. ## 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. ## High-level power classification. POWER_SAVER reserved for future OS API.
enum PowerProfile { enum PowerProfile {
FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions FULL = 0, ## Plugged in — no restrictions
BATTERY = 1, # On battery — AI inference should be suspended per D-138 §8 Layer 3 BATTERY = 1, ## On battery — suspend AI inference per D-138 §8 Layer 3
POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet) POWER_SAVER = 2, ## System-level power-saver mode (future)
} }
## How often (seconds) to re-poll OS for power state changes. ## How often (seconds) to re-poll OS for power state changes.
const POWER_POLL_INTERVAL := 30.0 const POWER_POLL_INTERVAL := 30.0
# OS power_state integer constants (Godot 4 — same values as POWERSTATE_* enum). # 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_ON_BATTERY := 1
const _POWER_STATE_NO_BATTERY := 2 const _POWER_STATE_NO_BATTERY := 2
const _POWER_STATE_CHARGING := 3 const _POWER_STATE_CHARGING := 3
const _POWER_STATE_CHARGED := 4 const _POWER_STATE_CHARGED := 4
## Current power profile. Updated by the 30-second poll timer. ## Current power profile. Updated by the 30-second poll timer.
var power_profile: PowerProfile = PowerProfile.FULL 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. ## Battery charge percentage (0–100). -1 if not available or not on battery.
var battery_percent: int = -1 var battery_percent: int = -1
# -- Memory ------------------------------------------------------------------- # -- Memory -------------------------------------------------------------------
## Free physical RAM in megabytes. Call refresh_memory() before reading if staleness matters. ## 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. ## Total physical RAM in megabytes. Populated once in _ready(); doesn't change at runtime.
var total_memory_mb: float = 0.0 var total_memory_mb: float = 0.0
# -- CPU ---------------------------------------------------------------------- # -- CPU ----------------------------------------------------------------------
## Human-readable processor name (e.g. "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz"). ## 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. ## Number of logical CPU cores reported by the OS.
var cpu_logical_cores: int = 0 var cpu_logical_cores: int = 0
# -- GPU ---------------------------------------------------------------------- # -- GPU ----------------------------------------------------------------------
## GPU display name (e.g. "NVIDIA GeForce RTX 3080"). ## 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). ## Low-level driver info strings from the OS (may be empty on some platforms).
var gpu_driver_info: PackedStringArray = PackedStringArray() var gpu_driver_info: PackedStringArray = PackedStringArray()
# -- Platform identity -------------------------------------------------------- # -- Platform identity --------------------------------------------------------
## OS name reported by Godot (e.g. "Linux", "Windows", "macOS"). ## 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.). ## True if the application is running inside a sandbox (Flatpak, Snap, macOS sandbox, etc.).
var is_sandboxed: bool = false var is_sandboxed: bool = false
# -- Display ------------------------------------------------------------------ # -- Display ------------------------------------------------------------------
## Number of connected screens. ## 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. ## UI scale factor. Uses OS-reported scale on macOS; falls back to dpi / 96.0 elsewhere.
var display_scale: float = 1.0 var display_scale: float = 1.0
# -- Locale ------------------------------------------------------------------- # -- Locale -------------------------------------------------------------------
## Full locale string (e.g. "en_US"). ## Full locale string (e.g. "en_US").
@@ -126,7 +119,6 @@ var locale: String = ""
## Language portion of the locale (e.g. "en"). ## Language portion of the locale (e.g. "en").
var locale_language: String = "" var locale_language: String = ""
# -- File paths --------------------------------------------------------------- # -- File paths ---------------------------------------------------------------
## Base user data directory (user://). ## 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). ## Directory where the bundled Gemma 2 model lives (derived from install_dir).
var model_dir: String = "" var model_dir: String = ""
# -- Lifecycle ---------------------------------------------------------------- # -- Lifecycle ----------------------------------------------------------------
func _ready() -> void: func _ready() -> void:
_init_paths() _init_paths()
_init_hardware() _init_hardware()
@@ -167,12 +159,12 @@ func _ready() -> void:
func _init_paths() -> void: func _init_paths() -> void:
user_data_dir = OS.get_user_data_dir() user_data_dir = OS.get_user_data_dir()
config_dir = user_data_dir config_dir = user_data_dir
benchmark_cache_path = "user://ai-dialogue-config.json" benchmark_cache_path = "user://ai-dialogue-config.json"
executable_path = OS.get_executable_path() executable_path = OS.get_executable_path()
install_dir = executable_path.get_base_dir() install_dir = executable_path.get_base_dir()
model_dir = install_dir + "/models" model_dir = install_dir + "/models"
# get_cache_dir() may not exist on all Godot builds — use callv for safety. # get_cache_dir() may not exist on all Godot builds — use callv for safety.
var os_cache: String = "" var os_cache: String = ""
if OS.has_method("get_cache_dir"): if OS.has_method("get_cache_dir"):
@@ -184,12 +176,12 @@ func _init_paths() -> void:
func _init_hardware() -> void: func _init_hardware() -> void:
# -- CPU # -- CPU
cpu_name = OS.get_processor_name() cpu_name = OS.get_processor_name()
cpu_logical_cores = OS.get_processor_count() cpu_logical_cores = OS.get_processor_count()
# -- GPU (guarded: RenderingServer methods may return empty in headless mode) # -- GPU (guarded: RenderingServer methods may return empty in headless mode)
gpu_name = RenderingServer.get_video_adapter_name() gpu_name = RenderingServer.get_video_adapter_name()
gpu_vendor = RenderingServer.get_video_adapter_vendor() gpu_vendor = RenderingServer.get_video_adapter_vendor()
gpu_api_version = RenderingServer.get_video_adapter_api_version() gpu_api_version = RenderingServer.get_video_adapter_api_version()
gpu_driver_info = OS.get_video_adapter_driver_info() gpu_driver_info = OS.get_video_adapter_driver_info()
if RenderingServer.has_method("get_video_adapter_type"): if RenderingServer.has_method("get_video_adapter_type"):
@@ -198,7 +190,7 @@ func _init_hardware() -> void:
# -- Platform identity # -- Platform identity
platform_name = OS.get_name() platform_name = OS.get_name()
os_version = OS.get_version() os_version = OS.get_version()
if OS.has_method("get_distribution_name"): if OS.has_method("get_distribution_name"):
var dist: Variant = OS.callv("get_distribution_name", []) var dist: Variant = OS.callv("get_distribution_name", [])
if dist is String: if dist is String:
@@ -207,14 +199,14 @@ func _init_hardware() -> void:
# -- Display # -- Display
screen_count = DisplayServer.get_screen_count() screen_count = DisplayServer.get_screen_count()
screen_size = DisplayServer.screen_get_size() screen_size = DisplayServer.screen_get_size()
screen_dpi = DisplayServer.screen_get_dpi() screen_dpi = DisplayServer.screen_get_dpi()
var raw_rate: float = DisplayServer.screen_get_refresh_rate() var raw_rate: float = DisplayServer.screen_get_refresh_rate()
screen_refresh_rate = raw_rate if raw_rate >= 0.0 else 60.0 screen_refresh_rate = raw_rate if raw_rate >= 0.0 else 60.0
display_scale = _compute_display_scale() display_scale = _compute_display_scale()
# -- Locale # -- Locale
locale = OS.get_locale() locale = OS.get_locale()
locale_language = OS.get_locale_language() 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. ## Values: 0=OTHER, 1=INTEGRATED, 2=DISCRETE, 3=VIRTUAL, 4=CPU.
static func _map_adapter_type(adapter_type: int) -> String: static func _map_adapter_type(adapter_type: int) -> String:
match adapter_type: match adapter_type:
1: return "integrated" 1:
2: return "discrete" return "integrated"
3: return "virtual" 2:
4: return "cpu" return "discrete"
_: return "other" 3:
return "virtual"
4:
return "cpu"
_:
return "other"
## Compute display scale for the primary screen. ## Compute display scale for the primary screen.
@@ -241,17 +238,19 @@ func _compute_display_scale() -> float:
# -- Memory API --------------------------------------------------------------- # -- Memory API ---------------------------------------------------------------
## Refresh free/total memory readings from the OS. Call before read if freshness matters. ## Refresh free/total memory readings from the OS. Call before read if freshness matters.
func refresh_memory() -> void: func refresh_memory() -> void:
var mem := OS.get_memory_info() var mem := OS.get_memory_info()
# "available" = Linux MemAvailable / Windows ullAvailPhys (includes reclaimable pages). # "available" = Linux MemAvailable / Windows ullAvailPhys (includes reclaimable pages).
# Fall back to "free" on platforms that don't provide "available". # 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) total_memory_mb = float(mem.get("physical", 0)) / (1024.0 * 1024.0)
# -- Power API ---------------------------------------------------------------- # -- Power API ----------------------------------------------------------------
## Classify an OS power_state integer into a human-readable string. ## Classify an OS power_state integer into a human-readable string.
## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown". ## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown".
static func classify_power_state(power_state: int) -> String: 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: if raw_power_state == _POWER_STATE_ON_BATTERY:
battery_percent = int(info.get("percent", -1)) battery_percent = int(info.get("percent", -1))
var new_profile: PowerProfile = PowerProfile.BATTERY \ var new_profile: PowerProfile = (
if raw_power_state == _POWER_STATE_ON_BATTERY \ PowerProfile.BATTERY if raw_power_state == _POWER_STATE_ON_BATTERY else PowerProfile.FULL
else PowerProfile.FULL )
if new_profile != power_profile: if new_profile != power_profile:
var old_profile := power_profile var old_profile := power_profile
power_profile = new_profile power_profile = new_profile
@@ -304,45 +303,46 @@ func _poll_power() -> void:
# -- Diagnostics -------------------------------------------------------------- # -- Diagnostics --------------------------------------------------------------
## Returns all platform properties as a flat dictionary for inclusion in bug reports. ## Returns all platform properties as a flat dictionary for inclusion in bug reports.
func get_diagnostics() -> Dictionary: func get_diagnostics() -> Dictionary:
return { return {
# Power # Power
"power_profile": int(power_profile), "power_profile": int(power_profile),
"raw_power_state": raw_power_state, "raw_power_state": raw_power_state,
"battery_percent": battery_percent, "battery_percent": battery_percent,
# Memory # Memory
"free_memory_mb": free_memory_mb, "free_memory_mb": free_memory_mb,
"total_memory_mb": total_memory_mb, "total_memory_mb": total_memory_mb,
# CPU # CPU
"cpu_name": cpu_name, "cpu_name": cpu_name,
"cpu_logical_cores": cpu_logical_cores, "cpu_logical_cores": cpu_logical_cores,
# GPU # GPU
"gpu_name": gpu_name, "gpu_name": gpu_name,
"gpu_vendor": gpu_vendor, "gpu_vendor": gpu_vendor,
"gpu_api_version": gpu_api_version, "gpu_api_version": gpu_api_version,
"gpu_type": gpu_type, "gpu_type": gpu_type,
"gpu_driver_info": Array(gpu_driver_info), "gpu_driver_info": Array(gpu_driver_info),
# Platform # Platform
"platform_name": platform_name, "platform_name": platform_name,
"os_version": os_version, "os_version": os_version,
"distribution": distribution, "distribution": distribution,
"is_sandboxed": is_sandboxed, "is_sandboxed": is_sandboxed,
# Display # Display
"screen_count": screen_count, "screen_count": screen_count,
"screen_size": {"x": screen_size.x, "y": screen_size.y}, "screen_size": {"x": screen_size.x, "y": screen_size.y},
"screen_dpi": screen_dpi, "screen_dpi": screen_dpi,
"screen_refresh_rate": screen_refresh_rate, "screen_refresh_rate": screen_refresh_rate,
"display_scale": display_scale, "display_scale": display_scale,
# Locale # Locale
"locale": locale, "locale": locale,
"locale_language": locale_language, "locale_language": locale_language,
# Paths # Paths
"user_data_dir": user_data_dir, "user_data_dir": user_data_dir,
"config_dir": config_dir, "config_dir": config_dir,
"benchmark_cache_path": benchmark_cache_path, "benchmark_cache_path": benchmark_cache_path,
"install_dir": install_dir, "install_dir": install_dir,
"executable_path": executable_path, "executable_path": executable_path,
"cache_dir": cache_dir, "cache_dir": cache_dir,
"model_dir": model_dir, "model_dir": model_dir,
} }
+49 -20
View File
@@ -17,18 +17,26 @@ var _quit_dialog: ConfirmationDialog = null
## Returns the new game-id string. ## Returns the new game-id string.
func new_game() -> String: func new_game() -> String:
var now := Time.get_datetime_dict_from_system() var now := Time.get_datetime_dict_from_system()
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [ var timestamp := (
now.year, now.month, now.day, "%04d%02d%02d-%02d%02d%02d"
now.hour, now.minute, now.second, % [
] now.year,
now.month,
now.day,
now.hour,
now.minute,
now.second,
]
)
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF) var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
var game_id := "%s-%s" % [timestamp, hex_seed] var game_id := "%s-%s" % [timestamp, hex_seed]
var save_path := SAVES_DIR + game_id + "/" var save_path := SAVES_DIR + game_id + "/"
var err := DirAccess.make_dir_recursive_absolute(save_path) var err := DirAccess.make_dir_recursive_absolute(save_path)
if err != OK: if err != OK:
push_error("SessionManager: failed to create save dir %s: %s" % [ push_error(
save_path, error_string(err)]) "SessionManager: failed to create save dir %s: %s" % [save_path, error_string(err)]
)
return "" return ""
GameState.current_game_id = game_id GameState.current_game_id = game_id
@@ -71,15 +79,21 @@ func list_game_dirs() -> Array:
var mtime: int = 0 var mtime: int = 0
if newest_save != "": if newest_save != "":
mtime = FileAccess.get_modified_time(dir_path + newest_save) mtime = FileAccess.get_modified_time(dir_path + newest_save)
results.append({ (
"game_id": entry, results
"modified_time": mtime, . append(
"newest_save": newest_save, {
}) "game_id": entry,
"modified_time": mtime,
"newest_save": newest_save,
}
)
)
entry = dir.get_next() entry = dir.get_next()
dir.list_dir_end() dir.list_dir_end()
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: results.sort_custom(
return a.modified_time > b.modified_time) func(a: Dictionary, b: Dictionary) -> bool: return a.modified_time > b.modified_time
)
return results return results
@@ -105,11 +119,16 @@ func _do_quit_to_menu() -> void:
# SimBridge._process() flushes the outbound buffer before teardown. # SimBridge._process() flushes the outbound buffer before teardown.
if not GameState.current_game_id.is_empty(): if not GameState.current_game_id.is_empty():
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav" var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
SimBridge.send_input({ (
"action": InputMapper.Action.SAVE_GAME, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
"action_data": {"path": path}, {
}) "action": InputMapper.Action.SAVE_GAME,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"path": path},
}
)
)
GameState.current_game_id = "" GameState.current_game_id = ""
_navigate_to_menu.call_deferred() _navigate_to_menu.call_deferred()
else: else:
@@ -131,7 +150,12 @@ func _cleanup_quit_dialog() -> void:
func _write_seed_file(save_path: String, seed: int) -> void: func _write_seed_file(save_path: String, seed: int) -> void:
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE) var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
if file == null: 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 return
file.store_64(seed) 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 save_path := SAVES_DIR + game_id + "/"
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE) var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
if file == null: 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 return
file.store_string(archetype) file.store_string(archetype)
+129 -50
View File
@@ -34,37 +34,50 @@ var _retry_timer: float = 0.0
var _handshake_start_usec: int = 0 var _handshake_start_usec: int = 0
var _test_tick: int: var _test_tick: int:
get: return harness.tick if harness else 0 get:
return harness.tick if harness else 0
set(v): set(v):
if harness: harness.tick = v if harness:
harness.tick = v
var _test_player_pos: Vector2i: 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): set(v):
if harness: harness.player_pos = v if harness:
harness.player_pos = v
var _test_facing: String: var _test_facing: String:
get: return harness.facing if harness else "North" get:
return harness.facing if harness else "North"
set(v): set(v):
if harness: harness.facing = v if harness:
harness.facing = v
var _test_in_dialogue: bool: 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): set(v):
if harness: harness.in_dialogue = v if harness:
harness.in_dialogue = v
var _test_gauntlet_mode: bool: 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): set(v):
if harness: harness.gauntlet_mode = v if harness:
harness.gauntlet_mode = v
var _test_npc_relationship: String: 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): set(v):
if harness: harness.npc_relationship = v if harness:
harness.npc_relationship = v
var _test_input_queue: Array: var _test_input_queue: Array:
get: return harness.input_queue if harness else [] get:
return harness.input_queue if harness else []
func _ready() -> void: func _ready() -> void:
@@ -75,18 +88,23 @@ func _ready() -> void:
# -- Test mode proxy API (backward compat for 13+ test files) ------------------ # -- Test mode proxy API (backward compat for 13+ test files) ------------------
func reset_test_state() -> void: func reset_test_state() -> void:
if harness: harness.reset() if harness:
harness.reset()
func _test_snapshot() -> Dictionary: func _test_snapshot() -> Dictionary:
return harness.snapshot() return harness.snapshot()
func _test_has_los(from: Vector2i, to: Vector2i) -> bool: func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
return harness.has_los(from, to) return harness.has_los(from, to)
# -- Connection lifecycle ------------------------------------------------------ # -- Connection lifecycle ------------------------------------------------------
# Change connection state and emit signal # Change connection state and emit signal
func _set_state(new_state: ConnectionState) -> void: func _set_state(new_state: ConnectionState) -> void:
if state != new_state: if state != new_state:
@@ -94,6 +112,7 @@ func _set_state(new_state: ConnectionState) -> void:
state = new_state state = new_state
connection_state_changed.emit(old_state, new_state) connection_state_changed.emit(old_state, new_state)
# Connect to simulation server. # Connect to simulation server.
# In test mode, immediately transitions to CONNECTED. # In test mode, immediately transitions to CONNECTED.
# In live mode, spawns server subprocess and defers TCP connection to _process() # 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 _retry_timer = 0.0
_bridge = null _bridge = null
# Disconnect from simulation server # Disconnect from simulation server
func disconnect_from_sim() -> void: func disconnect_from_sim() -> void:
if _bridge != null: if _bridge != null:
@@ -136,15 +156,21 @@ func disconnect_from_sim() -> void:
_connect_retries = 0 _connect_retries = 0
_set_state(ConnectionState.DISCONNECTED) _set_state(ConnectionState.DISCONNECTED)
# Attempt TCP connection. Called from _process() during CONNECTING state. # Attempt TCP connection. Called from _process() during CONNECTING state.
func _try_connect() -> void: func _try_connect() -> void:
_bridge = LocalBridge.new() _bridge = LocalBridge.new()
var err := _bridge.connect_to_server("127.0.0.1", server_port) var err := _bridge.connect_to_server("127.0.0.1", server_port)
if err != OK: if err != OK:
push_warning("SimBridge: TCP connect attempt %d/%d failed: %s" % [ push_warning(
_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)]) (
"SimBridge: TCP connect attempt %d/%d failed: %s"
% [_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)]
)
)
_bridge = null _bridge = null
# Poll transport layer every frame (non-test mode only) # Poll transport layer every frame (non-test mode only)
func _process(delta: float) -> void: # gdlint:disable=max-returns func _process(delta: float) -> void: # gdlint:disable=max-returns
if test_mode: if test_mode:
@@ -158,7 +184,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_retry_timer = 0.0 _retry_timer = 0.0
_connect_retries += 1 _connect_retries += 1
if _connect_retries > MAX_CONNECT_RETRIES: 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) _set_state(ConnectionState.ERROR)
return return
_try_connect() _try_connect()
@@ -176,7 +204,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Connection attempt failed — retry # Connection attempt failed — retry
_bridge = null _bridge = null
if _connect_retries >= MAX_CONNECT_RETRIES: 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) _set_state(ConnectionState.ERROR)
StreamPeerTCP.STATUS_NONE: StreamPeerTCP.STATUS_NONE:
_bridge = null # Reset and retry _bridge = null # Reset and retry
@@ -191,7 +221,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Check connection dropped during handshake # Check connection dropped during handshake
var bridge_status := _bridge.get_status() 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" var reason := "Connection dropped during handshake"
push_error("SimBridge: %s" % reason) push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason) handshake_failed.emit(reason)
@@ -215,8 +248,11 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# Decode HandshakeMessage: { "protocol_version": N } # Decode HandshakeMessage: { "protocol_version": N }
var decoded: Variant = Messagepack.decode(msg) var decoded: Variant = Messagepack.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary) \ if (
or not decoded.value.has("protocol_version"): decoded.status != null
or not (decoded.value is Dictionary)
or not decoded.value.has("protocol_version")
):
var reason := "Handshake decode failed: malformed HandshakeMessage" var reason := "Handshake decode failed: malformed HandshakeMessage"
push_error("SimBridge: %s" % reason) push_error("SimBridge: %s" % reason)
handshake_failed.emit(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"] var server_version: int = decoded.value["protocol_version"]
if server_version != Protocol.PROTOCOL_VERSION: if server_version != Protocol.PROTOCOL_VERSION:
var reason := "Protocol version mismatch: server=%d, client=%d" % [ var reason := (
server_version, Protocol.PROTOCOL_VERSION] "Protocol version mismatch: server=%d, client=%d"
% [server_version, Protocol.PROTOCOL_VERSION]
)
push_error("SimBridge: %s" % reason) push_error("SimBridge: %s" % reason)
handshake_failed.emit(reason) handshake_failed.emit(reason)
_bridge.disconnect_from_server() _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). # 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. # Server blocks waiting for this before entering the tick loop.
var startup_bytes := Protocol.encode_startup_message( 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: if startup_bytes.size() > 0:
var send_err := _bridge.send_message(startup_bytes) var send_err := _bridge.send_message(startup_bytes)
if send_err != OK: if send_err != OK:
@@ -259,10 +300,15 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
_set_state(ConnectionState.CONNECTED) _set_state(ConnectionState.CONNECTED)
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled # #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). # from server SQLite so the client reflects the authoritative persisted state (D-138).
_outbound_buffer.append({ (
"tick": 0, _outbound_buffer
"action_name": "RequestAllSettings", . append(
}) {
"tick": 0,
"action_name": "RequestAllSettings",
}
)
)
return return
if _bridge == null: if _bridge == null:
@@ -289,7 +335,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
if err != OK: if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err)) push_error("SimBridge: failed to send message: %s" % error_string(err))
else: 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: StreamPeerTCP.STATUS_CONNECTING:
pass # Should not happen in CONNECTED state pass # Should not happen in CONNECTED state
StreamPeerTCP.STATUS_ERROR: StreamPeerTCP.STATUS_ERROR:
@@ -304,6 +352,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
# -- Input / snapshot ---------------------------------------------------------- # -- Input / snapshot ----------------------------------------------------------
# Send input to simulation server. # Send input to simulation server.
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec". # player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
# In test mode, inputs are delegated to the test harness. # 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(): if action_name.is_empty():
return ERR_INVALID_PARAMETER return ERR_INVALID_PARAMETER
var tick: int = GameState.current_tick 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") var action_data: Variant = player_input.get("action_data")
if action_data != null: if action_data != null:
entry["action_data"] = action_data entry["action_data"] = action_data
_outbound_buffer.append(entry) _outbound_buffer.append(entry)
return OK return OK
# Poll for snapshot from simulation. # Poll for snapshot from simulation.
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot. # In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
func poll_snapshot() -> Variant: func poll_snapshot() -> Variant:
@@ -356,6 +406,7 @@ func poll_snapshot() -> Variant:
return null return null
# Called by transport layer when raw bytes arrive from the server. # Called by transport layer when raw bytes arrive from the server.
# Latest-wins for positional state (stale frames are worthless), but one-shot # Latest-wins for positional state (stale frames are worthless), but one-shot
# events (monologue, dialogue) are carried forward from overwritten snapshots # events (monologue, dialogue) are carried forward from overwritten snapshots
@@ -367,12 +418,21 @@ func receive_bytes(bytes: PackedByteArray) -> void:
return return
if _last_snapshot != null: if _last_snapshot != null:
# Carry forward one-shot events the client hasn't consumed yet. # 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"] 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"] snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through) # #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"] snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
var old_conv_events: Array = _last_snapshot.get("conversation_events", []) var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
if old_conv_events.size() > 0: 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: if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
snapshot["save_result"] = _last_snapshot["save_result"] snapshot["save_result"] = _last_snapshot["save_result"]
# #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot) # #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"] snapshot["settings_response"] = _last_snapshot["settings_response"]
_last_snapshot = snapshot _last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding. # Drain the outbound buffer. Returns raw input entries for batch encoding.
func drain_outbound() -> Array[Dictionary]: func drain_outbound() -> Array[Dictionary]:
var inputs = _outbound_buffer.duplicate() var inputs = _outbound_buffer.duplicate()
@@ -399,24 +463,39 @@ func drain_outbound() -> Array[Dictionary]:
# -- Wire protocol mapping ----------------------------------------------------- # -- Wire protocol mapping -----------------------------------------------------
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction). # 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. # OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
static func action_enum_to_wire(action: int) -> String: static func action_enum_to_wire(action: int) -> String:
match action: match action:
InputMapper.Action.MOVE_NORTH: return "MoveNorth" InputMapper.Action.MOVE_NORTH:
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast" return "MoveNorth"
InputMapper.Action.MOVE_EAST: return "MoveEast" InputMapper.Action.MOVE_NORTHEAST:
InputMapper.Action.MOVE_SOUTHEAST: return "MoveSoutheast" return "MoveNortheast"
InputMapper.Action.MOVE_SOUTH: return "MoveSouth" InputMapper.Action.MOVE_EAST:
InputMapper.Action.MOVE_SOUTHWEST: return "MoveSouthwest" return "MoveEast"
InputMapper.Action.MOVE_WEST: return "MoveWest" InputMapper.Action.MOVE_SOUTHEAST:
InputMapper.Action.MOVE_NORTHWEST: return "MoveNorthwest" return "MoveSoutheast"
InputMapper.Action.INTERACT: return "Interact" InputMapper.Action.MOVE_SOUTH:
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode" return "MoveSouth"
InputMapper.Action.PAUSE: return "Pause" InputMapper.Action.MOVE_SOUTHWEST:
InputMapper.Action.UNPAUSE: return "Unpause" return "MoveSouthwest"
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp" InputMapper.Action.MOVE_WEST:
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown" 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: InputMapper.Action.OPEN_MENU:
return "" # Client-only action, not part of wire protocol return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT: InputMapper.Action.BUG_REPORT:
@@ -432,11 +511,11 @@ static func action_enum_to_wire(action: int) -> String:
InputMapper.Action.DEBUG_COMMAND: InputMapper.Action.DEBUG_COMMAND:
return "DebugCommand" # #581: debug console command dispatch return "DebugCommand" # #581: debug console command dispatch
InputMapper.Action.CHANGE_SETTINGS: 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: InputMapper.Action.REQUEST_ALL_SETTINGS:
return "RequestAllSettings" # #646: unit variant — server sends full settings dump return "RequestAllSettings" # #646: unit variant — server sends full settings dump
InputMapper.Action.DELETE_SETTING: 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) push_warning("SimBridge: unknown action enum %s" % action)
return "" return ""
+7
View File
@@ -9,9 +9,11 @@ const STRINGS_PATH: String = "res://data/ui-strings.yaml"
var _strings: Dictionary = {} var _strings: Dictionary = {}
var _loaded: bool = false var _loaded: bool = false
func _ready() -> void: func _ready() -> void:
_load_strings() _load_strings()
func _load_strings() -> void: func _load_strings() -> void:
if not FileAccess.file_exists(STRINGS_PATH): if not FileAccess.file_exists(STRINGS_PATH):
push_warning("UIStrings: file not found: %s" % STRINGS_PATH) push_warning("UIStrings: file not found: %s" % STRINGS_PATH)
@@ -26,6 +28,7 @@ func _load_strings() -> void:
_loaded = true _loaded = true
print("UIStrings: loaded %d strings from %s" % [_strings.size(), STRINGS_PATH]) 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. ## Get a UI string by dotted key. Returns the key itself if not found.
func get_text(key: String) -> String: func get_text(key: String) -> String:
if _strings.has(key): if _strings.has(key):
@@ -33,20 +36,24 @@ func get_text(key: String) -> String:
push_warning("UIStrings: missing key '%s'" % key) push_warning("UIStrings: missing key '%s'" % key)
return key return key
## Check if a key exists. ## Check if a key exists.
func has_key(key: String) -> bool: func has_key(key: String) -> bool:
return _strings.has(key) return _strings.has(key)
## Get all keys. ## Get all keys.
func get_all_keys() -> PackedStringArray: func get_all_keys() -> PackedStringArray:
return PackedStringArray(_strings.keys()) return PackedStringArray(_strings.keys())
## Reload from disk (useful for hot-reload during development). ## Reload from disk (useful for hot-reload during development).
func reload() -> void: func reload() -> void:
_strings.clear() _strings.clear()
_loaded = false _loaded = false
_load_strings() _load_strings()
## Parse YAML with arbitrary nesting depth. ## Parse YAML with arbitrary nesting depth.
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }. ## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
## Delegates to YamlParser.parse_flat() (#560). ## Delegates to YamlParser.parse_flat() (#560).
+25 -14
View File
@@ -15,11 +15,11 @@ extends RefCounted
## ##
## Spec ref: D-030 (testability), checklist.schema.json (#497). ## Spec ref: D-030 (testability), checklist.schema.json (#497).
var _room_conditions: Array = [] # Conditions from per-room checklist var _room_conditions: Array = [] # Conditions from per-room checklist
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
var _latched: Dictionary = {} # condition_id -> true (once met, stays met) var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
var _current_room_id: String = "" 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 var _loaded: bool = false
@@ -54,8 +54,7 @@ func load_room(room_id: String) -> void:
_latched = kept _latched = kept
# Load per-room checklist # Load per-room checklist
var room_path := _content_base.path_join( var room_path := _content_base.path_join("gauntlet/rooms/%s/checklist.yaml" % room_id)
"gauntlet/rooms/%s/checklist.yaml" % room_id)
var room_data := _load_checklist_file(room_path) var room_data := _load_checklist_file(room_path)
if room_data.has("conditions"): if room_data.has("conditions"):
_room_conditions = room_data["conditions"] _room_conditions = room_data["conditions"]
@@ -90,12 +89,17 @@ func get_results() -> Array:
var cid: String = cond.get("id", "") var cid: String = cond.get("id", "")
if cid.is_empty(): if cid.is_empty():
continue continue
results.append({ (
"id": cid, results
"description": cond.get("description", ""), . append(
"condition_type": cond.get("condition_type", ""), {
"met": _latched.has(cid), "id": cid,
}) "description": cond.get("description", ""),
"condition_type": cond.get("condition_type", ""),
"met": _latched.has(cid),
}
)
)
return results return results
@@ -136,12 +140,17 @@ static func _warn_empty_ids(conditions: Array, path: String) -> void:
for i in conditions.size(): for i in conditions.size():
if conditions[i].get("id", "").is_empty(): if conditions[i].get("id", "").is_empty():
push_warning( 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 ------------------------------------------------------ # -- 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", ""): match cond.get("condition_type", ""):
"player_near": "player_near":
return _eval_player_near(cond) return _eval_player_near(cond)
@@ -225,6 +234,7 @@ func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
# -- Helpers ------------------------------------------------------------------- # -- Helpers -------------------------------------------------------------------
func _find_entity(entity_id: int) -> bool: func _find_entity(entity_id: int) -> bool:
for entity in GameState.visible_entities: for entity in GameState.visible_entities:
if not entity is Dictionary: if not entity is Dictionary:
@@ -236,6 +246,7 @@ func _find_entity(entity_id: int) -> bool:
# -- YAML parsing -------------------------------------------------------------- # -- YAML parsing --------------------------------------------------------------
func _load_checklist_file(path: String) -> Dictionary: func _load_checklist_file(path: String) -> Dictionary:
if not FileAccess.file_exists(path): if not FileAccess.file_exists(path):
return {} return {}
+46 -34
View File
@@ -12,35 +12,35 @@ const TILE_SIZE: int = 32
# All world content composited into one texture, then fog drawn over it. # All world content composited into one texture, then fog drawn over it.
# Y-sort contract: all children of YSortGroup MUST use z_index = 0. # Y-sort contract: all children of YSortGroup MUST use z_index = 0.
# z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715). # z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715).
const Z_FLOOR: int = 0 # Floor tiles — ground plane const Z_FLOOR: int = 0 # Floor tiles — ground plane
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
# z:20-99 reserved: liquid surface (z:110), surface effects # 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:110 reserved: liquid surface occlusion (alpha by depth)
# z:150-199 reserved: ground VFX (smoke origins, gas pools, sparks) # 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) # 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) # 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_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_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
# z:500-899 reserved: edge cases # z:500-899 reserved: edge cases
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader 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_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) # 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 # z:-75 to z:-51 reserved: lower floor VFX
# #
# INSERT SCOPE (CanvasLayer 10) # INSERT SCOPE (CanvasLayer 10)
# Bloom-rendered, not affected by fog or camera transform. # 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) # UI SCOPE (CanvasLayer 20)
# HUD, monologue, cursor — always visible. # 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) # MODAL SCOPE (CanvasLayer 30)
# Full-screen overlays: pause, inventory modal, death screen. # 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. # Rendering ceiling: 10 floors (25m) above current floor.
# Above this: no sprites, ground shadows + environmental effects only. # 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. # 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 1: default colors mapped by entity kind (Player/Npc/Object/Terrain).
# Phase 2 (#361): colors derived from RelationshipState via the knowledge graph. # 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_FRIENDLY: Color = Color("#6bc9a6") # Known/Friendly — soft green
const ENTITY_COLOR_POI: Color = Color("#e8c547") # Person of Interest — warm amber 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_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — muted red
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective) const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
# D-048/D-056: Insert-styled UI color palette # D-048/D-056: Insert-styled UI color palette
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI. # 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_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI 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_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
# D-015: Peripheral vision dimming # D-015: Peripheral vision dimming
const PERIPHERAL_ALPHA: float = 0.5 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 const CAMERA_SMOOTHING_SPEED: float = 8.0
# #517: Implant UI font color grading — avoid pure white, project through a lens # #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
# D-033 color lookup by relationship string (#521) # D-033 color lookup by relationship string (#521)
static func color_for_relationship(relationship: String) -> Color: static func color_for_relationship(relationship: String) -> Color:
match relationship: match relationship:
"Friendly": return ENTITY_COLOR_FRIENDLY "Friendly":
"PersonOfInterest": return ENTITY_COLOR_POI return ENTITY_COLOR_FRIENDLY
"Hostile": return ENTITY_COLOR_HOSTILE "PersonOfInterest":
"Unknown": return ENTITY_COLOR_UNKNOWN return ENTITY_COLOR_POI
_: return ENTITY_COLOR_UNKNOWN "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) # D-033 color lookup by entity data — uses relationship for NPCs (#521)
static func color_for_entity_kind(entity_data: Dictionary) -> Color: static func color_for_entity_kind(entity_data: Dictionary) -> Color:
var kind_variant: String = entity_data.get("kind", {}).get("variant", "") var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
match kind_variant: match kind_variant:
"Player": return ENTITY_COLOR_PLAYER "Player":
"Object", "Terrain": return ENTITY_COLOR_OBJECT return ENTITY_COLOR_PLAYER
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown")) "Object", "Terrain":
_: return ENTITY_COLOR_OBJECT 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". # D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String: static func format_game_time(time_of_day: int) -> String:
+33 -18
View File
@@ -13,7 +13,7 @@ var journal_panel: Node = null
# main.gd and this coordinator both append to the same array. # main.gd and this coordinator both append to the same array.
var _pending_record_inputs: 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_npc_name: String = "" # #535: NPC name for dialogue_response attribution
var _last_dialogue_tick: int = -1 var _last_dialogue_tick: int = -1
var _last_confrontation_tick: int = -1 var _last_confrontation_tick: int = -1
@@ -44,6 +44,7 @@ func connect_signals() -> void:
# -- Snapshot consumers (registered with SnapshotEventRouter) ----------------- # -- Snapshot consumers (registered with SnapshotEventRouter) -----------------
# Consume-once per tick with ID tracking: show dialogue, then clear. # Consume-once per tick with ID tracking: show dialogue, then clear.
func consume_dialogue() -> void: func consume_dialogue() -> void:
if GameState.current_dialogue == null or not dialogue_box: if GameState.current_dialogue == null or not dialogue_box:
@@ -102,17 +103,24 @@ func consume_dialogue_response() -> void:
# -- Signal handlers ---------------------------------------------------------- # -- Signal handlers ----------------------------------------------------------
# D-061: Handle dialogue option selection -> send to server # D-061: Handle dialogue option selection -> send to server
func on_dialogue_option_selected(response_id: String, _text: String) -> void: func on_dialogue_option_selected(response_id: String, _text: String) -> void:
SimBridge.send_input({ (
"action": InputMapper.Action.INTERACT, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
"action_data": { {
"target_entity_id": null, "action": InputMapper.Action.INTERACT,
"verb": "DialogueResponse", "timestamp_msec": Time.get_ticks_msec(),
"response_id": response_id, "action_data":
}, {
}) "target_entity_id": null,
"verb": "DialogueResponse",
"response_id": response_id,
},
}
)
)
# D-063: Confrontation beat monologue -> show on monologue display (layer 7) # 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 # D-064: Handle walk-away -> send WalkAway{npc_id} to server
func on_dialogue_dismissed() -> void: func on_dialogue_dismissed() -> void:
SimBridge.send_input({ (
"action": InputMapper.Action.INTERACT, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
"action_data": { {
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null, "action": InputMapper.Action.INTERACT,
"verb": "WalkAway", "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. # D-020 (#558): Coordinator handles dialogue state changes.
+75 -38
View File
@@ -5,15 +5,15 @@ const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
var _camera_anchored: bool = false var _camera_anchored: bool = false
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay 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 _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 _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@onready var world_renderer = $World @onready var world_renderer = $World
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization @onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
@onready var camera = $Camera2D @onready var camera = $Camera2D
@onready var hud = $UILayer/HUD @onready var hud = $InsertOverlay/HUD
@onready var monologue_display = $UILayer/MonologueDisplay @onready var monologue_display = $UILayer/MonologueDisplay
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback @onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6 @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 cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @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 minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay @onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041) @onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay @onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button @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 settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load @onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console @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 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 star_map = $InsertOverlay/HUD/StarMap # #674: star map insert module (hop-ring view)
func _ready() -> void: func _ready() -> void:
@@ -62,25 +61,54 @@ func _ready() -> void:
camera.global_position = GameState.player_position * Constants.TILE_SIZE camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true _camera_anchored = true
# #775: Initialize extracted components # D-170: Register HUD nodes into layer groups
_consumers = SnapshotConsumers.new().init({ for node in [
"monologue_display": monologue_display, hud,
"dialogue_box": dialogue_box, minimap,
"examine_display": examine_display, stance_indicator,
"loading_screen": loading_screen, interaction_prompt,
"debug_console": debug_console, interaction_list,
"cursor_renderer": cursor_renderer, inventory_grid,
"interaction_list": interaction_list, news_ticker,
"interaction_prompt": interaction_prompt, examine_display,
"minimap": minimap, world_radial,
"star_map": star_map, ]:
}, _screen_flash) if node and node is CanvasItem:
HudGroups.register(node, "gameplay")
_dialogue = DialogueCoordinator.new().init({ # #775: Initialize extracted components
"dialogue_box": dialogue_box, _consumers = (
"monologue_display": monologue_display, SnapshotConsumers
"journal_panel": journal_panel, . new()
}, _pending_record_inputs) . 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() _dialogue.connect_signals()
# #496: Print gauntlet session summary on disconnect # #496: Print gauntlet session summary on disconnect
@@ -106,8 +134,8 @@ func _ready() -> void:
_router.register_always(gauntlet_hud.update_from_state) _router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay: if checklist_overlay:
_router.register_always(checklist_overlay.update_from_state) _router.register_always(checklist_overlay.update_from_state)
if time_display: if hud:
_router.register_always(time_display.update_from_state) _router.register_always(hud.update_from_state)
if news_ticker: if news_ticker:
_router.register_always(news_ticker.update_from_state) _router.register_always(news_ticker.update_from_state)
if journal_panel: if journal_panel:
@@ -237,13 +265,17 @@ func _process(delta: float) -> void:
# #496: Finalize gauntlet stats on disconnect # #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: if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
gauntlet_hud.finalize() gauntlet_hud.finalize()
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED. # #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: if new_state != SimBridge.ConnectionState.CONNECTED:
return return
if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load): 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(): if load_path.is_empty():
return return
GameState.pending_load_path = "" GameState.pending_load_path = ""
var err := SimBridge.send_input({ var err := (
"action": InputMapper.Action.LOAD_GAME, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
"action_data": {"path": load_path}, {
}) "action": InputMapper.Action.LOAD_GAME,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"path": load_path},
}
)
)
if err != OK: if err != OK:
push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err)) push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err))
if loading_screen: if loading_screen:
+12 -3
View File
@@ -86,15 +86,23 @@ func _try_extract_message() -> PackedByteArray:
if _pending_length < 0: if _pending_length < 0:
if _read_buffer.size() < 4: if _read_buffer.size() < 4:
return PackedByteArray() return PackedByteArray()
_pending_length = (_read_buffer[0] << 24) | (_read_buffer[1] << 16) | \ _pending_length = (
(_read_buffer[2] << 8) | _read_buffer[3] (_read_buffer[0] << 24)
| (_read_buffer[1] << 16)
| (_read_buffer[2] << 8)
| _read_buffer[3]
)
_read_buffer = _read_buffer.slice(4) _read_buffer = _read_buffer.slice(4)
if _pending_length > MAX_MESSAGE_SIZE: if _pending_length > MAX_MESSAGE_SIZE:
# Stream is corrupt — we can't find the next valid frame boundary. # Stream is corrupt — we can't find the next valid frame boundary.
# Disconnect rather than silently discarding valid buffered data. # Disconnect rather than silently discarding valid buffered data.
push_error( 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 _corrupt = true
_pending_length = -1 _pending_length = -1
_read_buffer.clear() _read_buffer.clear()
@@ -129,6 +137,7 @@ func reset() -> void:
# These exist for unit tests that verify framing logic without a live TCP # These exist for unit tests that verify framing logic without a live TCP
# connection. Not used in production code paths. # connection. Not used in production code paths.
## Encode a payload into a framed byte array: [4-byte BE length][payload]. ## Encode a payload into a framed byte array: [4-byte BE length][payload].
static func frame_encode(payload: PackedByteArray) -> PackedByteArray: static func frame_encode(payload: PackedByteArray) -> PackedByteArray:
var len := payload.size() var len := payload.size()
+187 -87
View File
@@ -14,9 +14,9 @@ extends Node
## v20: adds settings_response field to ObserverSnapshot (#627, D-138). ## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
const PROTOCOL_VERSION: int = 20 const PROTOCOL_VERSION: int = 20
# -- Decode: bytes from server → GDScript types -------------------------------- # -- Decode: bytes from server → GDScript types --------------------------------
## Decode an ObserverSnapshot from MessagePack bytes. ## Decode an ObserverSnapshot from MessagePack bytes.
## Returns decoded snapshot Dictionary or null on error. ## Returns decoded snapshot Dictionary or null on error.
## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty ## 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") var version: Variant = raw.get("version")
if version != PROTOCOL_VERSION: if version != PROTOCOL_VERSION:
push_error( 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 return null
var entities: Array[Dictionary] = [] var entities: Array[Dictionary] = []
@@ -51,7 +55,11 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if dropped > 0: if dropped > 0:
push_error( 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 # 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). # 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") var raw_vtiles: Variant = raw.get("visible_tiles")
if raw_vtiles is Array: if raw_vtiles is Array:
for raw_tile in raw_vtiles: 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 := { var tile_entry := {
"x": int(raw_tile["x"]), "x": int(raw_tile["x"]),
"y": int(raw_tile["y"]), "y": int(raw_tile["y"]),
@@ -119,11 +132,16 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if raw_inventory is Array: if raw_inventory is Array:
for raw_item in raw_inventory: for raw_item in raw_inventory:
if raw_item is Dictionary and raw_item.has("item_id") and raw_item.has("name"): 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"]), player_inventory
"name": str(raw_item["name"]), . append(
"slot": int(raw_item.get("slot", 0)), {
}) "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 # v7: pending_recognitions (#431, D-059/D-060) — cognitive delay fog entities
# Bounded: server sends at most ~50 pending recognitions per snapshot (practical limit # 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 var count := 0
for raw_pr in raw_recognitions: for raw_pr in raw_recognitions:
if count >= MAX_PENDING_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 break
if raw_pr is Dictionary and raw_pr.has("entity_id") and raw_pr.has("x") and raw_pr.has("y"): if (
pending_recognitions.append({ raw_pr is Dictionary
"entity_id": int(raw_pr["entity_id"]), and raw_pr.has("entity_id")
"x": float(raw_pr["x"]), and raw_pr.has("x")
"y": float(raw_pr["y"]), and raw_pr.has("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)), 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 count += 1
# v7: current_dialogue (#435, D-061/D-062) — NPC speech + player response options # 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: if raw_options is Array:
for raw_opt in raw_options: for raw_opt in raw_options:
if raw_opt is Dictionary and raw_opt.has("text"): if raw_opt is Dictionary and raw_opt.has("text"):
dialogue_options.append({ (
"text": str(raw_opt["text"]), dialogue_options
"response_id": str(raw_opt.get("response_id", "")), . append(
"priority": int(raw_opt.get("priority", 0)), {
"confrontation": bool(raw_opt.get("confrontation", false)), "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 = { current_dialogue = {
"npc_name": str(raw_dialogue.get("npc_name", "")), "npc_name": str(raw_dialogue.get("npc_name", "")),
"npc_entity_id": int(raw_dialogue.get("npc_entity_id", -1)), "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: if raw_conv_events is Array:
for raw_ce in raw_conv_events: for raw_ce in raw_conv_events:
if raw_ce is Dictionary and raw_ce.has("occluded_line"): if raw_ce is Dictionary and raw_ce.has("occluded_line"):
conversation_events.append({ (
"speaker_id": int(raw_ce.get("speaker_id", 0)), conversation_events
"target_id": int(raw_ce.get("target_id", 0)), . append(
"speaker_name": str(raw_ce.get("speaker_name", "")), {
"target_name": str(raw_ce.get("target_name", "")), "speaker_id": int(raw_ce.get("speaker_id", 0)),
"occluded_line": str(raw_ce["occluded_line"]), "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. # v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
var conversation_ended: Array = [] var conversation_ended: Array = []
@@ -204,10 +247,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if raw_conv_ended is Array: if raw_conv_ended is Array:
for raw_end in raw_conv_ended: for raw_end in raw_conv_ended:
if raw_end is Dictionary: if raw_end is Dictionary:
conversation_ended.append({ (
"speaker_id": int(raw_end.get("speaker_id", 0)), conversation_ended
"target_id": int(raw_end.get("target_id", 0)), . 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. # v14: poi_list (#151) — discovered POIs for minimap rendering.
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords. # 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") var raw_pois: Variant = raw.get("poi_list")
if raw_pois is Array: if raw_pois is Array:
for raw_poi in raw_pois: 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"): if (
poi_list.append({ raw_poi is Dictionary
"poi_id": str(raw_poi["poi_id"]), and raw_poi.has("poi_id")
"name": str(raw_poi.get("name", "")), and raw_poi.has("x")
"x": int(raw_poi["x"]), and raw_poi.has("y")
"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"))), 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. # v14: examine_result (#174, #242) — character-filtered observation text.
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds. # {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: if raw_tce is Array:
for raw_ev in raw_tce: for raw_ev in raw_tce:
if raw_ev is Dictionary and raw_ev.has("triangle_id"): 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 # v20: settings_response (#627, D-138) — server ack after ChangeSettings / full settings dump
# after RequestAllSettings. kind = "full"|"ack". "full": settings array [{key, value}]. # 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: if raw_sr_settings is Array:
for raw_s in raw_sr_settings: for raw_s in raw_sr_settings:
if raw_s is Dictionary and raw_s.has("key"): if raw_s is Dictionary and raw_s.has("key"):
sr_settings.append({ (
"key": str(raw_s["key"]), sr_settings
"value": raw_s.get("value"), . append(
}) {
"key": str(raw_s["key"]),
"value": raw_s.get("value"),
}
)
)
settings_response = {"kind": "full", "settings": sr_settings} settings_response = {"kind": "full", "settings": sr_settings}
elif sr_kind == "ack": elif sr_kind == "ack":
settings_response = { settings_response = {
@@ -338,27 +407,37 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if raw_kg_entities is Array: if raw_kg_entities is Array:
for raw_ke in raw_kg_entities: for raw_ke in raw_kg_entities:
if raw_ke is Dictionary and raw_ke.has("entity_id"): if raw_ke is Dictionary and raw_ke.has("entity_id"):
kg_entities.append({ (
"entity_id": int(raw_ke["entity_id"]), kg_entities
"name": str(raw_ke.get("name", "Unknown")), . append(
"confidence": str(raw_ke.get("confidence", "Suspects")), {
"source": str(raw_ke.get("source", "")), "entity_id": int(raw_ke["entity_id"]),
"state": str(raw_ke.get("state", "Active")), "name": str(raw_ke.get("name", "Unknown")),
"relationship": str(raw_ke.get("relationship", "Unknown")), "confidence": str(raw_ke.get("confidence", "Suspects")),
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)), "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 kg_facts: Array = []
var raw_kg_facts: Variant = raw_pk.get("facts") var raw_kg_facts: Variant = raw_pk.get("facts")
if raw_kg_facts is Array: if raw_kg_facts is Array:
for raw_kf in raw_kg_facts: for raw_kf in raw_kg_facts:
if raw_kf is Dictionary and raw_kf.has("fact_id"): if raw_kf is Dictionary and raw_kf.has("fact_id"):
kg_facts.append({ (
"fact_id": str(raw_kf["fact_id"]), kg_facts
"confidence": str(raw_kf.get("confidence", "Suspects")), . append(
"source": str(raw_kf.get("source", "")), {
"state": str(raw_kf.get("state", "Active")), "fact_id": str(raw_kf["fact_id"]),
"acquired_tick": int(raw_kf.get("acquired_tick", 0)), "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 = { player_knowledge = {
"entities": kg_entities, "entities": kg_entities,
"facts": kg_facts, "facts": kg_facts,
@@ -396,8 +475,13 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
## Decode a single VisibleEntity from a raw msgpack map. ## Decode a single VisibleEntity from a raw msgpack map.
static func _decode_entity(raw: Dictionary) -> Variant: static func _decode_entity(raw: Dictionary) -> Variant:
if not raw.has("entity_id") or not raw.has("x") or not raw.has("y") \ if (
or not raw.has("z") or not raw.has("kind"): 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())) push_warning("Protocol: entity missing required fields: %s" % str(raw.keys()))
return null return null
@@ -485,23 +569,25 @@ static func _decode_verb_option(raw) -> Variant:
## Returns { "variant": String, "data": Variant } in both cases. ## Returns { "variant": String, "data": Variant } in both cases.
static func _decode_enum_variant(raw) -> Dictionary: static func _decode_enum_variant(raw) -> Dictionary:
if raw is String: if raw is String:
return { "variant": raw, "data": null } return {"variant": raw, "data": null}
if raw is Dictionary and raw.size() == 1: if raw is Dictionary and raw.size() == 1:
var variant_name: String = raw.keys()[0] 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)) 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: GDScript types → bytes to server ----------------------------------
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718). ## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
## Sent by the client immediately after handshake validation. ## Sent by the client immediately after handshake validation.
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032). ## 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_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict. ## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
static func encode_startup_message( 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. # Map client lowercase archetype string to server PascalCase enum variant.
# Explicit match prevents unknown strings silently reaching the server as # Explicit match prevents unknown strings silently reaching the server as
# garbage enum values — fail loudly and fall back to "Detective". # garbage enum values — fail loudly and fall back to "Detective".
@@ -512,7 +598,12 @@ static func encode_startup_message(
"smuggler": "smuggler":
archetype_variant = "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" archetype_variant = "Detective"
var msg := { var msg := {
"world_seed": world_seed, "world_seed": world_seed,
@@ -531,7 +622,9 @@ static func encode_startup_message(
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest", ## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
## "Interact", "UsePerceptionMode", "Pause", "Unpause" ## "Interact", "UsePerceptionMode", "Pause", "Unpause"
## action_data: null for unit variants, String for UsePerceptionMode ## 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 action_value: Variant = _encode_action(action_name, action_data)
var input := { var input := {
@@ -555,10 +648,15 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
for input in inputs: for input in inputs:
var action_name: String = input["action_name"] var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data") var action_data: Variant = input.get("action_data")
wire_inputs.append({ (
"tick": input["tick"], wire_inputs
"action": _encode_action(action_name, action_data), . append(
}) {
"tick": input["tick"],
"action": _encode_action(action_name, action_data),
}
)
)
var result = Messagepack.encode(wire_inputs) var result = Messagepack.encode(wire_inputs)
if result.status != null: 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. ## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
static func _encode_action(action_name: String, action_data: Variant) -> Variant: static func _encode_action(action_name: String, action_data: Variant) -> Variant:
if action_data != null: 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 # Interact is a struct variant — server expects named fields, not a bare string
if action_name == "Interact": if action_name == "Interact":
return { "Interact": { "target_entity_id": null, "verb": null } } return {"Interact": {"target_entity_id": null, "verb": null}}
return action_name return action_name
@@ -586,11 +684,13 @@ static func _encode_action(action_name: String, action_data: Variant) -> Variant
## queuing via SimBridge._outbound_buffer. ## queuing via SimBridge._outbound_buffer.
## The server receives this as a PlayerAction::ChangeSettings variant. ## The server receives this as a PlayerAction::ChangeSettings variant.
static func encode_change_settings(enabled: bool) -> PackedByteArray: static func encode_change_settings(enabled: bool) -> PackedByteArray:
var entries: Array = [{ var entries: Array = [
"tick": 0, {
"action_name": "ChangeSettings", "tick": 0,
"action_data": {"ai_enhanced_dialogue": enabled}, "action_name": "ChangeSettings",
}] "action_data": {"ai_enhanced_dialogue": enabled},
}
]
var result = Messagepack.encode(entries) var result = Messagepack.encode(entries)
if result.status != null: if result.status != null:
push_error("Protocol: encode_change_settings failed: %s" % result.status) push_error("Protocol: encode_change_settings failed: %s" % result.status)
+199 -84
View File
@@ -7,14 +7,34 @@ extends RefCounted
const _WALLS: Array = [ const _WALLS: Array = [
# Room walls (8x8 room from (7,7) to (14,14)) # Room walls (8x8 room from (7,7) to (14,14))
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7), Vector2i(7, 7),
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7), Vector2i(8, 7),
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14), Vector2i(9, 7),
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14), Vector2i(10, 7),
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11), Vector2i(11, 7),
Vector2i(7,12), Vector2i(7,13), Vector2i(12, 7),
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11), Vector2i(13, 7),
Vector2i(14,12), Vector2i(14,13), 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 # Interior wall blocking NPC
Vector2i(12, 10), Vector2i(12, 10),
] ]
@@ -48,6 +68,7 @@ func process_facing(new_facing: String) -> void:
# -- Snapshot generation ------------------------------------------------------- # -- Snapshot generation -------------------------------------------------------
func snapshot() -> Dictionary: func snapshot() -> Dictionary:
tick += 1 tick += 1
@@ -76,42 +97,60 @@ func snapshot() -> Dictionary:
var py := player_pos.y var py := player_pos.y
# Build entities — player always visible # Build entities — player always visible
var entities: Array = [{ var entities: Array = [
"entity_id": 1, {
"x": float(px), "entity_id": 1,
"y": float(py), "x": float(px),
"z": 0, "y": float(py),
"kind": { "variant": "Player", "data": null }, "z": 0,
"visibility": "Forward", "kind": {"variant": "Player", "data": null},
}] "visibility": "Forward",
}
]
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10) # NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
var npc_pos := Vector2i(12, 9) var npc_pos := Vector2i(12, 9)
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y) 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): if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral" var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
entities.append({ (
"entity_id": 2, entities
"x": float(npc_pos.x), . append(
"y": float(npc_pos.y), {
"z": 0, "entity_id": 2,
"kind": { "variant": "Npc", "data": null }, "x": float(npc_pos.x),
"visibility": sector, "y": float(npc_pos.y),
"relationship": npc_relationship, "z": 0,
}) "kind": {"variant": "Npc", "data": null},
"visibility": sector,
"relationship": npc_relationship,
}
)
)
# v4: nearby_interactions when NPC is nearby and visible (#404/#405) # v4: nearby_interactions when NPC is nearby and visible (#404/#405)
var nearby: Array = [] var nearby: Array = []
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos): if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
nearby.append({ (
"entity_id": 2, nearby
"entity_type": "Npc", . append(
"distance": npc_dist, {
"verbs": [ "entity_id": 2,
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}, "entity_type": "Npc",
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true}, "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) # v5: monologue on first tick (#414)
var monologue: Variant = null var monologue: Variant = null
@@ -128,11 +167,28 @@ func snapshot() -> Dictionary:
dialogue = { dialogue = {
"npc_name": "Kael", "npc_name": "Kael",
"npc_entity_id": 2, "npc_entity_id": 2,
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?", "speech":
"options": [ "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, # gdlint:ignore = max-line-length "options":
{"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 {
"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: if cycle_pos < 6:
var total_delay := 6 var total_delay := 6
var remaining := total_delay - cycle_pos var remaining := total_delay - cycle_pos
pending_recs.append({ (
"entity_id": 100, pending_recs
"x": 13.5, . append(
"y": 12.5, {
"z": 0, "entity_id": 100,
"remaining_ticks": remaining, "x": 13.5,
"total_delay_ticks": total_delay, "y": 12.5,
}) "z": 0,
"remaining_ticks": remaining,
"total_delay_ticks": total_delay,
}
)
)
# #535: Mock overheard NPC-NPC conversation (D-078) # #535: Mock overheard NPC-NPC conversation (D-078)
var conv_events: Array = [] var conv_events: Array = []
var conv_ended: Array = [] var conv_ended: Array = []
var conv_start := 3 var conv_start := 3
var conv_lines := [ 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",
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."}, "target": "Soren",
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."}, "line": "The cargo manifests don't add up. Three containers unaccounted for."
{"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": "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_tick_interval := 5
var conv_total_ticks := conv_lines.size() * conv_tick_interval 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 var within_tick := (tick - conv_start) % conv_tick_interval
if within_tick == 0 and conv_index < conv_lines.size(): if within_tick == 0 and conv_index < conv_lines.size():
var cl: Dictionary = conv_lines[conv_index] var cl: Dictionary = conv_lines[conv_index]
conv_events.append({ (
"speaker_id": 10, conv_events
"target_id": 11, . append(
"speaker_name": cl.speaker, {
"target_name": cl.target, "speaker_id": 10,
"occluded_line": cl.line, "target_id": 11,
}) "speaker_name": cl.speaker,
"target_name": cl.target,
"occluded_line": cl.line,
}
)
)
elif tick == conv_start + conv_total_ticks: elif tick == conv_start + conv_total_ticks:
conv_ended.append({"speaker_id": 10, "target_id": 11}) conv_ended.append({"speaker_id": 10, "target_id": 11})
return { return {
"tick": tick, "tick": tick,
"version": Protocol.PROTOCOL_VERSION, "version": Protocol.PROTOCOL_VERSION,
"game_time": { "game_time":
{
"day": 0, "day": 0,
"time_of_day": tick * 10, "time_of_day": tick * 10,
"day_phase": "Morning", "day_phase": "Morning",
@@ -207,6 +298,7 @@ func snapshot() -> Dictionary:
# -- Map generation ------------------------------------------------------------ # -- Map generation ------------------------------------------------------------
func _tiles() -> Array: func _tiles() -> Array:
var tiles: Array = [] var tiles: Array = []
var room_x := 7 var room_x := 7
@@ -216,8 +308,9 @@ func _tiles() -> Array:
for x in range(room_x, room_x + room_w): for x in range(room_x, room_x + room_w):
for y in range(room_y, room_y + room_h): for y in range(room_y, room_y + room_h):
var is_edge := (x == room_x or x == room_x + room_w - 1 var is_edge := (
or y == room_y or y == room_y + room_h - 1) 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 var tile_type: String
if is_edge: if is_edge:
if y == room_y + room_h - 1 and x == room_x + room_w / 2: 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 dist <= radius:
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h: 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" 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 return vtiles
@@ -262,8 +357,9 @@ func _get_tile_type(x: int, y: int) -> String:
var room_y := 7 var room_y := 7
var room_w := 8 var room_w := 8
var room_h := 8 var room_h := 8
var is_edge := (x == room_x or x == room_x + room_w - 1 var is_edge := (
or y == room_y or y == room_y + room_h - 1) 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 is_edge:
if y == room_y + room_h - 1 and x == room_x + room_w / 2: if y == room_y + room_h - 1 and x == room_x + room_w / 2:
return "door" return "door"
@@ -273,6 +369,7 @@ func _get_tile_type(x: int, y: int) -> String:
# -- Spatial helpers ----------------------------------------------------------- # -- Spatial helpers -----------------------------------------------------------
func _is_walkable(pos: Vector2i) -> bool: func _is_walkable(pos: Vector2i) -> bool:
return not _WALLS.has(pos) 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: static func action_to_delta(action_name: String) -> Vector2i:
match action_name: match action_name:
"MoveNorth": return Vector2i(0, -1) "MoveNorth":
"MoveNortheast": return Vector2i(1, -1) return Vector2i(0, -1)
"MoveEast": return Vector2i(1, 0) "MoveNortheast":
"MoveSoutheast": return Vector2i(1, 1) return Vector2i(1, -1)
"MoveSouth": return Vector2i(0, 1) "MoveEast":
"MoveSouthwest": return Vector2i(-1, 1) return Vector2i(1, 0)
"MoveWest": return Vector2i(-1, 0) "MoveSoutheast":
"MoveNorthwest": return Vector2i(-1, -1) return Vector2i(1, 1)
_: return Vector2i.ZERO "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: static func delta_to_facing(delta: Vector2i) -> String:
match delta: match delta:
Vector2i(0, -1): return "North" Vector2i(0, -1):
Vector2i(1, -1): return "Northeast" return "North"
Vector2i(1, 0): return "East" Vector2i(1, -1):
Vector2i(1, 1): return "Southeast" return "Northeast"
Vector2i(0, 1): return "South" Vector2i(1, 0):
Vector2i(-1, 1): return "Southwest" return "East"
Vector2i(-1, 0): return "West" Vector2i(1, 1):
Vector2i(-1, -1): return "Northwest" return "Southeast"
_: return "North" Vector2i(0, 1):
return "South"
Vector2i(-1, 1):
return "Southwest"
Vector2i(-1, 0):
return "West"
Vector2i(-1, -1):
return "Northwest"
_:
return "North"
+54 -16
View File
@@ -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. ## 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. ## torso_upper is loaded and hidden by default; clothing coverage reveals it.
const ALL_SEGMENTS: Array[String] = [ const ALL_SEGMENTS: Array[String] = [
"head", "neck", "torso_upper", "torso", "hips", "head",
"arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r", "neck",
"hand_l", "hand_r", "torso_upper",
"leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r", "torso",
"foot_l", "foot_r", "hips",
"eyes", "eyebrows", "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. ## 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 # Inspectable state for tests
var _active_torso_variant: String = "full" var _active_torso_variant: String = "full"
var _active_coverages: Dictionary = {} # item_id -> coverage dict var _active_coverages: Dictionary = {} # item_id -> coverage dict
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc. var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
var _toon_shader: Shader = null var _toon_shader: Shader = null
var _toon_masked_shader: Shader = null var _toon_masked_shader: Shader = null
var _outline_shader: Shader = null var _outline_shader: Shader = null
var _white_mask: ImageTexture = null # 1x1 white pixel — forces full-body tinting 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: func _ready() -> void:
@@ -140,6 +153,7 @@ func _load_shaders() -> void:
# Public API # Public API
# ============================================================================= # =============================================================================
## Rebuild the full character from a CharacterVisualDescriptor. ## Rebuild the full character from a CharacterVisualDescriptor.
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void: func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
_clear() _clear()
@@ -168,8 +182,14 @@ func set_facing(direction: Variant) -> void:
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y) rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
elif direction is String: elif direction is String:
var angles: Dictionary = { var angles: Dictionary = {
"south": 0.0, "southwest": 45.0, "west": 90.0, "northwest": 135.0, "south": 0.0,
"north": 180.0, "northeast": -135.0, "east": -90.0, "southeast": -45.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) rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
@@ -232,6 +252,7 @@ func get_overhead_anchor() -> Marker3D:
# Internal — teardown # Internal — teardown
# ============================================================================= # =============================================================================
func _clear() -> void: func _clear() -> void:
# Outline nodes are duplicates of body/clothing meshes — must be freed FIRST, # Outline nodes are duplicates of body/clothing meshes — must be freed FIRST,
# before the source meshes are removed. _rebuild_outlines() is not called here # before the source meshes are removed. _rebuild_outlines() is not called here
@@ -287,6 +308,7 @@ func _clear() -> void:
# Internal — skeleton # Internal — skeleton
# ============================================================================= # =============================================================================
func _load_skeleton() -> void: func _load_skeleton() -> void:
if not ResourceLoader.exists(SKELETON_PATH): if not ResourceLoader.exists(SKELETON_PATH):
push_error("CharacterVisual: skeleton not found at %s" % 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: for slot: String in SLOT_TO_BONE:
var bone_name: String = SLOT_TO_BONE[slot] var bone_name: String = SLOT_TO_BONE[slot]
if _skeleton.find_bone(bone_name) == -1: 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. ## #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) # Internal — body segments (D-160)
# ============================================================================= # =============================================================================
func _load_body_segments(desc: CharacterVisualDescriptor) -> void: func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key() var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)] 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) # Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
# ============================================================================= # =============================================================================
func _load_head(desc: CharacterVisualDescriptor) -> void: func _load_head(desc: CharacterVisualDescriptor) -> void:
if desc.head_id.is_empty(): if desc.head_id.is_empty():
return return
@@ -452,7 +481,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
func _attach_to_bone( 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: if _skeleton == null:
return null return null
if not ResourceLoader.exists(path): if not ResourceLoader.exists(path):
@@ -517,6 +547,7 @@ func _attach_to_bone(
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant # Internal — clothing (D-162): coverage.json → segment hiding + torso variant
# ============================================================================= # =============================================================================
func _load_clothing(desc: CharacterVisualDescriptor) -> void: func _load_clothing(desc: CharacterVisualDescriptor) -> void:
if _skeleton == null or desc.clothing_slots.is_empty(): if _skeleton == null or desc.clothing_slots.is_empty():
return return
@@ -566,7 +597,6 @@ func _load_clothing(desc: CharacterVisualDescriptor) -> void:
# Hiding is reserved for amputation/prosthetics via the debug tab or game logic. # Hiding is reserved for amputation/prosthetics via the debug tab or game logic.
func _read_coverage(path: String) -> Dictionary: func _read_coverage(path: String) -> Dictionary:
if not FileAccess.file_exists(path): if not FileAccess.file_exists(path):
return {} return {}
@@ -608,6 +638,7 @@ func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2
# Internal — accessories (BoneAttachment3D per slot→bone) # Internal — accessories (BoneAttachment3D per slot→bone)
# ============================================================================= # =============================================================================
func _load_accessories(desc: CharacterVisualDescriptor) -> void: func _load_accessories(desc: CharacterVisualDescriptor) -> void:
for slot: String in desc.accessory_slots: for slot: String in desc.accessory_slots:
var item_id: String = desc.accessory_slots[slot] 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) # Internal — tinting (hair, accessories, bone-attached assets with tint)
# ============================================================================= # =============================================================================
func _apply_tinted_shader( 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: if mi.mesh == null:
return return
for surf in range(mi.mesh.get_surface_count()): for surf in range(mi.mesh.get_surface_count()):
@@ -657,6 +690,7 @@ func _apply_tinted_shader(
# Internal — outline pass (inverted hull, cull_front) # Internal — outline pass (inverted hull, cull_front)
# ============================================================================= # =============================================================================
func _rebuild_outlines() -> void: func _rebuild_outlines() -> void:
for node in _outline_nodes: for node in _outline_nodes:
if is_instance_valid(node): if is_instance_valid(node):
@@ -693,6 +727,7 @@ func _rebuild_outlines() -> void:
# Animation # Animation
# ============================================================================= # =============================================================================
func _load_animations() -> void: func _load_animations() -> void:
if _skeleton == null: if _skeleton == null:
return return
@@ -764,6 +799,7 @@ func stop_animation() -> void:
# Static helpers # Static helpers
# ============================================================================= # =============================================================================
static func _find_skeleton(root: Node) -> Skeleton3D: static func _find_skeleton(root: Node) -> Skeleton3D:
if root is Skeleton3D: if root is Skeleton3D:
return root as Skeleton3D return root as Skeleton3D
@@ -791,7 +827,9 @@ static func _get_albedo_texture(mat: Material) -> Texture2D:
if mat is BaseMaterial3D: if mat is BaseMaterial3D:
return (mat as BaseMaterial3D).albedo_texture return (mat as BaseMaterial3D).albedo_texture
if mat is ShaderMaterial: 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) var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
if val is Texture2D: if val is Texture2D:
return val as Texture2D return val as Texture2D
@@ -166,6 +166,7 @@ func to_dict() -> Dictionary:
# -- Color serialization helpers -- # -- Color serialization helpers --
# Wire format: [r, g, b, a] float array (rmp_serde serializes Color as tuple). # Wire format: [r, g, b, a] float array (rmp_serde serializes Color as tuple).
static func _decode_color(value: Variant, fallback: Color) -> Color: static func _decode_color(value: Variant, fallback: Color) -> Color:
if value is Array and value.size() >= 3: 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) 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)) encoded.append(_encode_color(c))
result[key] = encoded result[key] = encoded
return result return result
+87 -27
View File
@@ -1,5 +1,5 @@
class_name CursorRenderer class_name CursorRenderer
extends Node2D extends GameplayRenderer
## Cursor state machine — 4 geometric states, 150ms linear transitions (D-056). ## Cursor state machine — 4 geometric states, 150ms linear transitions (D-056).
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer). ## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
@@ -54,8 +54,9 @@ var _bracket_a: float = 0.0
var _alpha: float = 0.45 var _alpha: float = 0.45
func _ready() -> void: func _renderer_ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN) Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
z_index = 100 # D-170: cursor must always render above all HUD/implant layers
_from = _snapshot() _from = _snapshot()
@@ -68,7 +69,7 @@ func _notification(what: int) -> void:
visible = true visible = true
func _process(delta: float) -> void: func _gameplay_process(delta: float) -> void:
if not _mouse_inside: if not _mouse_inside:
return return
_time += delta _time += delta
@@ -84,6 +85,7 @@ func _process(delta: float) -> void:
# --- Hover detection (automatic, from GameState.visible_entities) --- # --- Hover detection (automatic, from GameState.visible_entities) ---
func _detect_hover() -> void: func _detect_hover() -> void:
var prev_id := hovered_entity_id 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 mouse_world: Vector2 = xform.affine_inverse() * mouse_screen
var best_dist := INF 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: for entity in GameState.visible_entities:
if not entity.has("entity_id") or not entity.has("x") or not entity.has("y"): 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 --- # --- State transitions ---
func _set_target(new_state: State) -> void: func _set_target(new_state: State) -> void:
if new_state == _target: if new_state == _target:
return return
@@ -154,29 +157,75 @@ func _set_target(new_state: State) -> void:
func _snapshot() -> Dictionary: func _snapshot() -> Dictionary:
return { gap = _gap, len = _len, rot = _rot, thick = _thick, return {
color = _color, bloom = _bloom, bracket_a = _bracket_a, alpha = _alpha } gap = _gap,
len = _len,
rot = _rot,
thick = _thick,
color = _color,
bloom = _bloom,
bracket_a = _bracket_a,
alpha = _alpha
}
func _params_for(state: State) -> Dictionary: func _params_for(state: State) -> Dictionary:
match state: match state:
State.ENTITY_HOVER: State.ENTITY_HOVER:
return { gap = 10.0, len = 6.0, rot = 0.0, thick = 1.0, return {
color = _hover_color, bloom = 0.5, bracket_a = 1.0, alpha = 1.0 } 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: State.OBJECT_HOVER:
return { gap = 4.0, len = 6.0, rot = PI / 4.0, thick = 1.0, return {
color = _hover_color, bloom = 0.3, bracket_a = 0.0, alpha = 0.8 } 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: State.WEAPON_AIM:
return { gap = 12.0, len = 9.0, rot = 0.0, thick = 2.0, return {
color = COLOR_WEAPON, bloom = 0.0, bracket_a = 0.0, alpha = 1.0 } 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, return {
color = COLOR_DEFAULT, bloom = 0.4, bracket_a = 0.0, alpha = 0.45 } 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: func _apply_params(p: Dictionary) -> void:
_gap = p.gap; _len = p.len; _rot = p.rot; _thick = p.thick _gap = p.gap
_color = p.color; _bloom = p.bloom; _bracket_a = p.bracket_a; _alpha = p.alpha _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: func _interpolate() -> void:
@@ -193,8 +242,9 @@ func _interpolate() -> void:
# --- Drawing --- # --- 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 # here. This is safe because Camera2D (tree sibling, earlier in scene order) applies
# its smoothing transform before CursorRenderer._process() reads canvas_transform. # its smoothing transform before CursorRenderer._process() reads canvas_transform.
# Nothing modifies the canvas transform between _process() and _draw(). # 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) --- # --- Test-friendly API (expected by test_cursor_states.gd) ---
func get_state() -> String: func get_state() -> String:
match current_state: match current_state:
State.DEFAULT: return "Default" State.DEFAULT:
State.ENTITY_HOVER: return "EntityHover" return "Default"
State.OBJECT_HOVER: return "ObjectHover" State.ENTITY_HOVER:
State.WEAPON_AIM: return "WeaponAim" return "EntityHover"
_: return "Default" State.OBJECT_HOVER:
return "ObjectHover"
State.WEAPON_AIM:
return "WeaponAim"
_:
return "Default"
func set_hover_target(data: Dictionary) -> void: func set_hover_target(data: Dictionary) -> void:
@@ -252,10 +308,14 @@ func set_hover_target(data: Dictionary) -> void:
if kind == "Npc": if kind == "Npc":
var rel: String = data.get("relationship", "Unknown") var rel: String = data.get("relationship", "Unknown")
match rel: match rel:
"Friendly": _hover_color = Constants.ENTITY_COLOR_FRIENDLY "Friendly":
"PersonOfInterest": _hover_color = Constants.ENTITY_COLOR_POI _hover_color = Constants.ENTITY_COLOR_FRIENDLY
"Hostile": _hover_color = Constants.ENTITY_COLOR_HOSTILE "PersonOfInterest":
_: _hover_color = Constants.ENTITY_COLOR_UNKNOWN _hover_color = Constants.ENTITY_COLOR_POI
"Hostile":
_hover_color = Constants.ENTITY_COLOR_HOSTILE
_:
_hover_color = Constants.ENTITY_COLOR_UNKNOWN
_target = State.ENTITY_HOVER _target = State.ENTITY_HOVER
_t = 1.0 _t = 1.0
_apply_params(_params_for(State.ENTITY_HOVER)) _apply_params(_params_for(State.ENTITY_HOVER))
+27 -16
View File
@@ -1,5 +1,5 @@
class_name EntityRenderer class_name EntityRenderer
extends Node2D extends GameplayRenderer
# Entity renderer — manages entity sprites under the Entities node # Entity renderer — manages entity sprites under the Entities node
# Creates/updates/removes Sprite2D children based on entity data # 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) # D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
const ENTITY_WIDTH: int = 24 const ENTITY_WIDTH: int = 24
const ENTITY_HEIGHT: int = 32 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 const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
# Lerp speed — framerate-independent exponential smoothing. # Lerp speed — framerate-independent exponential smoothing.
@@ -28,17 +28,18 @@ const LERP_SPEED: float = 12.0
# #521: Color transition duration in seconds (D-033: "0.5s fade") # #521: Color transition duration in seconds (D-033: "0.5s fade")
const COLOR_FADE_DURATION: float = 0.5 const COLOR_FADE_DURATION: float = 0.5
var entity_nodes: Dictionary = {} # entity_id -> Node2D var entity_nodes: Dictionary = {} # entity_id -> Node2D
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position) var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship) var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float} var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west") var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
func _ready() -> void:
func _renderer_ready() -> void:
print("EntityRenderer: Initialized") print("EntityRenderer: Initialized")
func _process(delta: float) -> void: func _gameplay_process(delta: float) -> void:
# Lerp all entity visual positions toward their targets each frame. # Lerp all entity visual positions toward their targets each frame.
# Uses framerate-independent exponential smoothing. # Uses framerate-independent exponential smoothing.
var weight := 1.0 - exp(-LERP_SPEED * delta) 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) var tex := _load_sprite_texture(direction)
if tex == null: if tex == null:
push_error( 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 entity_node.texture = tex
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming. # 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 # N/NW → north, NE/E → east, SE/S → south, SW/W → west
static func _octant_to_direction(octant: String) -> String: static func _octant_to_direction(octant: String) -> String:
match octant: match octant:
"North", "Northwest": return "north" "North", "Northwest":
"Northeast", "East": return "east" return "north"
"Southeast", "South": return "south" "Northeast", "East":
"Southwest", "West": return "west" return "east"
"Southeast", "South":
return "south"
"Southwest", "West":
return "west"
_: _:
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant) push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
return "south" return "south"
@@ -247,11 +256,13 @@ func _add_facing_indicator(parent_node: Node2D) -> void:
var s := Constants.FACING_INDICATOR_SIZE var s := Constants.FACING_INDICATOR_SIZE
var offset := Constants.FACING_INDICATOR_OFFSET var offset := Constants.FACING_INDICATOR_OFFSET
# Triangle pointing up (North), offset from center. Rotates around (0,0). # Triangle pointing up (North), offset from center. Rotates around (0,0).
indicator.polygon = PackedVector2Array([ indicator.polygon = PackedVector2Array(
Vector2(0, -offset - s), [
Vector2(-s * 0.6, -offset + s * 0.4), Vector2(0, -offset - s),
Vector2(s * 0.6, -offset + s * 0.4), Vector2(-s * 0.6, -offset + s * 0.4),
]) Vector2(s * 0.6, -offset + s * 0.4),
]
)
indicator.color = Constants.ENTITY_COLOR_PLAYER indicator.color = Constants.ENTITY_COLOR_PLAYER
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32). # 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. # Indicator rotates around this point to track player facing direction.
+22 -24
View File
@@ -1,5 +1,5 @@
class_name FogEntities class_name FogEntities
extends Node2D extends GameplayRenderer
## Fog entity visualization — renders entities undergoing cognitive delay recognition ## Fog entity visualization — renders entities undergoing cognitive delay recognition
## in the fog layer. Between FogOverlay (z:900) and InsertOverlay (CanvasLayer 10). ## 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 const TILE_SIZE: int = Constants.TILE_SIZE
# D-059: Fog entity colors # 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). # Intentionally matches Constants.INSERT_COLOR_TEXT / cursor default (#c8d0e0).
# Sonar pings are insert-generated — same visual language as cursor and HUD overlays. # 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 # Animation timing
const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds) const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds)
const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing
const PULSE_MAX_ALPHA: float = 0.8 # Max 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_DURATION: float = 1.5 # Sound ping expand + fade (seconds)
const PING_MAX_RADIUS: float = 24.0 # Max ring expand radius (pixels) 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_COUNT: int = 3 # Concentric rings per ping
const PING_RING_WIDTH: float = 1.5 # Ring line width (pixels) 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_RANGE: float = 0.5 # ±0.5 tile position drift (D-059)
const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander
# Entity blob rendering # Entity blob rendering
const BLOB_RADIUS: float = 10.0 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. # 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). # Color transition starts at 50% to compress the visual change into ~0.3s (D-060).
const COLOR_TRANSITION_START: float = 0.5 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) const SILHOUETTE_SIZE := Vector2(6.0, 10.0)
# Internal state — no child nodes, pure data + _draw() # Internal state — no child nodes, pure data + _draw()
var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress} var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress}
var _pings: Array = [] # [{pos: Vector2, elapsed: float}] var _pings: Array = [] # [{pos: Vector2, elapsed: float}]
var _time: float = 0.0 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 # Early return when idle — skip queue_redraw() when nothing to draw
if _entities.is_empty() and _pings.is_empty(): if _entities.is_empty() and _pings.is_empty():
return return
@@ -118,11 +118,10 @@ func update_from_state() -> void:
_entities.erase(eid) _entities.erase(eid)
func _draw() -> void: func _gameplay_draw() -> void:
# Breathing pulse — shared across all entities # Breathing pulse — shared across all entities
var pulse_t := fmod(_time, PULSE_PERIOD) / PULSE_PERIOD var pulse_t := fmod(_time, PULSE_PERIOD) / PULSE_PERIOD
var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA, var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA, 0.5 + 0.5 * sin(pulse_t * TAU))
0.5 + 0.5 * sin(pulse_t * TAU))
# Draw entity blobs # Draw entity blobs
for eid in _entities.keys(): for eid in _entities.keys():
@@ -132,7 +131,9 @@ func _draw() -> void:
# Color transition: grey → D-033 teal based on recognition progress # Color transition: grey → D-033 teal based on recognition progress
# Transition begins at 50% progress (D-060: ~0.3s visual transition within delay window) # Transition begins at 50% progress (D-060: ~0.3s visual transition within delay window)
var progress: float = e.progress 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) var blob_color: Color = COLOR_UNRECOGNIZED.lerp(Constants.ENTITY_COLOR_UNKNOWN, color_t)
blob_color.a = pulse_alpha blob_color.a = pulse_alpha
@@ -172,7 +173,4 @@ func _draw() -> void:
func _random_drift() -> Vector2: func _random_drift() -> Vector2:
return Vector2( return Vector2(randf_range(-DRIFT_RANGE, DRIFT_RANGE), randf_range(-DRIFT_RANGE, DRIFT_RANGE))
randf_range(-DRIFT_RANGE, DRIFT_RANGE),
randf_range(-DRIFT_RANGE, DRIFT_RANGE)
)
+8 -2
View File
@@ -41,7 +41,11 @@ func _ready() -> void:
noise_tex.width = 256 noise_tex.width = 256
noise_tex.height = 256 noise_tex.height = 256
noise_tex.seamless = true 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("noise_tex", noise_tex)
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE) _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("rect_sz", _fog_rect.size)
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position)) _shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size)) _shader_mat.set_shader_parameter("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("time", t)
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration) _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 class_name SoundIndicatorRenderer
extends Node2D extends GameplayRenderer
## Medium-range sound indicators (#126, D-018). ## Medium-range sound indicators (#126, D-018).
## Renders directional arrows at the fog boundary for sounds outside LOS. ## Renders directional arrows at the fog boundary for sounds outside LOS.
@@ -20,21 +20,22 @@ extends Node2D
const TILE_SIZE: int = Constants.TILE_SIZE const TILE_SIZE: int = Constants.TILE_SIZE
# Visual parameters # Visual parameters
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
const FADE_DURATION: float = 0.6 # Fade-out window at end const FADE_DURATION: float = 0.6 # Fade-out window at end
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
const ARROW_LEN: float = 12.0 # Length from tip to base const ARROW_LEN: float = 12.0 # Length from tip to base
# D-018/D-069 colors — sourced from Constants to prevent palette drift # D-018/D-069 colors — sourced from Constants to prevent palette drift
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
# Indicators: [{x, y, event_type, elapsed}] # Indicators: [{x, y, event_type, elapsed}]
var _indicators: Array = [] var _indicators: Array = []
func _process(delta: float) -> void:
func _gameplay_process(delta: float) -> void:
if _indicators.is_empty(): if _indicators.is_empty():
return return
@@ -68,17 +69,22 @@ func update_sound_events(events: Array) -> void:
found = true found = true
break break
if not found: if not found:
_indicators.append({ (
"x": ex, _indicators
"y": ey, . append(
"event_type": evt.get("event_type", ""), {
"elapsed": 0.0, "x": ex,
}) "y": ey,
"event_type": evt.get("event_type", ""),
"elapsed": 0.0,
}
)
)
if not _indicators.is_empty(): if not _indicators.is_empty():
queue_redraw() queue_redraw()
func _draw() -> void: func _gameplay_draw() -> void:
if _indicators.is_empty(): if _indicators.is_empty():
return return
@@ -144,9 +150,19 @@ func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
## Map event type string → D-018 color category. ## Map event type string → D-018 color category.
func color_for_type(event_type: String) -> Color: func color_for_type(event_type: String) -> Color:
var et := event_type.to_lower() 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 return COLOR_VOICE
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \ if (
or et.contains("alert") or et.contains("threat"): et.contains("danger")
or et.contains("gunshot")
or et.contains("explosion")
or et.contains("alert")
or et.contains("threat")
):
return COLOR_DANGER return COLOR_DANGER
return COLOR_NEUTRAL return COLOR_NEUTRAL
+11 -2
View File
@@ -27,11 +27,13 @@ const TILE_TYPE_MAP: Dictionary = {
var _initialized: bool = false var _initialized: bool = false
func _ready() -> void: func _ready() -> void:
_setup_tileset() _setup_tileset()
_initialized = true _initialized = true
print("TileRenderer: Initialized") print("TileRenderer: Initialized")
# Build a programmatic TileSet with colored placeholder tiles # Build a programmatic TileSet with colored placeholder tiles
func _setup_tileset() -> void: func _setup_tileset() -> void:
var ts := TileSet.new() var ts := TileSet.new()
@@ -63,6 +65,7 @@ func _setup_tileset() -> void:
var source_id := ts.add_source(source) var source_id := ts.add_source(source)
tile_set = ts tile_set = ts
# Update tiles from snapshot data # Update tiles from snapshot data
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)} # 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.), # 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 var tile_type_str: String = tile_data.type
if not TILE_TYPE_MAP.has(tile_type_str): if not TILE_TYPE_MAP.has(tile_type_str):
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [ push_warning(
tile_type_str, tile_data.x, tile_data.y]) (
"TileRenderer: unknown tile type '%s' at (%d, %d)"
% [tile_type_str, tile_data.x, tile_data.y]
)
)
continue continue
var atlas_x: int = TILE_TYPE_MAP[tile_type_str] var atlas_x: int = TILE_TYPE_MAP[tile_type_str]
var coords := Vector2i(tile_data.x, tile_data.y) var coords := Vector2i(tile_data.x, tile_data.y)
set_cell(coords, 0, Vector2i(atlas_x, 0)) set_cell(coords, 0, Vector2i(atlas_x, 0))
# Fill a tile region with a solid color # Fill a tile region with a solid color
func _fill_tile(img: Image, tile_index: int, color: Color) -> void: func _fill_tile(img: Image, tile_index: int, color: Color) -> void:
var rect := Rect2i(tile_index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE) var rect := Rect2i(tile_index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE)
img.fill_rect(rect, color) img.fill_rect(rect, color)
# Fill a tile region with a color and a 1px border # 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: func _fill_tile_with_border(img: Image, tile_index: int, fill: Color, border: Color) -> void:
var x_offset := tile_index * TILE_SIZE var x_offset := tile_index * TILE_SIZE
+6 -2
View File
@@ -1,4 +1,4 @@
extends Node2D extends GameplayRenderer
# World renderer — manages all visual representation from GameState # World renderer — manages all visual representation from GameState
# Attached to the World node in main.tscn # 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 entity_renderer = $FogGroup/YSortGroup/Entities
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators @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)") print("WorldRenderer: Initialized (D-049 z-stack)")
# Called each frame to update visuals from game state. # Called each frame to update visuals from game state.
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives. # Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
func update_from_state() -> void: func update_from_state() -> void:
if gameplay_occluded:
return # D-170: skip rendering while fullscreen implant app is active
var tick := GameState.current_tick var tick := GameState.current_tick
if tick == _last_tick: if tick == _last_tick:
return return
+1 -2
View File
@@ -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"): if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
continue continue
AudioManager.play_sound_event( AudioManager.play_sound_event(
evt.get("event_type", ""), evt.get("event_type", ""), Vector2(float(evt.x), float(evt.y))
Vector2(float(evt.x), float(evt.y))
) )
GameState.close_sound_events = [] GameState.close_sound_events = []
+31 -8
View File
@@ -20,15 +20,23 @@ static func apply(snapshot: Dictionary) -> void:
GameState.visible_entities = snapshot.entities GameState.visible_entities = snapshot.entities
var found_player := false var found_player := false
for entity in GameState.visible_entities: 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) GameState.player_position = Vector2(entity.x, entity.y)
if entity.has("entity_id"): if entity.has("entity_id"):
GameState.player_entity_id = entity.entity_id GameState.player_entity_id = entity.entity_id
found_player = true found_player = true
break break
if not found_player and GameState.visible_entities.size() > 0: if not found_player and GameState.visible_entities.size() > 0:
push_warning("GameState: no Player entity found in %d entities" % [ push_warning(
GameState.visible_entities.size()]) (
"GameState: no Player entity found in %d entities"
% [GameState.visible_entities.size()]
)
)
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost. # D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int: 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" # Tiles for rendering: test mode sends "tiles", live server sends "visible_tiles"
if snapshot.has("tiles"): if snapshot.has("tiles"):
GameState.visible_tiles = snapshot.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 var has_type := false
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary: if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
has_type = snapshot.visible_tiles[0].has("type") 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": if entry.get("key") == "ai_dialogue.enabled":
var val: Variant = entry.get("value") var val: Variant = entry.get("value")
if val != null: 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: else:
GameState.settings_response = null GameState.settings_response = null
# #718: character_visual_descriptor — restored from server snapshot on save/load. # #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") var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
if CVD != null: if CVD != null:
var restored = CVD.from_dict(snapshot.character_visual_descriptor) 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: for vtile in GameState.visible_tiles:
if vtile is Dictionary and vtile.has("x") and vtile.has("y"): if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile 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) var player_tile = tile_by_coord.get(player_pos_key, null)
GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else "" GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
# v2: visible_tiles with visibility sectors # 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() GameState.visibility_sectors.clear()
var has_explicit_positions := snapshot.has("visible_positions") var has_explicit_positions := snapshot.has("visible_positions")
if not has_explicit_positions: if not has_explicit_positions:
+10 -8
View File
@@ -19,9 +19,9 @@ static func parse(text: String) -> Dictionary:
# Stack: [{indent: int, key: String}] — path of open section headers # Stack: [{indent: int, key: String}] — path of open section headers
var stack: Array = [] var stack: Array = []
# Array state # Array state
var current_array: Variant = null # Array being built, or null 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 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 array_parent_indent: int = -1 # indent of the "key:" line that owns the array
for raw_line in text.split("\n"): for raw_line in text.split("\n"):
var stripped := raw_line.strip_edges(false, true) var stripped := raw_line.strip_edges(false, true)
@@ -126,19 +126,21 @@ static func _parse_value(val: String) -> Variant:
if val.is_empty(): if val.is_empty():
return "" return ""
# Strip inline comment outside quotes # Strip inline comment outside quotes
if not val.begins_with("\""): if not val.begins_with('"'):
var comment_pos: int = val.find(" #") var comment_pos: int = val.find(" #")
if comment_pos >= 0: if comment_pos >= 0:
val = val.substr(0, comment_pos).strip_edges() val = val.substr(0, comment_pos).strip_edges()
# Quoted string — extract content between quotes # Quoted string — extract content between quotes
if val.begins_with("\""): if val.begins_with('"'):
var end_quote: int = val.find("\"", 1) var end_quote: int = val.find('"', 1)
if end_quote > 0: if end_quote > 0:
return val.substr(1, end_quote - 1) return val.substr(1, end_quote - 1)
return val.substr(1) return val.substr(1)
# Boolean # Boolean
if val == "true": return true if val == "true":
if val == "false": return false return true
if val == "false":
return false
# Float (must have decimal point) # Float (must have decimal point)
if val.contains(".") and val.is_valid_float(): if val.contains(".") and val.is_valid_float():
return val.to_float() return val.to_float()
+10
View File
@@ -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);
}
+5 -11
View File
@@ -222,15 +222,9 @@ func test_interact_with_data_encodes_as_data_variant() -> void:
# -- Helpers -- # -- Helpers --
func _make_prompt() -> PanelContainer: func _make_prompt() -> Control:
var PromptScript = load("res://ui/interaction_prompt.gd") var PromptScript = load("res://ui/interaction_prompt.gd")
var panel = PanelContainer.new() var ctrl = Control.new()
panel.set_script(PromptScript) ctrl.set_script(PromptScript)
var margin = MarginContainer.new() add_child(ctrl) # triggers _ready() which creates ImplantPanel + ImplantDataRow
margin.name = "MarginContainer" return ctrl
panel.add_child(margin)
var label = Label.new()
label.name = "PromptLabel"
margin.add_child(label)
add_child(panel)
return panel
+11 -11
View File
@@ -5,15 +5,15 @@
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display) ## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
## D-051 (diegetic insert 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 ## 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 ## 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 ## 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. ## 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 ## Scene integration tests now check InsertOverlay/HUD.get_time_text() instead.
## reading Label.text via public node paths instead.
class_name TestTimeDisplaySprint17 class_name TestTimeDisplaySprint17
extends GdUnitTestSuite 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 scene := MAIN_SCENE
var instance = scene.instantiate() var instance = scene.instantiate()
auto_free(instance) auto_free(instance)
add_child(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 scene := MAIN_SCENE
var instance = scene.instantiate() var instance = scene.instantiate()
auto_free(instance) auto_free(instance)
@@ -296,9 +296,9 @@ func test_insert_clock_time_str_updates_after_process() -> void:
}) })
instance._process(0.016) instance._process(0.016)
var clock = instance.get_node_or_null("InsertOverlay/TimeDisplay") var hud = instance.get_node_or_null("InsertOverlay/HUD")
assert_that(clock).is_not_null() assert_that(hud).is_not_null()
assert_that(clock._time_str).is_equal("12:00") assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
+33 -3
View File
@@ -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() assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null()
func test_hud_exists_in_ui_layer() -> void: func test_hud_exists_in_insert_overlay() -> void:
# D-049: HUD must be in UILayer. # D-051: HUD moved to InsertOverlay (layer 10) for bloom treatment (#786, PR #115).
var scene := MAIN_SCENE var scene := MAIN_SCENE
_instance = scene.instantiate() _instance = scene.instantiate()
auto_free(_instance) auto_free(_instance)
add_child(_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: 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) # 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(1, 0))).is_equal(-1)
assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 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")
+89 -36
View File
@@ -46,11 +46,11 @@ var _captured_screenshot: Image = null
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count) # Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a # + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
# debug tool that is always active during Gauntlet play. # debug tool that is always active during Gauntlet play.
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts 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_head: int = 0 # Next write index (0..RING_SIZE-1)
var _input_count: int = 0 # Filled slot count (0..RING_SIZE) 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_head: int = 0
var _snapshot_count: int = 0 var _snapshot_count: int = 0
@@ -74,6 +74,7 @@ func _ready() -> void:
# Public API for main.gd: record one tick's data # Public API for main.gd: record one tick's data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
## Record one tick. Called from main.gd on every server tick (snapshot arrival). ## Record one tick. Called from main.gd on every server tick (snapshot arrival).
## - tick: current server tick number ## - tick: current server tick number
## - snapshot_json: JSON.stringify(GameState.current_snapshot) ## - 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) # Test-accessible accessors (ring buffer introspection)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _get_buffer_capacity() -> int: func _get_buffer_capacity() -> int:
return RING_SIZE return RING_SIZE
func _get_snapshot_buffer_capacity() -> int: func _get_snapshot_buffer_capacity() -> int:
return RING_SIZE return RING_SIZE
func _get_input_buffer() -> Array: func _get_input_buffer() -> Array:
return _input_ring return _input_ring
func _get_filled_input_count() -> int: func _get_filled_input_count() -> int:
return _input_count return _input_count
func _get_filled_snapshot_count() -> int: func _get_filled_snapshot_count() -> int:
return _snapshot_count return _snapshot_count
@@ -200,6 +206,7 @@ func _get_filled_snapshot_count() -> int:
# UI / capture flow # UI / capture flow
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func start_capture() -> void: func start_capture() -> void:
if _active: if _active:
return return
@@ -209,10 +216,15 @@ func start_capture() -> void:
visible = true visible = true
# Pause the simulation # Pause the simulation
SimBridge.send_input({ (
"action": InputMapper.Action.PAUSE, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
}) {
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
# Create the LineEdit dynamically # Create the LineEdit dynamically
_line_edit = LineEdit.new() _line_edit = LineEdit.new()
@@ -254,10 +266,15 @@ func _close() -> void:
_captured_screenshot = null _captured_screenshot = null
# Unpause the simulation # Unpause the simulation
SimBridge.send_input({ (
"action": InputMapper.Action.UNPAUSE, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
}) {
"action": InputMapper.Action.UNPAUSE,
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
func _save_report(description: String) -> void: func _save_report(description: String) -> void:
@@ -301,7 +318,9 @@ func _save_report(description: String) -> void:
if desc_file: if desc_file:
desc_file.store_string("Description: %s\n" % description) desc_file.store_string("Description: %s\n" % description)
desc_file.store_string("Tick: %d\n" % tick) 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("Stance: %s\n" % GameState.player_stance)
desc_file.store_string("Facing: %s\n" % GameState.player_facing) desc_file.store_string("Facing: %s\n" % GameState.player_facing)
desc_file.store_string("Position: %s\n" % str(GameState.player_position)) 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() var seed_val: Variant = _get_current_seed()
seed_file.store_string(str(seed_val) + "\n") seed_file.store_string(str(seed_val) + "\n")
if seed_val == "unavailable": 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() seed_file.close()
files_saved += 1 files_saved += 1
@@ -360,8 +382,12 @@ func _save_report(description: String) -> void:
else: else:
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err]) push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
print("BugReport: saved %d/7 files to %s (ring: %d ticks)" % [ print(
files_saved, base_path, _input_count]) (
"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. ## Simplified client-side text render of the current snapshot.
@@ -369,14 +395,24 @@ func _save_report(description: String) -> void:
func _render_snapshot_text() -> String: func _render_snapshot_text() -> String:
var lines: PackedStringArray = [] var lines: PackedStringArray = []
lines.append("=== Snapshot t%d ===" % GameState.current_tick) lines.append("=== Snapshot t%d ===" % GameState.current_tick)
lines.append("Player: %s facing %s (%s)" % [ lines.append(
str(GameState.player_position), GameState.player_facing, GameState.player_stance]) (
"Player: %s facing %s (%s)"
% [str(GameState.player_position), GameState.player_facing, GameState.player_stance]
)
)
if GameState.game_time.size() > 0: if GameState.game_time.size() > 0:
lines.append("Time: day %s, %s, %s" % [ lines.append(
str(GameState.game_time.get("day", "?")), (
str(GameState.game_time.get("day_phase", "?")), "Time: day %s, %s, %s"
str(GameState.game_time.get("tick_rate", "?"))]) % [
str(GameState.game_time.get("day", "?")),
str(GameState.game_time.get("day_phase", "?")),
str(GameState.game_time.get("tick_rate", "?"))
]
)
)
lines.append("") lines.append("")
lines.append("Entities (%d):" % GameState.visible_entities.size()) 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: elif entity.has("kind") and entity.kind is String:
kind_str = entity.kind kind_str = entity.kind
var vis: String = entity.get("visibility", "?") var vis: String = entity.get("visibility", "?")
lines.append(" #%s %s at (%s, %s) [%s]" % [ lines.append(
str(entity.get("entity_id", "?")), (
kind_str, " #%s %s at (%s, %s) [%s]"
str(entity.get("x", "?")), % [
str(entity.get("y", "?")), str(entity.get("entity_id", "?")),
vis]) kind_str,
str(entity.get("x", "?")),
str(entity.get("y", "?")),
vis
]
)
)
lines.append("") lines.append("")
lines.append("Visible tiles: %d" % GameState.visible_tiles.size()) lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
@@ -400,9 +442,15 @@ func _render_snapshot_text() -> String:
if GameState.current_monologue != null: if GameState.current_monologue != null:
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", ""))) lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
if GameState.current_dialogue != null: if GameState.current_dialogue != null:
lines.append("Dialogue: %s says '%s'" % [ lines.append(
str(GameState.current_dialogue.get("npc_name", "?")), (
str(GameState.current_dialogue.get("speech", ""))]) "Dialogue: %s says '%s'"
% [
str(GameState.current_dialogue.get("npc_name", "?")),
str(GameState.current_dialogue.get("speech", ""))
]
)
)
return "\n".join(lines) return "\n".join(lines)
@@ -416,8 +464,7 @@ func _draw() -> void:
# Center box # Center box
var box_pos := Vector2( var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0
(viewport_size.y - BOX_HEIGHT) / 2.0
) )
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
@@ -425,15 +472,21 @@ func _draw() -> void:
# Title # Title
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
draw_string(font, draw_string(
font,
box_pos + Vector2(PADDING, 24), box_pos + Vector2(PADDING, 24),
"WRONG — Describe the issue (Enter to save, Esc to cancel):", "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 # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func is_active() -> bool: func is_active() -> bool:
return _active return _active
+235 -94
View File
@@ -16,10 +16,10 @@ signal creation_cancelled
const BG_COLOR := Color(0.03, 0.03, 0.06, 1.0) 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 PANEL_BG := Color(0.05, 0.05, 0.08, 1.0)
const BORDER_COLOR := Color(0.12, 0.15, 0.20, 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 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 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_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_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 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) --- # --- Camera presets (D-158) ---
# Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down. # Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down.
# Negative values = tilt downward (camera looks toward ground). # 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_PITCH_NAMES := ["Frontal", "Dramatic", "Overhead"]
const CAM_DISTANCE: float = 3.8 const CAM_DISTANCE: float = 3.8
const CAM_TARGET_HEIGHT: float = 0.85 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) --- # --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
const ACCESSORY_SLOTS := [ const ACCESSORY_SLOTS := [
"hat", "goggles", "mask", "backpack", "belt", "hat",
"wrist_l", "wrist_r", "earring_l", "earring_r", "necklace", "goggles",
"mask",
"backpack",
"belt",
"wrist_l",
"wrist_r",
"earring_l",
"earring_r",
"necklace",
] ]
const ACCESSORY_SLOT_LABELS := [ const ACCESSORY_SLOT_LABELS := [
"Hat", "Goggles", "Mask", "Backpack", "Belt", "Hat",
"Wrist L", "Wrist R", "Earring L", "Earring R", "Necklace", "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) --- # --- 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). # Hardcoded hex per D-165 spec (HSL L=18→76% per chromatic row, S fixed per row).
const PALETTE_COLORS: Array[Array] = [ const PALETTE_COLORS: Array[Array] = [
# Row 1: Reds H=0° S=35% # 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% # 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% # 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% # 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% # 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 # 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 PALETTE_COLS := 9
const RECENT_SLOTS := 9 const RECENT_SLOTS := 9
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail) 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_MAX: float = 3.0 # farthest zoom (crowd level)
const CAM_ZOOM_STEP: float = 0.12 const CAM_ZOOM_STEP: float = 0.12
const MANIFEST_PATH := "res://assets/characters/manifest.json" 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 --- # --- Descriptor and preview state ---
var _descriptor: CharacterVisualDescriptor var _descriptor: CharacterVisualDescriptor
var _char_visual: CharacterVisual = null 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_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: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed 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" var _active_accessory_slot: String = "hat"
# --- Body/skin tone buttons shared across Body + Head tabs --- # --- 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 _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 var _head_skin_btns: Array[Button] = [] # 9 buttons in Head tab skin dock
# --- Head / hair buttons --- # --- Head / hair buttons ---
var _head_item_btns: Dictionary = {} # head_id -> Button var _head_item_btns: Dictionary = {} # head_id -> Button
var _hair_item_btns: Dictionary = {} # hair_id -> Button var _hair_item_btns: Dictionary = {} # hair_id -> Button
var _facial_hair_btns: Array[Button] = [] var _facial_hair_btns: Array[Button] = []
var _eyebrow_btns: Array[Button] = [] var _eyebrow_btns: Array[Button] = []
# --- Clothing/accessory grid content per slot --- # --- 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_slot_btns: Array[Button] = []
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button} var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
var _accessory_grids: Dictionary = {} # slot -> GridContainer var _accessory_grids: Dictionary = {} # slot -> GridContainer
var _accessory_slot_btns: Array[Button] = [] 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 --- # --- Color dock swatch references ---
var _hair_primary_swatch: Button = null var _hair_primary_swatch: Button = null
var _hair_highlight_swatch: Button = null var _hair_highlight_swatch: Button = null
var _eyebrow_tint_swatch: Button = null var _eyebrow_tint_swatch: Button = null
var _facial_hair_tint_swatch: Button = null var _facial_hair_tint_swatch: Button = null
var _clothing_primary_swatches: Dictionary = {} # slot -> Button var _clothing_primary_swatches: Dictionary = {} # slot -> Button
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
var _clothing_accent_swatches: Dictionary = {} # slot -> Button var _clothing_accent_swatches: Dictionary = {} # slot -> Button
var _accessory_primary_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) --- # --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
# Note: hair highlight is always auto-derived (display-only swatch, no override). # Note: hair highlight is always auto-derived (display-only swatch, no override).
var _eyebrow_tint_auto: bool = true var _eyebrow_tint_auto: bool = true
var _facial_hair_tint_auto: bool = true var _facial_hair_tint_auto: bool = true
var _clothing_secondary_auto: Dictionary = {} # slot -> bool var _clothing_secondary_auto: Dictionary = {} # slot -> bool
var _clothing_accent_auto: Dictionary = {} # slot -> bool var _clothing_accent_auto: Dictionary = {} # slot -> bool
var _accessory_secondary_auto: Dictionary = {} # slot -> bool var _accessory_secondary_auto: Dictionary = {} # slot -> bool
# --- Color picker modal state --- # --- Color picker modal state ---
var _modal_callback: Callable 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_preview_swatch: Button = null # the swatch button being edited
var _modal_hex_input: LineEdit = null var _modal_hex_input: LineEdit = null
var _modal_swatch_btns: Array[Button] = [] # 54 palette swatches 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] = [] var _modal_recent_btns: Array[Button] = []
# --- Dock container references (set during tab build; avoid fragile child-index traversal) --- # --- 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 references to .tscn nodes ---
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport @onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor @onready
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn @onready
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn 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 _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
@onready var _footer_back: Button = $Footer/BackBtn @onready var _footer_back: Button = $Footer/BackBtn
@onready var _footer_randomize: Button = $Footer/RandomizeBtn @onready var _footer_randomize: Button = $Footer/RandomizeBtn
@onready var _footer_start: Button = $Footer/StartBtn @onready var _footer_start: Button = $Footer/StartBtn
@onready var _modal_root: Control = $ColorPickerModal @onready var _modal_root: Control = $ColorPickerModal
# ============================================================================= # =============================================================================
# Lifecycle # Lifecycle
# ============================================================================= # =============================================================================
func _load_manifest() -> void: func _load_manifest() -> void:
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ) var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
if file == null: 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 = {} _manifest = {}
return return
var parsed: Variant = JSON.parse_string(file.get_as_text()) var parsed: Variant = JSON.parse_string(file.get_as_text())
@@ -277,12 +359,16 @@ func _ready() -> void:
if has_hair: if has_hair:
tab_builders.append({"name": "Hair", "build": _build_hair_tab, "always": false}) tab_builders.append({"name": "Hair", "build": _build_hair_tab, "always": false})
var clothing_data: Variant = _manifest.get("clothing", {}) 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: if has_clothing:
tab_builders.append({"name": "Clothing", "build": _build_clothing_tab, "always": false}) tab_builders.append({"name": "Clothing", "build": _build_clothing_tab, "always": false})
var has_accessories := not _manifest_array("accessories").is_empty() var has_accessories := not _manifest_array("accessories").is_empty()
if has_accessories: 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}) tab_builders.append({"name": "Debug", "build": _build_debug_tab, "always": true})
for tb in tab_builders: for tb in tab_builders:
@@ -337,6 +423,7 @@ func _ready() -> void:
# Camera management (D-158) # Camera management (D-158)
# ============================================================================= # =============================================================================
func _update_camera_angle() -> void: func _update_camera_angle() -> void:
var pitch_deg := CAM_PITCHES[_cam_pitch_idx] var pitch_deg := CAM_PITCHES[_cam_pitch_idx]
var pitch_rad := deg_to_rad(-pitch_deg) # negative = looking downward 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: if _char_visual and _char_visual._skeleton:
var head_idx := _char_visual._skeleton.find_bone("Head") var head_idx := _char_visual._skeleton.find_bone("Head")
if head_idx >= 0: if head_idx >= 0:
var head_pos := _char_visual._skeleton.global_transform \ var head_pos := (
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin _char_visual._skeleton.global_transform
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
)
# Blend from body center toward head as zoom increases # Blend from body center toward head as zoom increases
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom 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 _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) # Rotation (D-155: cardinal only, Q=CCW, E=CW)
# ============================================================================= # =============================================================================
func _on_rotate_left() -> void: func _on_rotate_left() -> void:
_facing_idx = (_facing_idx + 1) % CARDINAL_DIRS.size() _facing_idx = (_facing_idx + 1) % CARDINAL_DIRS.size()
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx]) _char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
@@ -398,6 +488,7 @@ func _on_rotate_right() -> void:
# Preview refresh # Preview refresh
# ============================================================================= # =============================================================================
func _refresh_preview() -> void: func _refresh_preview() -> void:
if _char_visual and is_instance_valid(_char_visual): if _char_visual and is_instance_valid(_char_visual):
_char_visual.load_descriptor(_descriptor) _char_visual.load_descriptor(_descriptor)
@@ -408,6 +499,7 @@ func _refresh_preview() -> void:
# Tab: Body (Task #2) # Tab: Body (Task #2)
# ============================================================================= # =============================================================================
func _build_body_tab(tab: Control) -> void: func _build_body_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab) var vbox := _make_tab_vbox(tab)
@@ -475,10 +567,13 @@ func _build_body_tab(tab: Control) -> void:
var eye_row := HBoxContainer.new() var eye_row := HBoxContainer.new()
eye_row.add_theme_constant_override("separation", 8) eye_row.add_theme_constant_override("separation", 8)
vbox.add_child(eye_row) 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: func(c: Color) -> void:
_descriptor.eye_color = c _descriptor.eye_color = c
_refresh_preview()) _refresh_preview()
)
eye_row.add_child(eye_swatch) eye_row.add_child(eye_swatch)
_update_body_type_btns() _update_body_type_btns()
@@ -527,6 +622,7 @@ func _update_body_type_btns() -> void:
# Tab: Head (Task #3) # Tab: Head (Task #3)
# ============================================================================= # =============================================================================
func _build_head_tab(tab: Control) -> void: func _build_head_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab) var vbox := _make_tab_vbox(tab)
@@ -564,10 +660,13 @@ func _build_head_tab(tab: Control) -> void:
var eye_row := HBoxContainer.new() var eye_row := HBoxContainer.new()
eye_row.add_theme_constant_override("separation", 8) eye_row.add_theme_constant_override("separation", 8)
vbox.add_child(eye_row) 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: func(c: Color) -> void:
_descriptor.eye_color = c _descriptor.eye_color = c
_refresh_preview()) _refresh_preview()
)
eye_row.add_child(eye_swatch) eye_row.add_child(eye_swatch)
_update_head_btns() _update_head_btns()
@@ -589,6 +688,7 @@ func _update_head_btns() -> void:
# Tab: Hair (Task #4) # Tab: Hair (Task #4)
# ============================================================================= # =============================================================================
func _build_hair_tab(tab: Control) -> void: func _build_hair_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab) var vbox := _make_tab_vbox(tab)
@@ -669,7 +769,9 @@ func _update_hair_btns() -> void:
for hid: String in _hair_item_btns: for hid: String in _hair_item_btns:
_set_item_selected(_hair_item_btns[hid], hid == _descriptor.hair_id) _set_item_selected(_hair_item_btns[hid], hid == _descriptor.hair_id)
for i in FACIAL_HAIR_OPTIONS.size(): 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 # 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) row.add_theme_constant_override("separation", 8)
dock.add_child(row) dock.add_child(row)
_hair_primary_swatch = _make_color_swatch(Color(0.55, 0.35, 0.20), "Primary", _hair_primary_swatch = _make_color_swatch(
func(c): _on_hair_primary_changed(c)) Color(0.55, 0.35, 0.20), "Primary", func(c): _on_hair_primary_changed(c)
)
row.add_child(_hair_primary_swatch) row.add_child(_hair_primary_swatch)
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable. # #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
_hair_highlight_swatch = _make_display_swatch( _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) row.add_child(_hair_highlight_swatch)
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●", _eyebrow_tint_swatch = _make_color_swatch(
func(c): _on_eyebrow_tint_changed(c)) _descriptor.hair_tint, "Brows ●", func(c): _on_eyebrow_tint_changed(c)
)
row.add_child(_eyebrow_tint_swatch) row.add_child(_eyebrow_tint_swatch)
_facial_hair_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Facial ●", _facial_hair_tint_swatch = _make_color_swatch(
func(c): _on_facial_hair_tint_changed(c)) _descriptor.hair_tint, "Facial ●", func(c): _on_facial_hair_tint_changed(c)
)
row.add_child(_facial_hair_tint_swatch) row.add_child(_facial_hair_tint_swatch)
return dock return dock
@@ -743,6 +849,7 @@ func _on_facial_hair_tint_changed(color: Color) -> void:
# Tab: Clothing (Task #5) # Tab: Clothing (Task #5)
# ============================================================================= # =============================================================================
func _build_clothing_tab(tab: Control) -> void: func _build_clothing_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab) var vbox := _make_tab_vbox(tab)
@@ -851,23 +958,25 @@ func _rebuild_clothing_color_dock(container: Control) -> void:
container.add_child(row) container.add_child(row)
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, "")) 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 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 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 accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
var p_swatch := _make_color_swatch(primary, "Primary", var p_swatch := _make_color_swatch(primary, "Primary", func(c): _on_clothing_primary_changed(c))
func(c): _on_clothing_primary_changed(c))
row.add_child(p_swatch) row.add_child(p_swatch)
_clothing_primary_swatches[_active_clothing_slot] = p_swatch _clothing_primary_swatches[_active_clothing_slot] = p_swatch
var s_swatch := _make_color_swatch(secondary, "Secondary ●", var s_swatch := _make_color_swatch(
func(c): _on_clothing_secondary_changed(c)) secondary, "Secondary ●", func(c): _on_clothing_secondary_changed(c)
)
row.add_child(s_swatch) row.add_child(s_swatch)
_clothing_secondary_swatches[_active_clothing_slot] = s_swatch _clothing_secondary_swatches[_active_clothing_slot] = s_swatch
var a_swatch := _make_color_swatch(accent, "Accent ●", var a_swatch := _make_color_swatch(accent, "Accent ●", func(c): _on_clothing_accent_changed(c))
func(c): _on_clothing_accent_changed(c))
row.add_child(a_swatch) row.add_child(a_swatch)
_clothing_accent_swatches[_active_clothing_slot] = 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 tints[0] = color
_descriptor.clothing_tints[item_id] = tints _descriptor.clothing_tints[item_id] = tints
if _clothing_secondary_auto.get(_active_clothing_slot, true): 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() _refresh_preview()
@@ -953,6 +1064,7 @@ func _update_clothing_item_btns() -> void:
# Tab: Accessories (Task #6) # Tab: Accessories (Task #6)
# ============================================================================= # =============================================================================
func _build_accessories_tab(tab: Control) -> void: func _build_accessories_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab) var vbox := _make_tab_vbox(tab)
@@ -1032,17 +1144,22 @@ func _rebuild_accessory_color_dock(container: Control) -> void:
container.add_child(row) container.add_child(row)
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, "")) 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 primary: Color = tints_arr[0] as Color if not tints_arr.is_empty() else Color.WHITE
var secondary: Color = _derive_secondary(primary) var secondary: Color = _derive_secondary(primary)
var p_swatch := _make_color_swatch(primary, "Primary", var p_swatch := _make_color_swatch(
func(c): _on_accessory_primary_changed(c)) primary, "Primary", func(c): _on_accessory_primary_changed(c)
)
row.add_child(p_swatch) row.add_child(p_swatch)
_accessory_primary_swatches[_active_accessory_slot] = p_swatch _accessory_primary_swatches[_active_accessory_slot] = p_swatch
var s_swatch := _make_color_swatch(secondary, "Secondary ●", var s_swatch := _make_color_swatch(
func(c): _on_accessory_secondary_changed(c)) secondary, "Secondary ●", func(c): _on_accessory_secondary_changed(c)
)
row.add_child(s_swatch) row.add_child(s_swatch)
_accessory_secondary_swatches[_active_accessory_slot] = s_swatch _accessory_secondary_swatches[_active_accessory_slot] = s_swatch
@@ -1103,6 +1220,7 @@ func _update_accessory_item_btns() -> void:
# Screenshot & automated testing # Screenshot & automated testing
# ============================================================================= # =============================================================================
func _schedule_screenshot() -> void: func _schedule_screenshot() -> void:
_screenshot_pending = true _screenshot_pending = true
_screenshot_frame_count = 0 _screenshot_frame_count = 0
@@ -1125,10 +1243,10 @@ func _take_screenshot(suffix: String = "") -> void:
_char_visual.set_facing(CARDINAL_DIRS[_screenshot_cardinal_idx]) _char_visual.set_facing(CARDINAL_DIRS[_screenshot_cardinal_idx])
suffix = dir_name suffix = dir_name
var filename := "charcreator_%s_%s.png" % [ var filename := (
_descriptor.body_type_key(), "charcreator_%s_%s.png"
suffix if not suffix.is_empty() else "default" % [_descriptor.body_type_key(), suffix if not suffix.is_empty() else "default"]
] )
var path := SCREENSHOT_DIR + filename var path := SCREENSHOT_DIR + filename
var img := get_viewport().get_texture().get_image() var img := get_viewport().get_texture().get_image()
img.save_png(path) img.save_png(path)
@@ -1212,6 +1330,7 @@ func _load_test_config() -> void:
# Debug tab — segment visibility toggles # Debug tab — segment visibility toggles
# ============================================================================= # =============================================================================
func _build_debug_tab(tab: Control) -> void: func _build_debug_tab(tab: Control) -> void:
var vbox := _make_tab_vbox(tab) 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 # Color picker modal (Task #7) — D-165 palette
# ============================================================================= # =============================================================================
func _build_color_picker_modal() -> void: func _build_color_picker_modal() -> void:
var modal := _modal_root var modal := _modal_root
@@ -1496,6 +1616,7 @@ func _update_modal_recent_btns() -> void:
# Input handling (Task #8 keyboard nav) # Input handling (Task #8 keyboard nav)
# ============================================================================= # =============================================================================
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if not visible: if not visible:
return return
@@ -1547,7 +1668,9 @@ func _input(event: InputEvent) -> void:
KEY_TAB: KEY_TAB:
var count := _tab_container.get_tab_count() var count := _tab_container.get_tab_count()
if key.shift_pressed: 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: else:
_tab_container.current_tab = (_tab_container.current_tab + 1) % count _tab_container.current_tab = (_tab_container.current_tab + 1) % count
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
@@ -1557,6 +1680,7 @@ func _input(event: InputEvent) -> void:
# Game flow (Task #8) # Game flow (Task #8)
# ============================================================================= # =============================================================================
func _on_back() -> void: func _on_back() -> void:
creation_cancelled.emit() creation_cancelled.emit()
@@ -1569,6 +1693,7 @@ func _on_start() -> void:
# Randomize # Randomize
# ============================================================================= # =============================================================================
func _on_randomize() -> void: func _on_randomize() -> void:
# Body type — pick from manifest only # Body type — pick from manifest only
var available_types: Array = _manifest_array("body_types") var available_types: Array = _manifest_array("body_types")
@@ -1595,7 +1720,9 @@ func _on_randomize() -> void:
_descriptor.facial_hair_id = "" _descriptor.facial_hair_id = ""
# Eye color — random natural tones # 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 # Sync auto tints
_eyebrow_tint_auto = true _eyebrow_tint_auto = true
@@ -1615,6 +1742,7 @@ func _on_randomize() -> void:
# Shared helpers — skin tone dock # Shared helpers — skin tone dock
# ============================================================================= # =============================================================================
func _build_skin_tone_dock() -> HBoxContainer: func _build_skin_tone_dock() -> HBoxContainer:
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4) row.add_theme_constant_override("separation", 4)
@@ -1659,6 +1787,7 @@ func _update_skin_tone_btns() -> void:
# Shared helpers — color swatches # Shared helpers — color swatches
# ============================================================================= # =============================================================================
## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect). ## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect).
func _get_swatch_color(btn: Button) -> Color: func _get_swatch_color(btn: Button) -> Color:
for child in btn.get_children(): 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) vbox.add_child(lbl)
# Use _get_swatch_color to read current color at click time (avoids stale closure). # Use _get_swatch_color to read current color at click time (avoids stale closure).
btn.pressed.connect(func(): btn.pressed.connect(func(): _open_color_picker(_get_swatch_color(btn), btn, callback))
_open_color_picker(_get_swatch_color(btn), btn, callback)
)
return btn return btn
@@ -1754,6 +1881,7 @@ func _set_swatch_color(btn: Button, color: Color) -> void:
# Shared helpers — layout builders # Shared helpers — layout builders
# ============================================================================= # =============================================================================
func _make_tab_vbox(tab: Control) -> VBoxContainer: func _make_tab_vbox(tab: Control) -> VBoxContainer:
var margin := MarginContainer.new() var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) 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.placeholder_text = UIStrings.get_text("character_creation.search_placeholder")
search.add_theme_color_override("font_color", TEXT_COLOR) search.add_theme_color_override("font_color", TEXT_COLOR)
search.add_theme_font_size_override("font_size", 12) search.add_theme_font_size_override("font_size", 12)
search.text_changed.connect(func(q: String): search.text_changed.connect(
_tab_search[tab_idx] = q func(q: String):
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index _tab_search[tab_idx] = q
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot): # Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
_apply_search_filter(_clothing_grids[_active_clothing_slot], q) if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot): _apply_search_filter(_clothing_grids[_active_clothing_slot], q)
_apply_search_filter(_accessory_grids[_active_accessory_slot], q) elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null: _apply_search_filter(_accessory_grids[_active_accessory_slot], q)
_apply_search_filter(_tab_grids[tab_idx], q) elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
_apply_search_filter(_tab_grids[tab_idx], q)
) )
return search return search
@@ -1849,7 +1978,9 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void:
var lower_q := query.to_lower() var lower_q := query.to_lower()
for child in grid.get_children(): for child in grid.get_children():
if child is Button: 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: 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. # Accessory items from manifest, filtered by slot name prefix convention.
var all_ids: Array = _manifest_array("accessories") var all_ids: Array = _manifest_array("accessories")
match slot: match slot:
"hat": return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat")) "hat":
"goggles": return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle")) return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
"mask": return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask")) "goggles":
"backpack": return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack")) return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
"belt": return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt")) "mask":
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist")) return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring")) "backpack":
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace")) return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
_: return all_ids # gdlint:ignore = max-returns "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 # Color derivation helpers
# ============================================================================= # =============================================================================
func _derive_hair_highlight(primary: Color) -> Color: func _derive_hair_highlight(primary: Color) -> Color:
return primary.lightened(0.3) return primary.lightened(0.3)
+29 -8
View File
@@ -9,10 +9,10 @@ extends Control
const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd") const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45) const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
const FONT_SIZE := 11 const FONT_SIZE := 11
const LINE_HEIGHT := 16 const LINE_HEIGHT := 16
const PADDING := Vector2(8, 6) const PADDING := Vector2(8, 6)
@@ -79,13 +79,17 @@ func _draw() -> void:
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
# Calculate box width from longest line # 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: for r in results:
var desc: String = r.get("description", r.get("id", "")) var desc: String = r.get("description", r.get("id", ""))
if desc.length() > MAX_DESC_CHARS: if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..." desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var prefix: String = "[x] " if r.get("met", false) else "[ ] " 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: if line_width > max_width:
max_width = line_width max_width = line_width
@@ -96,7 +100,15 @@ func _draw() -> void:
# Header # Header
var y: float = PADDING.y + FONT_SIZE 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 # Condition lines
for r in results: for r in results:
@@ -107,11 +119,20 @@ func _draw() -> void:
if desc.length() > MAX_DESC_CHARS: if desc.length() > MAX_DESC_CHARS:
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..." desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
var color: Color = MET_COLOR if is_met else UNMET_COLOR 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 --------------------------------------------------------------- # -- Public API ---------------------------------------------------------------
func get_evaluator(): func get_evaluator():
return _evaluator return _evaluator
+34 -19
View File
@@ -7,7 +7,7 @@ extends Control
## Settings-toggled; enabled state persisted in user://settings.cfg. ## Settings-toggled; enabled state persisted in user://settings.cfg.
## D-088: triggers Overlay pause while open — sim must not advance during debug input. ## 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 signal unpause_requested # D-088: unpause sim when console closes
const PREFS_PATH := "user://settings.cfg" const PREFS_PATH := "user://settings.cfg"
@@ -97,6 +97,7 @@ func _update_panel_layout() -> void:
# -- Input handling -- # -- Input handling --
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if not _enabled: if not _enabled:
return return
@@ -155,6 +156,7 @@ func is_open() -> bool:
# -- Command input -- # -- Command input --
func _on_input_submitted(text: String) -> void: func _on_input_submitted(text: String) -> void:
var trimmed := text.strip_edges() var trimmed := text.strip_edges()
_input_line.clear() _input_line.clear()
@@ -199,7 +201,9 @@ func _dispatch(line: String) -> void:
z = int(parts[3]) z = int(parts[3])
else: else:
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR) _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: else:
var loc := " ".join(PackedStringArray(parts.slice(1))) var loc := " ".join(PackedStringArray(parts.slice(1)))
_send_debug({"TeleportToLocation": loc}) _send_debug({"TeleportToLocation": loc})
@@ -226,17 +230,23 @@ func _dispatch(line: String) -> void:
func _send_debug(kind: Variant) -> void: func _send_debug(kind: Variant) -> void:
var err := SimBridge.send_input({ var err := (
"action": InputMapper.Action.DEBUG_COMMAND, SimBridge
"action_data": kind, . send_input(
"timestamp_msec": Time.get_ticks_msec(), {
}) "action": InputMapper.Action.DEBUG_COMMAND,
"action_data": kind,
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
if err != OK: if err != OK:
_append_text("send error: %s" % error_string(err), ERROR_COLOR) _append_text("send error: %s" % error_string(err), ERROR_COLOR)
# -- Response display -- # -- Response display --
## Append a server debug response to the output log. Auto-opens console if closed ## Append a server debug response to the output log. Auto-opens console if closed
## (only if console is enabled — respect user's settings toggle). ## (only if console is enabled — respect user's settings toggle).
func append_response(response: Dictionary) -> void: func append_response(response: Dictionary) -> void:
@@ -250,6 +260,7 @@ func append_response(response: Dictionary) -> void:
# -- Log rendering -- # -- Log rendering --
func _append_text(text: String, color: Color) -> void: func _append_text(text: String, color: Color) -> void:
var escaped := text.replace("[", "[lb]").replace("]", "[rb]") var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped]) _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: func _print_help() -> void:
_append_text( _append_text(
"Commands:\n" (
+ " ticks <n> — fast-forward N ticks\n" "Commands:\n"
+ " contaminate — skip to contamination phase\n" + " ticks <n> — fast-forward N ticks\n"
+ " tp <x> <y> [z] — teleport to tile position\n" + " contaminate — skip to contamination phase\n"
+ " tp <location> — teleport to named location\n" + " tp <x> <y> [z] — teleport to tile position\n"
+ " activate — force contamination activate\n" + " tp <location> — teleport to named location\n"
+ " triangle <id> — force triangle activation\n" + " activate — force contamination activate\n"
+ " npc <entity_id> — inspect NPC state\n" + " triangle <id> — force triangle activation\n"
+ " triangles — list all triangles\n" + " npc <entity_id> — inspect NPC state\n"
+ " pop — list active NPCs\n" + " triangles — list all triangles\n"
+ " status — contamination status\n" + " pop — list active NPCs\n"
+ " help — this list", + " status — contamination status\n"
+ " help — this list"
),
TEXT_COLOR TEXT_COLOR
) )
# -- Command history -- # -- Command history --
func _history_up() -> void: func _history_up() -> void:
if _history.is_empty(): if _history.is_empty():
return return
@@ -299,6 +313,7 @@ func _history_down() -> void:
# -- Settings -- # -- Settings --
func set_enabled(enabled: bool) -> void: func set_enabled(enabled: bool) -> void:
_enabled = enabled _enabled = enabled
if not _enabled and _open: if not _enabled and _open:
+144 -43
View File
@@ -37,7 +37,7 @@ const GRAPH_WARN_COLOR := Color("#e8c547")
# Vision cone geometry (radians) # Vision cone geometry (radians)
# Forward: ±60° around facing direction (120° total) # Forward: ±60° around facing direction (120° total)
# Peripheral: ±60° to ±120° on each side (60° band each side) # 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_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
const CONE_ARC_STEPS: int = 20 const CONE_ARC_STEPS: int = 20
@@ -65,17 +65,18 @@ var _npc_paths: Dictionary = {}
var _last_tick_processed: int = -1 var _last_tick_processed: int = -1
# Tick timing ring # 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 var _tick_deltas: Array = [] # ms between consecutive snapshots
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lifecycle # Lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _ready() -> void: func _ready() -> void:
_dev_mode = OS.is_debug_build() _dev_mode = OS.is_debug_build()
visible = false visible = false
# Position set in main.tscn: below merged HUD status panel (starts at y:16)
_cached_font = ThemeDB.fallback_font _cached_font = ThemeDB.fallback_font
# Clear NPC path history on session change to prevent entity ID collisions # Clear NPC path history on session change to prevent entity ID collisions
GameState.connect("game_id_changed", _on_game_id_changed) GameState.connect("game_id_changed", _on_game_id_changed)
@@ -147,6 +148,7 @@ func _update_npc_paths() -> void:
# Draw dispatch # Draw dispatch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _draw() -> void: func _draw() -> void:
if not visible: if not visible:
return return
@@ -159,6 +161,7 @@ func _draw() -> void:
# Panel 1: Stats text (top-left) # Panel 1: Stats text (top-left)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _draw_stats_panel() -> void: func _draw_stats_panel() -> void:
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font 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]) right_lines.append(["facing", GameState.player_facing])
left_lines.append(["stance", GameState.player_stance]) 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())]) left_lines.append(["entities", str(GameState.visible_entities.size())])
right_lines.append(["tiles", str(GameState.visible_tiles.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"]) right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
var gt := GameState.game_time 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 "-" var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
left_lines.append(["time", time_str]) left_lines.append(["time", time_str])
right_lines.append(["tick_rate", rate_str]) right_lines.append(["tick_rate", rate_str])
@@ -210,14 +217,26 @@ func _draw_stats_panel() -> void:
var right_value_w: float = 0.0 var right_value_w: float = 0.0
for line in left_lines: 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_label_w = max(
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) 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: 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_label_w = max(
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x) 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_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 content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
var box_w: float = max(header_w, content_w) + PADDING.x * 2 var box_w: float = max(header_w, content_w) + PADDING.x * 2
var line_count: int = maxi(left_lines.size(), right_lines.size()) 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) draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
var y: float = PADDING.y + FONT_SIZE 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 y += LINE_HEIGHT
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
for i in range(line_count): for i in range(line_count):
if i < left_lines.size(): if i < left_lines.size():
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ", draw_string(
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) font,
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], Vector2(PADDING.x, y),
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) 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(): if i < right_lines.size():
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ", draw_string(
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR) font,
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], Vector2(right_x, y),
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR) 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 y += LINE_HEIGHT
@@ -248,6 +303,7 @@ func _draw_stats_panel() -> void:
# Panel 2: World overlays # Panel 2: World overlays
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _draw_world_overlays() -> void: func _draw_world_overlays() -> void:
var vp := get_viewport() var vp := get_viewport()
if vp == null: 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_from := facing_angle + CONE_FORWARD_HALF
var perip_r_to := facing_angle + CONE_PERIPHERAL_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(
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR) _arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_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, 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 # Forward arc boundary ring
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0) 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 # 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() var pts := PackedVector2Array()
pts.append(center) pts.append(center)
for i in range(CONE_ARC_STEPS + 1): 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 a_screen := _w2s(path[i - 1], canvas_xf)
var b_screen := _w2s(path[i], canvas_xf) var b_screen := _w2s(path[i], canvas_xf)
var alpha := float(i) / float(path.size()) var alpha := float(i) / float(path.size())
draw_line(a_screen, b_screen, draw_line(
Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5) 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) 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, entity_screen.x - text_w / 2.0 - 3.0,
tag_baseline - FONT_SIZE + 2.0, tag_baseline - FONT_SIZE + 2.0,
text_w + 6.0, text_w + 6.0,
FONT_SIZE) FONT_SIZE
)
draw_rect(tag_rect, TAG_BG_COLOR) draw_rect(tag_rect, TAG_BG_COLOR)
draw_string(font, draw_string(
font,
Vector2(entity_screen.x - text_w / 2.0, tag_baseline), 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) # Panel 3: Tick timing sparkline (bottom-left)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _draw_tick_graph() -> void: func _draw_tick_graph() -> void:
if _tick_deltas.size() < 2: if _tick_deltas.size() < 2:
return return
@@ -398,10 +476,15 @@ func _draw_tick_graph() -> void:
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN 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_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), Vector2(box_x + 4, box_y + FONT_SIZE + 1),
"tick ms (n=%d)" % _tick_deltas.size(), "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_top := box_y + label_h
var chart_left := box_x + 4.0 var chart_left := box_x + 4.0
@@ -418,9 +501,12 @@ func _draw_tick_graph() -> void:
# Warn threshold dashed line # Warn threshold dashed line
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms) var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
draw_dashed_line( 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), Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
1.0, 4.0) 1.0,
4.0
)
# Sparkline # Sparkline
var n := _tick_deltas.size() var n := _tick_deltas.size()
@@ -441,25 +527,40 @@ func _draw_tick_graph() -> void:
for d in _tick_deltas: for d in _tick_deltas:
avg_ms += float(d) avg_ms += float(d)
avg_ms /= float(_tick_deltas.size()) 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), Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
"avg %.0fms" % avg_ms, "avg %.0fms" % avg_ms,
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR) HORIZONTAL_ALIGNMENT_LEFT,
-1,
FONT_SIZE - 1,
LABEL_COLOR
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North) # Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
static func _facing_to_angle(facing: String) -> float: static func _facing_to_angle(facing: String) -> float:
match facing: match facing:
"North": return -PI / 2.0 "North":
"Northeast": return -PI / 4.0 return -PI / 2.0
"East": return 0.0 "Northeast":
"Southeast": return PI / 4.0 return -PI / 4.0
"South": return PI / 2.0 "East":
"Southwest": return PI * 3.0 / 4.0 return 0.0
"West": return PI "Southeast":
"Northwest": return -PI * 3.0 / 4.0 return PI / 4.0
_: return -PI / 2.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
+87 -40
View File
@@ -13,7 +13,7 @@ extends Control
signal option_selected(response_id: String, text: String) signal option_selected(response_id: String, text: String)
signal dialogue_dismissed # Walk-away or conversation end signal dialogue_dismissed # Walk-away or conversation end
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue 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 signal unpause_requested # D-061: auto-unpause
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles. # D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
# Replaces direct GameState.dialogue_active mutation and AudioManager calls. # 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_IN: float = 0.2
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away 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_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_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 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_BEAT_DURATION: float = 1.5 # D-063: pause before sending
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat 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 # D-064: movement actions that trigger walk-away
const _WALK_AWAY_ACTIONS: Array[StringName] = [ const _WALK_AWAY_ACTIONS: Array[StringName] = [
&"move_north", &"move_south", &"move_east", &"move_west", &"move_north",
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest", &"move_south",
&"move_east",
&"move_west",
&"move_northeast",
&"move_southeast",
&"move_southwest",
&"move_northwest",
] ]
# -- Log state -- # -- 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. # Populated from server events; drives retroactive re-render when NPC names resolve.
var _entity_display: Dictionary = {} var _entity_display: Dictionary = {}
# -- Option state -- # -- Option state --
var _option_controls: Array[Control] = [] var _option_controls: Array[Control] = []
var _option_response_ids: Array[String] = [] var _option_response_ids: Array[String] = []
@@ -68,8 +73,8 @@ var _npc_name: String = ""
# v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid # v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid
# palette exhaustion (8 entries) across long sessions with 9+ NPCs. # palette exhaustion (8 entries) across long sessions with 9+ NPCs.
var _npc_entity_colors: Dictionary = {} # entity_id -> Color var _npc_entity_colors: Dictionary = {} # entity_id -> Color
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC 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 _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
# -- UI state -- # -- UI state --
var _active_tween: Tween = null var _active_tween: Tween = null
@@ -86,7 +91,8 @@ var _entry_fade: float = 5.0
@onready var panel: PanelContainer = $PanelContainer @onready var panel: PanelContainer = $PanelContainer
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog @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: func _ready() -> void:
@@ -114,15 +120,17 @@ func _unhandled_input(event: InputEvent) -> void:
# Number keys 1-3 select dialogue options # Number keys 1-3 select dialogue options
if event is InputEventKey and event.pressed: if event is InputEventKey and event.pressed:
var key_index := -1 var key_index := -1
if event.keycode == KEY_1: key_index = 0 if event.keycode == KEY_1:
elif event.keycode == KEY_2: key_index = 1 key_index = 0
elif event.keycode == KEY_3: key_index = 2 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(): if key_index >= 0 and key_index < _option_controls.size():
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
_on_option_pressed(key_index) _on_option_pressed(key_index)
return return
# D-064: WASD during active player dialogue → walk-away # D-064: WASD during active player dialogue → walk-away
if event is InputEventKey and event.pressed: if event is InputEventKey and event.pressed:
for action in _WALK_AWAY_ACTIONS: for action in _WALK_AWAY_ACTIONS:
@@ -136,11 +144,18 @@ func _unhandled_input(event: InputEvent) -> void:
# -- Theme loading -- # -- Theme loading --
func _load_theme() -> void: func _load_theme() -> void:
# Default NPC palette in case file not found # Default NPC palette in case file not found
_npc_colors = [ _npc_colors = [
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"), Color("#4a9ebb"),
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"), Color("#6bc9a6"),
Color("#e8c547"),
Color("#d49e5d"),
Color("#b586d4"),
Color("#d45d5d"),
Color("#5daa7d"),
Color("#7daccc"),
] ]
if not FileAccess.file_exists(THEME_PATH): if not FileAccess.file_exists(THEME_PATH):
@@ -180,6 +195,7 @@ func _load_theme() -> void:
# -- Responsive layout -- # -- Responsive layout --
func _update_layout() -> void: func _update_layout() -> void:
var vp := get_viewport_rect().size var vp := get_viewport_rect().size
var max_h := vp.y * MAX_HEIGHT_RATIO var max_h := vp.y * MAX_HEIGHT_RATIO
@@ -191,14 +207,21 @@ func _update_layout() -> void:
# -- Log entry management -- # -- Log entry management --
## Append a dialogue line to the log. ## Append a dialogue line to the log.
## speaker/target: display names. text: the spoken line. ## speaker/target: display names. text: the spoken line.
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated). ## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
## Active conversation entries are pinned (no timeout) while _in_player_conversation. ## 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). ## 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. ## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
func append_line(speaker: String, target: String, text: String, func append_line(
is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void: 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 pinned := not is_passive and _in_player_conversation
var entry: Dictionary = { var entry: Dictionary = {
"speaker": speaker, "speaker": speaker,
@@ -294,10 +317,13 @@ func append_dialogue_response(npc_name: String, text: String, entity_id: int = -
# -- Active player conversation -- # -- Active player conversation --
## Show dialogue with NPC speech and response options. ## Show dialogue with NPC speech and response options.
## npc_name: who is speaking. speech: the NPC's line. options: player choices. ## 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). ## 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_name = npc_name
_npc_entity_id = npc_entity_id _npc_entity_id = npc_entity_id
_cancel_beat() _cancel_beat()
@@ -379,6 +405,7 @@ func has_active_entries() -> bool:
# -- Options -- # -- Options --
func _show_options(options: Array) -> void: func _show_options(options: Array) -> void:
var sorted_opts: Array = options.duplicate() var sorted_opts: Array = options.duplicate()
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0)) 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.fit_content = true
rtl.scroll_active = false rtl.scroll_active = false
rtl.add_theme_font_size_override("normal_font_size", 14) rtl.add_theme_font_size_override("normal_font_size", 14)
rtl.add_theme_color_override("default_color", Color( rtl.add_theme_color_override(
Constants.INSERT_COLOR_TEXT.r * 1.08, "default_color",
Constants.INSERT_COLOR_TEXT.g * 0.96, Color(
Constants.INSERT_COLOR_TEXT.b * 0.90, Constants.INSERT_COLOR_TEXT.r * 1.08,
1.0 Constants.INSERT_COLOR_TEXT.g * 0.96,
)) # Slight warm tint for confrontation weight Constants.INSERT_COLOR_TEXT.b * 0.90,
1.0
)
) # Slight warm tint for confrontation weight
rtl.mouse_filter = Control.MOUSE_FILTER_STOP rtl.mouse_filter = Control.MOUSE_FILTER_STOP
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text] rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
@@ -419,9 +449,14 @@ func _show_options(options: Array) -> void:
ctrl = label ctrl = label
var idx := i var idx := i
ctrl.gui_input.connect(func(event: InputEvent): ctrl.gui_input.connect(
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: func(event: InputEvent):
_on_option_pressed(idx) 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_entered.connect(_make_hover_on(ctrl))
ctrl.mouse_exited.connect(_make_hover_off(ctrl)) ctrl.mouse_exited.connect(_make_hover_off(ctrl))
@@ -438,7 +473,9 @@ func _on_option_pressed(index: int) -> void:
return return
var rid: String = _option_response_ids[index] if index < _option_response_ids.size() else "" 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 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's chosen response to the log
append_player_line(_npc_name, text) append_player_line(_npc_name, text)
@@ -452,6 +489,7 @@ func _on_option_pressed(index: int) -> void:
# -- Confrontation beat (D-063) -- # -- Confrontation beat (D-063) --
func _start_confrontation_beat(response_id: String, text: String) -> void: func _start_confrontation_beat(response_id: String, text: String) -> void:
for ctrl in _option_controls: for ctrl in _option_controls:
if is_instance_valid(ctrl): 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 = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2) _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 audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
_beat_tween = create_tween() _beat_tween = create_tween()
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION) _beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
_beat_tween.tween_callback(func(): _beat_tween.tween_callback(
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager func():
option_selected.emit(response_id, text) audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
_end_player_conversation() option_selected.emit(response_id, text)
_end_player_conversation()
) )
@@ -483,6 +524,7 @@ func _cancel_beat() -> void:
# -- Log rendering -- # -- Log rendering --
## Rebuild the dialogue log BBCode from all non-expired entries. ## Rebuild the dialogue log BBCode from all non-expired entries.
func _rebuild_log() -> void: func _rebuild_log() -> void:
if not dialogue_log: if not dialogue_log:
@@ -550,7 +592,9 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
target_color = _npc_entity_colors[tg_eid] target_color = _npc_entity_colors[tg_eid]
else: else:
target_color = _color_for_name(entry.get("target", "?")) 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 text: String = _escape_bbcode(entry.text)
var is_passive: bool = entry.is_passive 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 # Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
if involves_player and not is_passive: if involves_player and not is_passive:
# Just "Speaker: text" or "You: text" # Just "Speaker: text" or "You: text"
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [ return (
prefix, sc, speaker, txc, text "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [prefix, sc, speaker, txc, text]
] )
# Full "Speaker → Target: text" for overheard # 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]" % [ return (
prefix, sc, speaker, ac, tc, target, txc, text "%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). ## 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 -- # -- Entry expiry --
## Remove fully expired entries. Mark dirty if any fading entries exist. ## Remove fully expired entries. Mark dirty if any fading entries exist.
## Pinned entries (active conversation) skip expiry entirely (Araminta #2). ## Pinned entries (active conversation) skip expiry entirely (Araminta #2).
func _expire_entries() -> void: func _expire_entries() -> void:
@@ -689,6 +735,7 @@ func _expire_entries() -> void:
# -- Visibility -- # -- Visibility --
## No-op — panel is always visible as a permanent insert UI element. ## No-op — panel is always visible as a permanent insert UI element.
func _ensure_visible() -> void: func _ensure_visible() -> void:
pass pass
+12 -6
View File
@@ -9,7 +9,7 @@ extends Control
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6). ## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
## Only one examine result is shown at a time — new result replaces old. ## 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_IN: float = 0.18
const FADE_OUT: float = 0.35 const FADE_OUT: float = 0.35
@@ -27,6 +27,7 @@ var _active: bool = false
@onready var panel: PanelContainer = $PanelContainer @onready var panel: PanelContainer = $PanelContainer
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel @onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
func _ready() -> void: func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
modulate.a = 0.0 modulate.a = 0.0
@@ -48,8 +49,12 @@ func show_result(result: Dictionary) -> void:
# Apply confidence-based alpha to the insert color # Apply confidence-based alpha to the insert color
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75) var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g, var col := Color(
Constants.INSERT_COLOR_TEXT.b, alpha) 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.add_theme_color_override("default_color", col)
text_label.text = text text_label.text = text
@@ -69,9 +74,10 @@ func _start_fade_out() -> void:
return return
var t := create_tween() var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT) t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): t.tween_callback(
visible = false func():
_active = false visible = false
_active = false
) )
+34 -9
View File
@@ -5,9 +5,9 @@ extends Control
## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json. ## 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 BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
const TIMER_COLOR := Color("#c8d0e0") # Default insert text const TIMER_COLOR := Color("#c8d0e0") # Default insert text
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
const FONT_SIZE := 13 const FONT_SIZE := 13
const PADDING := Vector2(10, 6) const PADDING := Vector2(10, 6)
const STATS_PATH := "user://dev/gauntlet-stats.json" const STATS_PATH := "user://dev/gauntlet-stats.json"
@@ -109,13 +109,28 @@ func _draw() -> void:
# Timer text # Timer text
var y_offset := PADDING.y + text_size.y var y_offset := PADDING.y + text_size.y
var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE) 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) # PB text (different color)
if not pb_text.is_empty(): if not pb_text.is_empty():
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR 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, draw_string(
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color) 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: static func _format_time(seconds: float) -> String:
@@ -154,9 +169,15 @@ func print_session_summary() -> void:
for room_id in _session_rooms: for room_id in _session_rooms:
var entry: Dictionary = _session_rooms[room_id] var entry: Dictionary = _session_rooms[room_id]
var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--" 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 "--:--" var pb_str := (
print(" Room %s: %d attempts, session best %s, all-time PB %s" % [ _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
room_id, entry["attempts"], best_str, pb_str]) )
print(
(
" Room %s: %d attempts, session best %s, all-time PB %s"
% [room_id, entry["attempts"], best_str, pb_str]
)
)
print("================================") print("================================")
@@ -170,14 +191,18 @@ func finalize() -> void:
# -- Public API --------------------------------------------------------------- # -- Public API ---------------------------------------------------------------
func get_timer_seconds() -> float: func get_timer_seconds() -> float:
return _timer_seconds return _timer_seconds
func is_timer_running() -> bool: func is_timer_running() -> bool:
return _timer_running return _timer_running
func get_current_room_id() -> Variant: func get_current_room_id() -> Variant:
return _current_room_id return _current_room_id
func get_personal_best(for_room_id: String) -> float: func get_personal_best(for_room_id: String) -> float:
return _personal_bests.get(for_room_id, INF) return _personal_bests.get(for_room_id, INF)
+3 -3
View File
@@ -2,16 +2,16 @@
[ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"] [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"] [node name="GauntletHUD" type="Control"]
layout_mode = 3 layout_mode = 3
anchors_preset = 1 anchors_preset = 1
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
offset_left = -160.0 offset_left = -160.0
offset_top = 48.0 offset_top = 254.0
offset_right = -16.0 offset_right = -16.0
offset_bottom = 74.0 offset_bottom = 280.0
grow_horizontal = 0 grow_horizontal = 0
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_gauntlet") script = ExtResource("1_gauntlet")
+40 -17
View File
@@ -8,10 +8,10 @@ extends Node
## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector"). ## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector").
## Settings dialog reads classification strings and power-state methods to configure the UI. ## 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 RAM_MARGINAL_THRESHOLD_MB := 1600.0
const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently
const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness. ## 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). # User's preference at the moment battery suspension activated (to restore on plug-in).
var _pre_battery_pref: bool = true var _pre_battery_pref: bool = true
# -- Lifecycle ---------------------------------------------------------------- # -- Lifecycle ----------------------------------------------------------------
func _ready() -> void: func _ready() -> void:
assert(PlatformInfo != null, "PlatformInfo must load before HardwareDetector") assert(PlatformInfo != null, "PlatformInfo must load before HardwareDetector")
load_ai_pref() load_ai_pref()
@@ -40,6 +40,7 @@ func _ready() -> void:
# -- Layer 1: RAM classification ---------------------------------------------- # -- Layer 1: RAM classification ----------------------------------------------
## Layer 1 — RAM classification. ## Layer 1 — RAM classification.
## Accepts free MB as input; returns "pass" | "marginal" | "fail". ## Accepts free MB as input; returns "pass" | "marginal" | "fail".
func classify_ram(free_mb: float) -> String: func classify_ram(free_mb: float) -> String:
@@ -54,11 +55,15 @@ func classify_ram(free_mb: float) -> String:
## Returns: {classification: String, free_mb: float} ## Returns: {classification: String, free_mb: float}
func check_ram() -> Dictionary: func check_ram() -> Dictionary:
PlatformInfo.refresh_memory() 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 ------------------------------------
## Layer 2 — TPT benchmark classification. ## Layer 2 — TPT benchmark classification.
## Accepts tokens/sec as input; returns "green" | "yellow" | "red". ## Accepts tokens/sec as input; returns "green" | "yellow" | "red".
func classify_tpt(tps: float) -> String: func classify_tpt(tps: float) -> String:
@@ -90,6 +95,7 @@ func read_benchmark_cache() -> Variant:
# -- Layer 3: degradation + battery ------------------------------------------- # -- Layer 3: degradation + battery -------------------------------------------
## Layer 3 — Ongoing TPT degradation classification. ## Layer 3 — Ongoing TPT degradation classification.
## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable). ## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable).
## Returns "ok" | "yellow". ## Returns "ok" | "yellow".
@@ -130,6 +136,7 @@ func is_inference_suspended() -> bool:
# -- Startup pref load -------------------------------------------------------- # -- Startup pref load --------------------------------------------------------
## Load AI dialogue preference from settings.cfg and apply to GameState. ## Load AI dialogue preference from settings.cfg and apply to GameState.
## Called from _ready() to restore the toggle state across session restarts. ## Called from _ready() to restore the toggle state across session restarts.
## Spec: D-138 §8 — toggle persists via ConfigFile (client-local) + server SQLite (#627). ## 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 --------------------------------------------------- # -- Battery suspend/resume ---------------------------------------------------
func _on_power_profile_changed(old_profile: int, new_profile: int) -> void: func _on_power_profile_changed(old_profile: int, new_profile: int) -> void:
if new_profile == PlatformInfo.PowerProfile.BATTERY: if new_profile == PlatformInfo.PowerProfile.BATTERY:
_suspend_for_battery() _suspend_for_battery()
@@ -176,34 +184,49 @@ func _resume_from_battery() -> void:
func _send_settings_change(enabled: bool) -> void: func _send_settings_change(enabled: bool) -> void:
if SimBridge.state != SimBridge.ConnectionState.CONNECTED: if SimBridge.state != SimBridge.ConnectionState.CONNECTED:
return return
SimBridge.send_input({ (
"action": InputMapper.Action.CHANGE_SETTINGS, SimBridge
"action_data": {"ai_enhanced_dialogue": enabled}, . send_input(
"timestamp_msec": Time.get_ticks_msec(), {
}) "action": InputMapper.Action.CHANGE_SETTINGS,
"action_data": {"ai_enhanced_dialogue": enabled},
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
# -- Status message ----------------------------------------------------------- # -- Status message -----------------------------------------------------------
## Compose a human-readable status message for the settings dialog. ## Compose a human-readable status message for the settings dialog.
## classification: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT). ## classification: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT).
## Returns empty string when no message is needed. ## 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": if ram_classification == "fail":
return ( 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." + " 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": if ram_classification == "marginal":
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
match tpt_classification: match tpt_classification:
"yellow": "yellow":
return ( 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." + " Background NPCs may show base text until the queue catches up."
) % tps )
% tps
)
"red": "red":
return ( return (
"Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours." ("Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours.")
) % tps % tps
)
return "" return ""
+54 -18
View File
@@ -1,32 +1,68 @@
extends Control 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: 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: func update_from_hud_data(data: Dictionary) -> void:
# Update perception mode display if not _perception_row:
return
if data.has("perception_mode"): if data.has("perception_mode"):
var prefix := UIStrings.get_text("hud.perception_mode_prefix") var prefix := UIStrings.get_text("hud.perception_mode_prefix")
if prefix.is_empty(): if prefix.is_empty():
perception_label.text = data.perception_mode.capitalize() _perception_row.text = data.perception_mode.capitalize()
else: 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: 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 "--:--"
+5 -27
View File
@@ -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="Script" path="res://ui/hud.gd" id="1_hud"]
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"] [ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
@@ -13,31 +13,9 @@ grow_vertical = 2
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_hud") script = ExtResource("1_hud")
[node name="MarginContainer" type="MarginContainer" parent="."] ; #674: Star map — full-screen insert overlay, hidden until player activates (M key / insert UI).
layout_mode = 1 ; Toggled via star_map.toggle_visible() from main.gd.
anchors_preset = 10 ; TODO: migrate to HudGroups.open_app("implant/map/starchart") per D-170.
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
[node name="StarMap" parent="." instance=ExtResource("2_starmap")] [node name="StarMap" parent="." instance=ExtResource("2_starmap")]
visible = false visible = false
+24
View File
@@ -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
+31
View File
@@ -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
+40
View File
@@ -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)
+78
View File
@@ -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()
+23
View File
@@ -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)
+30
View File
@@ -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)
)
+46
View File
@@ -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
+26 -12
View File
@@ -42,6 +42,7 @@ func _ready() -> void:
visible = false visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if _showing: if _showing:
_update_screen_position() _update_screen_position()
@@ -113,14 +114,21 @@ func _rebuild_labels() -> void:
var lbl := Label.new() var lbl := Label.new()
lbl.text = " %s " % verb.get("label", "") lbl.text = " %s " % verb.get("label", "")
lbl.add_theme_font_size_override("font_size", 14) lbl.add_theme_font_size_override("font_size", 14)
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM) lbl.add_theme_color_override(
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM
)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.mouse_filter = Control.MOUSE_FILTER_STOP lbl.mouse_filter = Control.MOUSE_FILTER_STOP
lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
var idx := i var idx := i
lbl.gui_input.connect(func(event: InputEvent): lbl.gui_input.connect(
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: func(event: InputEvent):
_select_and_interact(idx) 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_entered.connect(func(): _hover_index(idx))
lbl.mouse_exited.connect(func(): _unhover_index(idx)) lbl.mouse_exited.connect(func(): _unhover_index(idx))
@@ -155,7 +163,9 @@ func _update_screen_position() -> void:
return return
var viewport_size := get_viewport_rect().size var viewport_size := get_viewport_rect().size
var cam_center := camera.get_screen_center_position() 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 world_px := _entity_world_pos * Constants.TILE_SIZE
var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0 var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0
# Anchor above the entity, centered horizontally # Anchor above the entity, centered horizontally
@@ -184,17 +194,19 @@ func _hide() -> void:
_active_tween = create_tween() _active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT) _active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
# Guard: tween callback from previous _hide() may fire after labels already freed # Guard: tween callback from previous _hide() may fire after labels already freed
_active_tween.tween_callback(func(): _active_tween.tween_callback(
visible = false func():
for lbl in _verb_labels: visible = false
if is_instance_valid(lbl): for lbl in _verb_labels:
lbl.queue_free() if is_instance_valid(lbl):
_verb_labels.clear() lbl.queue_free()
_verb_labels.clear()
) )
# -- Lazy sync for getters (tests may set GameState without calling update) -- # -- Lazy sync for getters (tests may set GameState without calling update) --
func _ensure_synced() -> void: func _ensure_synced() -> void:
if _current_target_id == -1 and not GameState.nearby_interactions.is_empty(): if _current_target_id == -1 and not GameState.nearby_interactions.is_empty():
update_from_state() update_from_state()
@@ -202,6 +214,7 @@ func _ensure_synced() -> void:
# -- Public API (QA test contract) ------------------------------------------ # -- Public API (QA test contract) ------------------------------------------
func get_visible_verb_count() -> int: func get_visible_verb_count() -> int:
_ensure_synced() _ensure_synced()
return _verb_items.size() return _verb_items.size()
@@ -254,7 +267,8 @@ func _update_label_colors() -> void:
for i in range(_verb_labels.size()): for i in range(_verb_labels.size()):
if is_instance_valid(_verb_labels[i]): if is_instance_valid(_verb_labels[i]):
_verb_labels[i].add_theme_color_override( _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: func set_insert_active(active: bool) -> void:
+25 -4
View File
@@ -1,4 +1,4 @@
extends PanelContainer extends Control
# Interaction prompt — displays available interaction verb for nearby entity. # Interaction prompt — displays available interaction verb for nearby entity.
# Server-driven: shows when GameState.nearby_interactions is non-empty, # 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) # OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
var _insert_active: bool = true var _insert_active: bool = true
@onready var prompt_label: Label = $MarginContainer/PromptLabel var _panel: ImplantPanel
var _row: ImplantDataRow
func _ready() -> void: func _ready() -> void:
modulate.a = 0.0 modulate.a = 0.0
visible = false visible = false
_is_showing = 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: func _process(_delta: float) -> void:
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed) # 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: elif _is_showing:
_hide_prompt() _hide_prompt()
func _show_prompt(interaction: Dictionary) -> void: func _show_prompt(interaction: Dictionary) -> void:
var target_id: int = interaction.get("entity_id", -1) var target_id: int = interaction.get("entity_id", -1)
var verbs: Array = interaction.get("verbs", []) 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 verb_label: String = verbs[0].get("label", "")
var display_text: String = "E - %s" % verb_label var display_text: String = "E - %s" % verb_label
if _current_target_id != target_id or prompt_label.text != display_text: if _current_target_id != target_id or _row.text != display_text:
prompt_label.text = display_text _row.set_content(display_text)
_current_target_id = target_id _current_target_id = target_id
if not _is_showing: if not _is_showing:
@@ -61,6 +79,7 @@ func _show_prompt(interaction: Dictionary) -> void:
_active_tween = create_tween() _active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN) _active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide_prompt() -> void: func _hide_prompt() -> void:
if not _is_showing: if not _is_showing:
return return
@@ -72,10 +91,12 @@ func _hide_prompt() -> void:
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT) _active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func(): visible = false) _active_tween.tween_callback(func(): visible = false)
## Returns the current interaction target entity ID, or -1 if no interaction. ## Returns the current interaction target entity ID, or -1 if no interaction.
func get_interaction_target() -> int: func get_interaction_target() -> int:
return _current_target_id return _current_target_id
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6). ## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
## Cursor shape changes still fire on cursor_renderer.gd. ## Cursor shape changes still fire on cursor_renderer.gd.
func set_insert_active(active: bool) -> void: func set_insert_active(active: bool) -> void:
+4 -14
View File
@@ -2,27 +2,17 @@
[ext_resource type="Script" path="res://ui/interaction_prompt.gd" id="1_prompt"] [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 anchors_preset = 7
anchor_top = 1.0 anchor_top = 1.0
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 200.0 offset_left = 200.0
offset_top = -60.0 offset_top = -264.0
offset_right = -200.0 offset_right = -200.0
offset_bottom = -222.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 0 grow_vertical = 0
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_prompt") 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 = ""
+14 -7
View File
@@ -67,10 +67,7 @@ func _draw() -> void:
var col: int = slot_idx % GRID_COLS var col: int = slot_idx % GRID_COLS
var row: int = slot_idx / GRID_COLS var row: int = slot_idx / GRID_COLS
var pos := Vector2( var pos := Vector2(col * (SLOT_SIZE + SLOT_GAP), row * (SLOT_SIZE + SLOT_GAP))
col * (SLOT_SIZE + SLOT_GAP),
row * (SLOT_SIZE + SLOT_GAP)
)
var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE)) var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE))
# Slot background # Slot background
@@ -89,13 +86,22 @@ func _draw() -> void:
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
var font_size := 11 var font_size := 11
var text_size := font.get_string_size(item_name, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size) 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) draw_string(font, text_pos, item_name, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, SLOT_TEXT)
# Hotkey number (top-left corner) # Hotkey number (top-left corner)
var hotkey := str(slot_idx + 1) var hotkey := str(slot_idx + 1)
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey, draw_string(
HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM) font,
pos + Vector2(3, HOTKEY_SIZE + 2),
hotkey,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
HOTKEY_SIZE,
SLOT_TEXT_DIM
)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
@@ -125,6 +131,7 @@ func _select_slot(slot_idx: int) -> void:
# -- Public API --------------------------------------------------------------- # -- Public API ---------------------------------------------------------------
func get_slot_count() -> int: func get_slot_count() -> int:
return _slots.size() return _slots.size()
+19 -12
View File
@@ -29,8 +29,9 @@ var _last_rendered_tick: int = -1
@onready var panel: PanelContainer = $PanelContainer @onready var panel: PanelContainer = $PanelContainer
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel @onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer @onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
@onready var entries_container: VBoxContainer = \ @onready
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
func _ready() -> void: func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE 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) header_rtl.add_theme_font_size_override("normal_font_size", 14)
var rel_color: Color = Constants.color_for_relationship(relationship) 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(): if rel_label == "relationship_states.%s.label" % relationship.to_lower():
rel_label = relationship # fallback if key missing rel_label = relationship # fallback if key missing
var state_color: Color = _state_color(state) 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) var src_label: String = _resolve_source_label(source)
# Build BBCode: # Build BBCode:
@@ -133,13 +138,15 @@ func _add_entity_entry(entity: Dictionary) -> void:
var header_bbcode: String var header_bbcode: String
if state == "Contradicted": if state == "Contradicted":
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [ header_bbcode = (
name_hex, name_str, rel_hex, rel_label "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]"
] % [name_hex, name_str, rel_hex, rel_label]
)
else: else:
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [ header_bbcode = (
name_hex, name_str, rel_hex, rel_label "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]"
] % [name_hex, name_str, rel_hex, rel_label]
)
header_rtl.text = header_bbcode header_rtl.text = header_bbcode
entries_container.add_child(header_rtl) 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: func _state_color(state: String) -> Color:
match state: match state:
"Contradicted": "Contradicted":
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
"Stale": "Stale":
return Constants.IMPLANT_TEXT_DIM # dimmed return Constants.IMPLANT_TEXT_DIM # dimmed
_: _:
return Constants.INSERT_COLOR_TEXT # active — normal return Constants.INSERT_COLOR_TEXT # active — normal
+11 -4
View File
@@ -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: if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6:
var d := parts[0] var d := parts[0]
var t := parts[1] var t := parts[1]
return "%s-%s-%s %s:%s:%s" % [ return (
d.substr(0, 4), d.substr(4, 2), d.substr(6, 2), "%s-%s-%s %s:%s:%s"
t.substr(0, 2), t.substr(2, 2), t.substr(4, 2), % [
] 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 return game_id
+70 -15
View File
@@ -29,28 +29,67 @@ const CARDINAL_TICK_LEN: float = 4.0
# Colors — insert palette from Constants, tuned for the circular minimap frame # 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_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_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_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_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
var _insert_active: bool = true 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: func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true) 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: 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() queue_redraw()
## Called from main.gd when GameState.insert_active changes. ## Called from main.gd when GameState.insert_active changes.
## Hides the minimap overlay when the neural insert is inactive. ## Hides the minimap overlay when the neural insert is inactive.
func set_insert_active(active: bool) -> void: func set_insert_active(active: bool) -> void:
_insert_active = active _insert_active = active
visible = active visible = active
if active:
_dirty = true
func _draw() -> void: 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 sz: Vector2 = get_rect().size
var center := sz / 2.0 var center := sz / 2.0
# Outer radius: fill control with 1px edge padding # Outer radius: fill control with 1px edge padding
@@ -68,8 +107,16 @@ func _draw() -> void:
# --- Player dot at center --- # --- Player dot at center ---
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER) draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
# Soft bloom ring # Soft bloom ring
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32, draw_arc(
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true) 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 --- # --- POIs ---
var pois: Array = GameState.discovered_pois var pois: Array = GameState.discovered_pois
@@ -118,7 +165,9 @@ func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
draw_line( draw_line(
center + n_dir * (outer_r - NORTH_TICK_LEN), center + n_dir * (outer_r - NORTH_TICK_LEN),
center + n_dir * outer_r, 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 # East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]: 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( draw_line(
center + dir * (outer_r - CARDINAL_TICK_LEN), center + dir * (outer_r - CARDINAL_TICK_LEN),
center + dir * outer_r, 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 # Diamond for danger
var s: float = POI_DOT_RADIUS + 1.0 var s: float = POI_DOT_RADIUS + 1.0
draw_polygon( draw_polygon(
PackedVector2Array([ PackedVector2Array(
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0), [
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0) 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]) PackedColorArray([color, color, color, color])
) )
"evidence", "note", "clue": "evidence", "note", "clue":
@@ -163,10 +218,10 @@ func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
func _category_color(category: String) -> Color: func _category_color(category: String) -> Color:
match category.to_lower(): match category.to_lower():
"danger", "threat", "hostile": "danger", "threat", "hostile":
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
"evidence", "note", "clue": "evidence", "note", "clue":
return Constants.ENTITY_COLOR_POI # #e8c547 — amber return Constants.ENTITY_COLOR_POI # #e8c547 — amber
"contact", "npc", "person": "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
+73 -39
View File
@@ -13,28 +13,30 @@ extends Control
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred). # is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
const MAX_VISIBLE: int = 3 const MAX_VISIBLE: int = 3
const MAX_QUEUE: int = 5 const MAX_QUEUE: int = 5
const STAGGER_SEC: float = 0.15 const STAGGER_SEC: float = 0.15
const FADE_IN_SEC: float = 0.3 const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5 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 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. # Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# standard opacity = 0.85, urgent opacity = 1.0. # standard opacity = 0.85, urgent opacity = 1.0.
# Source: Tyre architecture review, Sprint 14. # Source: Tyre architecture review, Sprint 14.
const _LATTICE_COLORS: Dictionary = { const _LATTICE_COLORS: Dictionary = {
"lattice_augmented": { # detective "lattice_augmented":
{ # detective
"standard": Color("#d0d4e0"), "standard": Color("#d0d4e0"),
"urgent": Color("#e0e8f8"), "urgent": Color("#e0e8f8"),
}, },
"lattice_baseline": { # smuggler "lattice_baseline":
{ # smuggler
"standard": Color("#d8d0c4"), "standard": Color("#d8d0c4"),
"urgent": Color("#f0e4d4"), "urgent": Color("#f0e4d4"),
}, },
} }
const _FALLBACK_STANDARD: Color = Color("#c8d0e0") 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_COLOR: Color = Color("#8890a0") # #554: neutral system notification
const _NOTIFICATION_DURATION: float = 2.5 const _NOTIFICATION_DURATION: float = 2.5
@@ -67,7 +69,9 @@ func _process(delta: float) -> void:
if next.get("is_notification", false): if next.get("is_notification", false):
_show_notification_line(next.text) _show_notification_line(next.text)
else: 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. # Display a monologue line.
@@ -86,20 +90,30 @@ func show_notification(text: String) -> void:
_show_notification_line(text) _show_notification_line(text)
else: else:
var entry := { var entry := {
text = text, duration = _NOTIFICATION_DURATION, priority = 1, text = text,
is_urgent = false, lattice_profile = "", is_notification = true duration = _NOTIFICATION_DURATION,
priority = 1,
is_urgent = false,
lattice_profile = "",
is_notification = true
} }
if _queue.size() < MAX_QUEUE: if _queue.size() < MAX_QUEUE:
_queue.append(entry) _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: else:
var lowest := _lowest_priority_idx() var lowest := _lowest_priority_idx()
if 1 >= _queue[lowest].priority: if 1 >= _queue[lowest].priority:
_queue[lowest] = entry _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(): if text.is_empty():
return return
var profile := GameState.lattice_profile var profile := GameState.lattice_profile
@@ -114,11 +128,12 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
# Internal # Internal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _show_notification_line(text: String) -> void: func _show_notification_line(text: String) -> void:
var container := MarginContainer.new() var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4) container.add_theme_constant_override("margin_left", 4)
container.add_theme_constant_override("margin_right", 4) container.add_theme_constant_override("margin_right", 4)
container.add_theme_constant_override("margin_top", 2) container.add_theme_constant_override("margin_top", 2)
container.add_theme_constant_override("margin_bottom", 2) container.add_theme_constant_override("margin_bottom", 2)
var label := RichTextLabel.new() var label := RichTextLabel.new()
label.bbcode_enabled = true label.bbcode_enabled = true
@@ -130,10 +145,10 @@ func _show_notification_line(text: String) -> void:
container.add_child(label) container.add_child(label)
_vbox.add_child(container) _vbox.add_child(container)
var slot := { var slot := {
node = container, node = container,
expire_timer = _NOTIFICATION_DURATION, expire_timer = _NOTIFICATION_DURATION,
priority = 1, priority = 1,
tween = null, tween = null,
} }
_visible.append(slot) _visible.append(slot)
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0 _next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
@@ -143,15 +158,17 @@ func _show_notification_line(text: String) -> void:
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC) 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) var line_node := _build_line_node(text, is_urgent, lattice_profile)
_vbox.add_child(line_node) _vbox.add_child(line_node)
var slot := { var slot := {
node = line_node, node = line_node,
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
priority = priority, priority = priority,
tween = null, tween = null,
} }
_visible.append(slot) _visible.append(slot)
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0 _next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
@@ -174,22 +191,36 @@ func _retire_slot(slot: Dictionary) -> void:
tween.tween_callback(node.queue_free) 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: if _queue.size() < MAX_QUEUE:
_queue.append({ _queue.append(
text = text, duration = duration, priority = priority, {
is_urgent = is_urgent, lattice_profile = lattice_profile text = text,
}) duration = duration,
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority) 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: else:
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks) # >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
var lowest := _lowest_priority_idx() var lowest := _lowest_priority_idx()
if priority >= _queue[lowest].priority: if priority >= _queue[lowest].priority:
_queue[lowest] = { _queue[lowest] = {
text = text, duration = duration, priority = priority, text = text,
is_urgent = is_urgent, lattice_profile = lattice_profile 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 # 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: func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {}) 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) else palette.get("standard", _FALLBACK_STANDARD)
)
var container := MarginContainer.new() var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4) container.add_theme_constant_override("margin_left", 4)
container.add_theme_constant_override("margin_right", 4) container.add_theme_constant_override("margin_right", 4)
container.add_theme_constant_override("margin_top", 2) container.add_theme_constant_override("margin_top", 2)
container.add_theme_constant_override("margin_bottom", 2) container.add_theme_constant_override("margin_bottom", 2)
var label := RichTextLabel.new() var label := RichTextLabel.new()
+46 -36
View File
@@ -8,33 +8,33 @@ extends Control
signal closed signal closed
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled 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 BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
const BORDER_COLOR := Color("#4a9ebb") const BORDER_COLOR := Color("#4a9ebb")
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1) const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
const TITLE_COLOR := Color("#4a9ebb") const TITLE_COLOR := Color("#4a9ebb")
const STATUS_GREEN := Color("#6bc9a6") const STATUS_GREEN := Color("#6bc9a6")
const STATUS_YELLOW := Color("#e8c547") const STATUS_YELLOW := Color("#e8c547")
const STATUS_RED := Color("#c84040") const STATUS_RED := Color("#c84040")
const FONT_SIZE := 14 const FONT_SIZE := 14
const FONT_SIZE_SMALL := 11 const FONT_SIZE_SMALL := 11
## Shared client preferences file — avoids compile-time dependency on DebugConsole for path. ## Shared client preferences file — avoids compile-time dependency on DebugConsole for path.
const SETTINGS_CFG_PATH := "user://settings.cfg" 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 BOX_HEIGHT := 500 # +36 debug console, +88 AI dialogue section
const PADDING := 20 const PADDING := 20
const ROW_HEIGHT := 36 const ROW_HEIGHT := 36
# Bus display labels → bus name strings (must match AudioManager BUS_* constants) # Bus display labels → bus name strings (must match AudioManager BUS_* constants)
const BUS_ROWS: Array = [ const BUS_ROWS: Array = [
["Music", "Music"], ["Music", "Music"],
["Ambient", "Ambient"], ["Ambient", "Ambient"],
["World SFX", "WorldSFX"], ["World SFX", "WorldSFX"],
["Player Actions", "PlayerActions"], ["Player Actions", "PlayerActions"],
["UI Sounds", "UISounds"], ["UI Sounds", "UISounds"],
] ]
var _active: bool = false var _active: bool = false
@@ -77,10 +77,7 @@ func is_open() -> bool:
func _build_ui() -> void: func _build_ui() -> void:
var vp_size := get_viewport_rect().size var vp_size := get_viewport_rect().size
var box_pos := Vector2( var box_pos := Vector2((vp_size.x - BOX_WIDTH) / 2.0, (vp_size.y - BOX_HEIGHT) / 2.0)
(vp_size.x - BOX_WIDTH) / 2.0,
(vp_size.y - BOX_HEIGHT) / 2.0
)
_container = VBoxContainer.new() _container = VBoxContainer.new()
_container.position = box_pos + Vector2(PADDING, PADDING + 28) _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) db_label.add_theme_color_override("font_color", TEXT_COLOR)
hbox.add_child(db_label) hbox.add_child(db_label)
slider.value_changed.connect(func(value: float) -> void: slider.value_changed.connect(
AudioManager.set_volume(bus_name, value) func(value: float) -> void:
db_label.text = _format_db(value) AudioManager.set_volume(bus_name, value)
db_label.text = _format_db(value)
) )
# #581: Debug Console toggle # #581: Debug Console toggle
@@ -148,10 +146,10 @@ func _build_ui() -> void:
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
debug_check.button_pressed = true debug_check.button_pressed = true
if cfg.load(DebugConsole.PREFS_PATH) == OK: if cfg.load(DebugConsole.PREFS_PATH) == OK:
debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true) debug_check.button_pressed = cfg.get_value(
debug_check.toggled.connect(func(enabled: bool) -> void: DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true
debug_console_toggled.emit(enabled) )
) debug_check.toggled.connect(func(enabled: bool) -> void: debug_console_toggled.emit(enabled))
debug_hbox.add_child(debug_check) debug_hbox.add_child(debug_check)
# #646: AI Dialogue section divider # #646: AI Dialogue section divider
@@ -223,7 +221,8 @@ func _build_ui() -> void:
# Status message label — only shown when non-empty # Status message label — only shown when non-empty
var status_msg: String = HardwareDetector.status_message( 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() var ai_status_label := Label.new()
ai_status_label.text = status_msg ai_status_label.text = status_msg
ai_status_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) 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 _ai_battery_warning_label.visible = _ai_inference_suspended
_container.add_child(_ai_battery_warning_label) _container.add_child(_ai_battery_warning_label)
_ai_check_node.toggled.connect(func(enabled: bool) -> void: _ai_check_node.toggled.connect(
GameState.ai_enhanced_dialogue_enabled = enabled func(enabled: bool) -> void:
_save_ai_pref(enabled) GameState.ai_enhanced_dialogue_enabled = enabled
if SimBridge.state == SimBridge.ConnectionState.CONNECTED: _save_ai_pref(enabled)
SimBridge.send_input({ if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
"action": InputMapper.Action.CHANGE_SETTINGS, (
"action_data": {"ai_enhanced_dialogue": enabled}, SimBridge
"timestamp_msec": Time.get_ticks_msec(), . send_input(
}) {
ai_dialogue_toggled.emit(enabled) "action": InputMapper.Action.CHANGE_SETTINGS,
"action_data": {"ai_enhanced_dialogue": enabled},
"timestamp_msec": Time.get_ticks_msec(),
}
)
)
ai_dialogue_toggled.emit(enabled)
) )
# Spacer # Spacer
@@ -284,12 +289,13 @@ func _destroy_ui() -> void:
if _container: if _container:
_container.queue_free() _container.queue_free()
_container = null _container = null
_ai_check_node = null # freed with _container _ai_check_node = null # freed with _container
_ai_battery_warning_label = null _ai_battery_warning_label = null
# -- #646: AI Dialogue testable API ------------------------------------------- # -- #646: AI Dialogue testable API -------------------------------------------
## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138). ## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138).
## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support. ## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support.
func get_ai_dialogue_label_text() -> String: func get_ai_dialogue_label_text() -> String:
@@ -354,8 +360,7 @@ func _draw() -> void:
# Dialog box # Dialog box
var box_pos := Vector2( var box_pos := Vector2(
(viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0
(viewport_size.y - BOX_HEIGHT) / 2.0
) )
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
@@ -363,10 +368,15 @@ func _draw() -> void:
# Title # Title
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
draw_string(font, draw_string(
font,
box_pos + Vector2(PADDING, PADDING + 18), box_pos + Vector2(PADDING, PADDING + 18),
"SETTINGS", "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: func _on_quit_to_menu() -> void:
+80 -29
View File
@@ -1,51 +1,102 @@
extends Control extends Control
## D-053: Stance indicator — shows current movement stance on HUD. ## D-053: Stance indicator — implant-styled badge (D-169).
## Sprint/Walk/Careful/Crouch. Color-coded for quick read. ## Sprint/Walk/Careful/Crouch. Icon (D-086) + color-coded label.
## Lives on UILayer (z-layer 7).
const STANCE_COLORS := { const STANCE_COLORS := {
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous "Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
"Walk": Color("#c8d0e0"), # Default — neutral white-blue "Walk": Color("#c8d0e0"), # Default — neutral white-blue
"Careful": Color("#6bc9a6"), # Green — quiet, observant "Careful": Color("#6bc9a6"), # Green — quiet, observant
"Crouch": Color("#e8c547"), # Amber — very quiet, slow "Crouch": Color("#e8c547"), # Amber — very quiet, slow
} }
const STANCE_DEFAULT_COLOR := Color("#c8d0e0") const STANCE_ICONS := {
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5) "Sprint": "res://assets/icons/icon_stance_sprint.svg",
const FONT_SIZE := 13 "Walk": "res://assets/icons/icon_stance_walk.svg",
const PADDING := Vector2(10, 6) "Careful": "res://assets/icons/icon_stance_careful.svg",
"Crouch": "res://assets/icons/icon_stance_crouch.svg",
}
var _current_stance: String = "Walk" 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: func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE 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: func update_from_state() -> void:
var stance: String = GameState.player_stance var stance: String = GameState.player_stance
if stance == _current_stance: if stance == _current_stance:
return return
_current_stance = stance _apply_stance(stance)
queue_redraw()
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: func get_current_stance() -> String:
return _current_stance return _current_stance
+4 -3
View File
@@ -2,16 +2,17 @@
[ext_resource type="Script" path="res://ui/stance_indicator.gd" id="1_stance"] [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"] [node name="StanceIndicator" type="Control"]
layout_mode = 3 layout_mode = 3
anchors_preset = 1 anchors_preset = 1
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0
offset_left = -100.0 offset_left = -100.0
offset_top = 16.0 offset_top = 200.0
offset_right = -16.0 offset_right = -16.0
offset_bottom = 42.0 offset_bottom = 246.0
grow_horizontal = 0 grow_horizontal = 0
mouse_filter = 2 mouse_filter = 2
script = ExtResource("1_stance") script = ExtResource("1_stance")
+176 -139
View File
@@ -16,9 +16,9 @@ const DATA_PATH := "res://data/star_map_data.json"
# Layout # Layout
const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control 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 MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
const RING_SPACING: float = 22.0 # pixels between hop rings const RING_SPACING: float = 22.0 # pixels between hop rings
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
# Dot sizing # Dot sizing
const DOT_RADIUS_HUB: float = 4.5 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_WIDTH: float = 0.8
const EDGE_SELECTED_ALPHA: float = 0.55 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_WIDTH: float = 300.0
const POPUP_MARGIN: float = 16.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 const POPUP_GTTR_MAX_LINES: int = 7
# Colors — sector palette from wireframe # Colors — sector palette from wireframe
@@ -51,7 +48,6 @@ const COLOR_GATEWAY: Color = Color("#f0d060")
const COLOR_SELECTION: Color = Color("#f0d060") const COLOR_SELECTION: Color = Color("#f0d060")
const COLOR_TEXT: Color = Color("#c8d0e0") const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788") 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 = { const SECTOR_COLORS: Dictionary = {
"core": Color("#c8d0e0"), "core": Color("#c8d0e0"),
@@ -79,8 +75,8 @@ const SECTOR_ANGLE_CENTER: Dictionary = {
"west_reach": PI, "west_reach": PI,
} }
const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc 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 CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
# Pan/zoom # Pan/zoom
const ZOOM_MIN: float = 0.3 const ZOOM_MIN: float = 0.3
@@ -91,7 +87,7 @@ const ZOOM_STEP: float = 0.15
var _nodes: Array = [] var _nodes: Array = []
var _edges: Array = [] var _edges: Array = []
var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center) 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 _selected_system: String = ""
var _hovered_system: String = "" var _hovered_system: String = ""
@@ -103,11 +99,29 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
var _data_loaded: bool = false var _data_loaded: bool = false
var _insert_active: bool = true 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: func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP 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() _load_data()
if _data_loaded: if _data_loaded:
_compute_layout() _compute_layout()
@@ -122,19 +136,27 @@ func _process(_delta: float) -> void:
## Called from main.gd when insert state changes. ## Called from main.gd when insert state changes.
## Only force-hides when insert is inactive. Does NOT auto-show — star map is ## Called from main.gd when insert state changes.
## modal (player opens via toggle_visible()), not always-on like the minimap.
func set_insert_active(active: bool) -> void: func set_insert_active(active: bool) -> void:
_insert_active = active _insert_active = active
if not active: if not active and HudGroups.is_app_active("implant/map/starchart"):
visible = false HudGroups.close_app()
## Toggle visibility (e.g., from a keybind or button). ## Toggle via HUD layer system (D-170).
func toggle_visible() -> void: func toggle_visible() -> void:
visible = not visible HudGroups.toggle_app("implant/map/starchart")
if visible:
## 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 _dirty = true
else:
visible = false
## Return the currently selected system data, or empty dict. ## Return the currently selected system data, or empty dict.
@@ -156,6 +178,7 @@ func get_edge_count() -> int:
# Data loading # Data loading
# ============================================================================= # =============================================================================
func _load_data() -> void: func _load_data() -> void:
if not FileAccess.file_exists(DATA_PATH): if not FileAccess.file_exists(DATA_PATH):
push_warning("StarMapRenderer: data file not found at %s" % 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 # Layout — place systems on concentric rings by hop distance
# ============================================================================= # =============================================================================
func _compute_layout() -> void: func _compute_layout() -> void:
_node_positions.clear() _node_positions.clear()
@@ -246,7 +270,9 @@ func _compute_layout() -> void:
var jitter: float = _system_hash(node["system_id"]) * 0.08 var jitter: float = _system_hash(node["system_id"]) * 0.08
angle += jitter angle += jitter
# Slight radial variation to avoid perfect circles # 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 _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: func _sector_sort_key(node: Dictionary) -> float:
var sector: String = node.get("geographic_sector", "unknown") var sector: String = node.get("geographic_sector", "unknown")
match sector: match sector:
"core": return 0.0 "core":
"north_reach": return 1.0 return 0.0
"east_reach": return 2.0 "north_reach":
"south_reach": return 3.0 return 1.0
"west_reach": return 4.0 "east_reach":
"deep_frontier": return 5.0 return 2.0
_: return 6.0 # gdlint:ignore = max-returns "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. ## Deterministic float in [-1, 1] from a string key.
@@ -280,6 +313,7 @@ func _system_hash(key: String) -> float:
# Drawing # Drawing
# ============================================================================= # =============================================================================
func _draw() -> void: func _draw() -> void:
if not _data_loaded: if not _data_loaded:
return return
@@ -307,9 +341,21 @@ func _draw() -> void:
if _selected_system != "": if _selected_system != "":
_draw_selection(center) _draw_selection(center)
# Info panel for selected system # Info panel positioned in top-right, clamped to screen (D-169)
if _selected_system != "": if _info_panel:
_draw_info_panel(sz) _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 # Title
_draw_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 color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
var font := get_theme_default_font() var font := get_theme_default_font()
var font_size: int = 10 var font_size: int = 10
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) var text_size: Vector2 = font.get_string_size(
draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color) 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: func _draw_systems(center: Vector2) -> void:
@@ -359,10 +409,42 @@ func _draw_systems(center: Vector2) -> void:
# Hover highlight # Hover highlight
if sid == _hovered_system and sid != _selected_system: 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) 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: func _draw_edges(center: Vector2) -> void:
# Only draw edges connected to the selected system (full web is unreadable at 301 systems) # 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): if not _node_positions.has(_selected_system):
return return
var pos: Vector2 = center + _node_positions[_selected_system] * _zoom 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_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, {}) var node: Dictionary = _node_lookup.get(_selected_system, {})
if node.is_empty(): if node.is_empty():
_info_panel.visible = false
return return
var font := get_theme_default_font() # ── Header ───────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────
var sys_name: String = node.get("proper_name", "") var sys_name: String = node.get("proper_name", "")
if sys_name.is_empty(): if sys_name.is_empty():
sys_name = node.get("system_id", "Unknown") sys_name = node.get("system_id", "Unknown")
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT) _info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", "")))
y += POPUP_LINE_H
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM) _info_panel.add_component(ImplantSeparator.new())
y += POPUP_LINE_H
# Separator # ── Stats ────────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────
var star_type: String = node.get("star_type", "") var star_type: String = node.get("star_type", "")
var hop: int = int(node.get("hop_distance", 0)) if not star_type.is_empty():
var stat_line_1: String _info_panel.add_component(ImplantDataRow.new(star_type + " star"))
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
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper() 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) 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) _info_panel.add_component(
y += POPUP_LINE_H ImplantDataRow.new("%s corridor (hop %d)" % [sector_str, hop], sector_color)
)
# Bodies — single line
var bodies: String = node.get("bodies", "") 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 population: String = node.get("population", "")
var stat_line_3: String if not population.is_empty():
if not bodies.is_empty() and not population.is_empty(): _info_panel.add_component(ImplantDataRow.new("")) # blank line spacer
stat_line_3 = "%s · pop %s" % [bodies, population] _info_panel.add_component(ImplantDataRow.new("pop " + population))
elif not bodies.is_empty(): _info_panel.add_component(ImplantDataRow.new("GDP —"))
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
# ── GTTR excerpt ───────────────────────────────────────────────────────── # ── GTTR excerpt ─────────────────────────────────────────────────────────
var gttr: String = node.get("gttr_excerpt", "")
if not gttr.is_empty(): if not gttr.is_empty():
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0), _info_panel.add_component(ImplantSeparator.new())
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0) _info_panel.add_component(ImplantTextBlock.new(gttr, POPUP_GTTR_MAX_LINES))
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
# ── Adjacent systems ────────────────────────────────────────────────────── # ── 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
var adj: Array = node.get("adjacent_systems", []) var adj: Array = node.get("adjacent_systems", [])
if adj.is_empty(): if not adj.is_empty():
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM) _info_panel.add_component(ImplantSeparator.new())
else:
var adj_names: Array = [] var adj_names: Array = []
for neighbor_id: String in adj: for neighbor_id: String in adj:
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {}) var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
var n_name: String = neighbor.get("proper_name", "") var n_name: String = neighbor.get("proper_name", "")
adj_names.append(n_name if not n_name.is_empty() else neighbor_id) adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
var adj_line: String = " · ".join(adj_names) _info_panel.add_component(ImplantTextBlock.new(" · ".join(adj_names)))
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
inner_w, 10, 2, COLOR_TEXT_DIM)
func _draw_title() -> void: func _draw_title() -> void:
var font := get_theme_default_font() var font := get_theme_default_font()
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT) draw_string(
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(), font,
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM) 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: func _dot_radius(topology: String) -> float:
match topology: match topology:
"hub": return DOT_RADIUS_HUB "hub":
"junction": return DOT_RADIUS_JUNCTION return DOT_RADIUS_HUB
"dead_end": return DOT_RADIUS_DEAD_END "junction":
_: return DOT_RADIUS_DEFAULT return DOT_RADIUS_JUNCTION
"dead_end":
return DOT_RADIUS_DEAD_END
_:
return DOT_RADIUS_DEFAULT
# ============================================================================= # =============================================================================
# Input — selection, pan, zoom # Input — selection, pan, zoom
# ============================================================================= # =============================================================================
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton: if event is InputEventMouseButton:
var mb := event as InputEventMouseButton var mb := event as InputEventMouseButton
@@ -588,6 +624,7 @@ func _find_nearest_system(pos: Vector2) -> String:
func _handle_click(pos: Vector2) -> void: func _handle_click(pos: Vector2) -> void:
var nearest := _find_nearest_system(pos) var nearest := _find_nearest_system(pos)
_selected_system = nearest _selected_system = nearest
_rebuild_info_panel()
_dirty = true _dirty = true

Some files were not shown because too many files have changed in this diff Show More