style(client): gdformat all 52 GDScript files — zero format warnings

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