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()