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:
@@ -30,18 +30,24 @@ const BUSES := [BUS_MUSIC, BUS_AMBIENT, BUS_WORLD_SFX, BUS_PLAYER_ACTIONS, BUS_U
|
||||
# NOTE: listening_focus requires 30+ stationary ticks before activation (D-069/D-071).
|
||||
# The tick gate is the caller's responsibility — AudioManager only manages bus volumes.
|
||||
const DIP_SPECS := {
|
||||
"dialogue": {
|
||||
"ease_in": 0.3, "ease_out": 0.5,
|
||||
"buses": { "Ambient": -7.0 },
|
||||
"dialogue":
|
||||
{
|
||||
"ease_in": 0.3,
|
||||
"ease_out": 0.5,
|
||||
"buses": {"Ambient": -7.0},
|
||||
},
|
||||
"confrontation": {
|
||||
"ease_in": 0.5, "ease_out": 1.0,
|
||||
"buses": { "Ambient": -11.0, "WorldSFX": -5.0 },
|
||||
"confrontation":
|
||||
{
|
||||
"ease_in": 0.5,
|
||||
"ease_out": 1.0,
|
||||
"buses": {"Ambient": -11.0, "WorldSFX": -5.0},
|
||||
"filter_hz": 800.0,
|
||||
},
|
||||
"listening_focus": {
|
||||
"ease_in": 0.5, "ease_out": 0.5,
|
||||
"buses": { "WorldSFX": 2.5 },
|
||||
"listening_focus":
|
||||
{
|
||||
"ease_in": 0.5,
|
||||
"ease_out": 0.5,
|
||||
"buses": {"WorldSFX": 2.5},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,10 +65,10 @@ const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
const ZONE_ASSETS: Dictionary = {
|
||||
"hub": "amb_hub_layer",
|
||||
"hub": "amb_hub_layer",
|
||||
"workplace": "amb_hub_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
@@ -73,12 +79,12 @@ const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
@@ -110,6 +116,7 @@ func _ready() -> void:
|
||||
|
||||
# --- Bus setup ---
|
||||
|
||||
|
||||
func _setup_buses() -> void:
|
||||
for bus_name in BUSES:
|
||||
if AudioServer.get_bus_index(bus_name) < 0:
|
||||
@@ -130,6 +137,7 @@ func _setup_buses() -> void:
|
||||
|
||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||
|
||||
|
||||
func _scan_registry() -> void:
|
||||
_scan_dir("res://assets/audio/")
|
||||
print("AudioManager: %d assets registered" % _registry.size())
|
||||
@@ -166,6 +174,7 @@ func get_registry_size() -> int:
|
||||
|
||||
# --- Playback: non-spatial ---
|
||||
|
||||
|
||||
## Play a one-shot sound on the given bus. No-ops if asset not in registry.
|
||||
func play(asset_key: String, bus: String = BUS_UI_SOUNDS) -> void:
|
||||
var stream := _get_stream(asset_key)
|
||||
@@ -227,6 +236,7 @@ func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
|
||||
## Play a one-shot spatial sound at a world position (pixels).
|
||||
## Added to current_scene for correct AudioListener2D positioning.
|
||||
func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD_SFX) -> void:
|
||||
@@ -248,6 +258,7 @@ func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD
|
||||
|
||||
# --- Audio dip profiles (D-069) ---
|
||||
|
||||
|
||||
## Apply a dip profile. Interrupts any active dip, restoring unaffected buses.
|
||||
## Profiles: "dialogue", "confrontation", "listening_focus"
|
||||
func apply_dip(profile: String) -> void:
|
||||
@@ -318,6 +329,7 @@ func get_active_dip() -> String:
|
||||
|
||||
# --- Volume control (player sliders, D-069 proportional base) ---
|
||||
|
||||
|
||||
## Set the player's volume preference for a bus. During active dip, immediately
|
||||
## recalculates effective volume (proportional to new base).
|
||||
func set_volume(bus: String, volume_db: float) -> void:
|
||||
@@ -341,6 +353,7 @@ func get_volume(bus: String) -> float:
|
||||
|
||||
# --- Volume persistence (user://audio_prefs.cfg) ---
|
||||
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
@@ -366,6 +379,7 @@ func _save_prefs() -> void:
|
||||
|
||||
# --- Zone crossfade (D-073) ---
|
||||
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||
@@ -415,6 +429,7 @@ func _kill_zone_tweens() -> void:
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
|
||||
func _get_stream(asset_key: String) -> AudioStream:
|
||||
return _registry.get(asset_key) as AudioStream
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ extends Node
|
||||
# Fog texture byte values — visibility and exploration layers.
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569).
|
||||
# Retained — tests still reference it.
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
|
||||
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
|
||||
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
|
||||
@@ -25,10 +25,10 @@ const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame
|
||||
# bar: #2a1f15 (warm amber-dark — social, inhabited)
|
||||
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
|
||||
const ZONE_TINTS: Dictionary = {
|
||||
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
|
||||
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
|
||||
"workplace": Color(0.102, 0.122, 0.180), # same as hub
|
||||
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
|
||||
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
|
||||
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
|
||||
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
|
||||
}
|
||||
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
|
||||
|
||||
@@ -213,7 +213,11 @@ func update_from_state() -> void:
|
||||
var new_g := int(tint_color.g * 255.0)
|
||||
var new_b := int(tint_color.b * 255.0)
|
||||
# Only update if different from current (avoid spurious texture uploads)
|
||||
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
|
||||
if (
|
||||
_tint_bytes[tint_idx] != new_r
|
||||
or _tint_bytes[tint_idx + 1] != new_g
|
||||
or _tint_bytes[tint_idx + 2] != new_b
|
||||
):
|
||||
_tint_bytes[tint_idx + 0] = new_r
|
||||
_tint_bytes[tint_idx + 1] = new_g
|
||||
_tint_bytes[tint_idx + 2] = new_b
|
||||
@@ -243,5 +247,3 @@ func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
|
||||
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
|
||||
return tile_bounds
|
||||
return map_bounds.merge(tile_bounds)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585)
|
||||
# — visible in fog but not explored
|
||||
|
||||
@@ -162,6 +162,7 @@ var stationary_ticks: int = 0
|
||||
# Empty string when zone_id field absent.
|
||||
var current_zone_id: String = ""
|
||||
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
# Autoload parse-order: class_name types are not registered when autoloads compile.
|
||||
# load() returns the cached resource after the first call — essentially free per-tick.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,10 +14,21 @@ extends Node
|
||||
# Server cooldown is authoritative, but the client throttle prevents
|
||||
# flooding and gives correct movement feel in test mode.
|
||||
enum Action {
|
||||
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
|
||||
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
MOVE_NORTH,
|
||||
MOVE_NORTHEAST,
|
||||
MOVE_EAST,
|
||||
MOVE_SOUTHEAST,
|
||||
MOVE_SOUTH,
|
||||
MOVE_SOUTHWEST,
|
||||
MOVE_WEST,
|
||||
MOVE_NORTHWEST,
|
||||
INTERACT,
|
||||
USE_PERCEPTION_MODE,
|
||||
OPEN_MENU,
|
||||
PAUSE,
|
||||
UNPAUSE,
|
||||
TOGGLE_STANCE_UP,
|
||||
TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
@@ -25,18 +36,18 @@ enum Action {
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
|
||||
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
|
||||
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
|
||||
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
}
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
"Sprint": 200, # 5/sec — fast but trackable
|
||||
"Walk": 400, # 2.5/sec — comfortable walking pace
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
"Sprint": 200, # 5/sec — fast but trackable
|
||||
"Walk": 400, # 2.5/sec — comfortable walking pace
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
@@ -44,7 +55,7 @@ var input_queue: Array[Dictionary] = []
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
var _last_move_msec: int = 0
|
||||
|
||||
@@ -63,11 +74,16 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
if facing_octant != _last_sent_octant:
|
||||
_last_sent_octant = facing_octant
|
||||
input_queue.append({
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
})
|
||||
(
|
||||
input_queue
|
||||
. append(
|
||||
{
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Poll held WASD keys
|
||||
var raw_dir := Vector2i.ZERO
|
||||
@@ -88,10 +104,15 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Transform WASD input relative to mouse facing
|
||||
var world_dir := _wasd_to_world_dir(raw_dir)
|
||||
var action: Action = _dir_to_action(world_dir)
|
||||
input_queue.append({
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
})
|
||||
(
|
||||
input_queue
|
||||
. append(
|
||||
{
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Discrete actions: fire once on key press (not held).
|
||||
@@ -197,15 +218,24 @@ static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
# Quantize to nearest 45° (PI/4)
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return Vector2i(1, 0) # East
|
||||
1: return Vector2i(1, 1) # Southeast
|
||||
2, -6: return Vector2i(0, 1) # South
|
||||
3, -5: return Vector2i(-1, 1) # Southwest
|
||||
4, -4: return Vector2i(-1, 0) # West
|
||||
-3, 5: return Vector2i(-1, -1) # Northwest
|
||||
-2: return Vector2i(0, -1) # North
|
||||
-1: return Vector2i(1, -1) # Northeast
|
||||
_: return Vector2i.ZERO
|
||||
0:
|
||||
return Vector2i(1, 0) # East
|
||||
1:
|
||||
return Vector2i(1, 1) # Southeast
|
||||
2, -6:
|
||||
return Vector2i(0, 1) # South
|
||||
3, -5:
|
||||
return Vector2i(-1, 1) # Southwest
|
||||
4, -4:
|
||||
return Vector2i(-1, 0) # West
|
||||
-3, 5:
|
||||
return Vector2i(-1, -1) # Northwest
|
||||
-2:
|
||||
return Vector2i(0, -1) # North
|
||||
-1:
|
||||
return Vector2i(1, -1) # Northeast
|
||||
_:
|
||||
return Vector2i.ZERO
|
||||
|
||||
|
||||
# D-054: Convert a facing angle (radians) to the nearest octant name.
|
||||
@@ -213,27 +243,45 @@ static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
static func _angle_to_octant(angle: float) -> String:
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return "East"
|
||||
1: return "Southeast"
|
||||
2, -6: return "South"
|
||||
3, -5: return "Southwest"
|
||||
4, -4: return "West"
|
||||
-3, 5: return "Northwest"
|
||||
-2: return "North"
|
||||
-1: return "Northeast"
|
||||
_: return "East"
|
||||
0:
|
||||
return "East"
|
||||
1:
|
||||
return "Southeast"
|
||||
2, -6:
|
||||
return "South"
|
||||
3, -5:
|
||||
return "Southwest"
|
||||
4, -4:
|
||||
return "West"
|
||||
-3, 5:
|
||||
return "Northwest"
|
||||
-2:
|
||||
return "North"
|
||||
-1:
|
||||
return "Northeast"
|
||||
_:
|
||||
return "East"
|
||||
|
||||
|
||||
# Map a direction vector to the corresponding movement Action.
|
||||
# Handles all 8 directions via composite W+D, W+A, etc.
|
||||
static func _dir_to_action(dir: Vector2i) -> Action:
|
||||
match dir:
|
||||
Vector2i(0, -1): return Action.MOVE_NORTH
|
||||
Vector2i(1, -1): return Action.MOVE_NORTHEAST
|
||||
Vector2i(1, 0): return Action.MOVE_EAST
|
||||
Vector2i(1, 1): return Action.MOVE_SOUTHEAST
|
||||
Vector2i(0, 1): return Action.MOVE_SOUTH
|
||||
Vector2i(-1, 1): return Action.MOVE_SOUTHWEST
|
||||
Vector2i(-1, 0): return Action.MOVE_WEST
|
||||
Vector2i(-1, -1): return Action.MOVE_NORTHWEST
|
||||
_: return Action.MOVE_NORTH
|
||||
Vector2i(0, -1):
|
||||
return Action.MOVE_NORTH
|
||||
Vector2i(1, -1):
|
||||
return Action.MOVE_NORTHEAST
|
||||
Vector2i(1, 0):
|
||||
return Action.MOVE_EAST
|
||||
Vector2i(1, 1):
|
||||
return Action.MOVE_SOUTHEAST
|
||||
Vector2i(0, 1):
|
||||
return Action.MOVE_SOUTH
|
||||
Vector2i(-1, 1):
|
||||
return Action.MOVE_SOUTHWEST
|
||||
Vector2i(-1, 0):
|
||||
return Action.MOVE_WEST
|
||||
Vector2i(-1, -1):
|
||||
return Action.MOVE_NORTHWEST
|
||||
_:
|
||||
return Action.MOVE_NORTH
|
||||
|
||||
@@ -16,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 (0–100). -1 if not available or not on battery.
|
||||
var battery_percent: int = -1
|
||||
|
||||
|
||||
# -- Memory -------------------------------------------------------------------
|
||||
|
||||
## Free physical RAM in megabytes. Call refresh_memory() before reading if staleness matters.
|
||||
@@ -57,7 +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,
|
||||
}
|
||||
|
||||
@@ -17,18 +17,26 @@ var _quit_dialog: ConfirmationDialog = null
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var timestamp := (
|
||||
"%04d%02d%02d-%02d%02d%02d"
|
||||
% [
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
]
|
||||
)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
push_error(
|
||||
"SessionManager: failed to create save dir %s: %s" % [save_path, error_string(err)]
|
||||
)
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
@@ -71,15 +79,21 @@ func list_game_dirs() -> Array:
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
(
|
||||
results
|
||||
. append(
|
||||
{
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
}
|
||||
)
|
||||
)
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
results.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.modified_time > b.modified_time
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@@ -105,11 +119,16 @@ func _do_quit_to_menu() -> void:
|
||||
# SimBridge._process() flushes the outbound buffer before teardown.
|
||||
if not GameState.current_game_id.is_empty():
|
||||
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
}
|
||||
)
|
||||
)
|
||||
GameState.current_game_id = ""
|
||||
_navigate_to_menu.call_deferred()
|
||||
else:
|
||||
@@ -131,7 +150,12 @@ func _cleanup_quit_dialog() -> void:
|
||||
func _write_seed_file(save_path: String, seed: int) -> void:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error()))
|
||||
push_error(
|
||||
(
|
||||
"SessionManager: failed to write seed file: %s"
|
||||
% error_string(FileAccess.get_open_error())
|
||||
)
|
||||
)
|
||||
return
|
||||
file.store_64(seed)
|
||||
|
||||
@@ -152,7 +176,12 @@ func save_character_archetype(game_id: String, archetype: String) -> void:
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write character.txt: %s" % error_string(FileAccess.get_open_error()))
|
||||
push_error(
|
||||
(
|
||||
"SessionManager: failed to write character.txt: %s"
|
||||
% error_string(FileAccess.get_open_error())
|
||||
)
|
||||
)
|
||||
return
|
||||
file.store_string(archetype)
|
||||
|
||||
|
||||
@@ -34,37 +34,50 @@ var _retry_timer: float = 0.0
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
get:
|
||||
return harness.tick if harness else 0
|
||||
set(v):
|
||||
if harness: harness.tick = v
|
||||
if harness:
|
||||
harness.tick = v
|
||||
|
||||
var _test_player_pos: Vector2i:
|
||||
get: return harness.player_pos if harness else Vector2i.ZERO
|
||||
get:
|
||||
return harness.player_pos if harness else Vector2i.ZERO
|
||||
set(v):
|
||||
if harness: harness.player_pos = v
|
||||
if harness:
|
||||
harness.player_pos = v
|
||||
|
||||
var _test_facing: String:
|
||||
get: return harness.facing if harness else "North"
|
||||
get:
|
||||
return harness.facing if harness else "North"
|
||||
set(v):
|
||||
if harness: harness.facing = v
|
||||
if harness:
|
||||
harness.facing = v
|
||||
|
||||
var _test_in_dialogue: bool:
|
||||
get: return harness.in_dialogue if harness else false
|
||||
get:
|
||||
return harness.in_dialogue if harness else false
|
||||
set(v):
|
||||
if harness: harness.in_dialogue = v
|
||||
if harness:
|
||||
harness.in_dialogue = v
|
||||
|
||||
var _test_gauntlet_mode: bool:
|
||||
get: return harness.gauntlet_mode if harness else false
|
||||
get:
|
||||
return harness.gauntlet_mode if harness else false
|
||||
set(v):
|
||||
if harness: harness.gauntlet_mode = v
|
||||
if harness:
|
||||
harness.gauntlet_mode = v
|
||||
|
||||
var _test_npc_relationship: String:
|
||||
get: return harness.npc_relationship if harness else "Unknown"
|
||||
get:
|
||||
return harness.npc_relationship if harness else "Unknown"
|
||||
set(v):
|
||||
if harness: harness.npc_relationship = v
|
||||
if harness:
|
||||
harness.npc_relationship = v
|
||||
|
||||
var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
get:
|
||||
return harness.input_queue if harness else []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -75,18 +88,23 @@ func _ready() -> void:
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
|
||||
func reset_test_state() -> void:
|
||||
if harness: harness.reset()
|
||||
if harness:
|
||||
harness.reset()
|
||||
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
|
||||
|
||||
# -- Connection lifecycle ------------------------------------------------------
|
||||
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
if state != new_state:
|
||||
@@ -94,6 +112,7 @@ func _set_state(new_state: ConnectionState) -> void:
|
||||
state = new_state
|
||||
connection_state_changed.emit(old_state, new_state)
|
||||
|
||||
|
||||
# Connect to simulation server.
|
||||
# In test mode, immediately transitions to CONNECTED.
|
||||
# In live mode, spawns server subprocess and defers TCP connection to _process()
|
||||
@@ -125,6 +144,7 @@ func connect_to_sim() -> void:
|
||||
_retry_timer = 0.0
|
||||
_bridge = null
|
||||
|
||||
|
||||
# Disconnect from simulation server
|
||||
func disconnect_from_sim() -> void:
|
||||
if _bridge != null:
|
||||
@@ -136,15 +156,21 @@ func disconnect_from_sim() -> void:
|
||||
_connect_retries = 0
|
||||
_set_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
|
||||
# Attempt TCP connection. Called from _process() during CONNECTING state.
|
||||
func _try_connect() -> void:
|
||||
_bridge = LocalBridge.new()
|
||||
var err := _bridge.connect_to_server("127.0.0.1", server_port)
|
||||
if err != OK:
|
||||
push_warning("SimBridge: TCP connect attempt %d/%d failed: %s" % [
|
||||
_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)])
|
||||
push_warning(
|
||||
(
|
||||
"SimBridge: TCP connect attempt %d/%d failed: %s"
|
||||
% [_connect_retries + 1, MAX_CONNECT_RETRIES, error_string(err)]
|
||||
)
|
||||
)
|
||||
_bridge = null
|
||||
|
||||
|
||||
# Poll transport layer every frame (non-test mode only)
|
||||
func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if test_mode:
|
||||
@@ -158,7 +184,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_retry_timer = 0.0
|
||||
_connect_retries += 1
|
||||
if _connect_retries > MAX_CONNECT_RETRIES:
|
||||
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
|
||||
push_error(
|
||||
"SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES
|
||||
)
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
_try_connect()
|
||||
@@ -176,7 +204,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
# Connection attempt failed — retry
|
||||
_bridge = null
|
||||
if _connect_retries >= MAX_CONNECT_RETRIES:
|
||||
push_error("SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES)
|
||||
push_error(
|
||||
"SimBridge: TCP connection failed after %d retries" % MAX_CONNECT_RETRIES
|
||||
)
|
||||
_set_state(ConnectionState.ERROR)
|
||||
StreamPeerTCP.STATUS_NONE:
|
||||
_bridge = null # Reset and retry
|
||||
@@ -191,7 +221,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
|
||||
if (
|
||||
bridge_status == StreamPeerTCP.STATUS_ERROR
|
||||
or bridge_status == StreamPeerTCP.STATUS_NONE
|
||||
):
|
||||
var reason := "Connection dropped during handshake"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
@@ -215,8 +248,11 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
# Decode HandshakeMessage: { "protocol_version": N }
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary) \
|
||||
or not decoded.value.has("protocol_version"):
|
||||
if (
|
||||
decoded.status != null
|
||||
or not (decoded.value is Dictionary)
|
||||
or not decoded.value.has("protocol_version")
|
||||
):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
@@ -226,8 +262,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
var server_version: int = decoded.value["protocol_version"]
|
||||
if server_version != Protocol.PROTOCOL_VERSION:
|
||||
var reason := "Protocol version mismatch: server=%d, client=%d" % [
|
||||
server_version, Protocol.PROTOCOL_VERSION]
|
||||
var reason := (
|
||||
"Protocol version mismatch: server=%d, client=%d"
|
||||
% [server_version, Protocol.PROTOCOL_VERSION]
|
||||
)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
@@ -237,7 +275,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(
|
||||
GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
|
||||
GameState.world_seed,
|
||||
GameState.character_archetype,
|
||||
GameState.character_visual_descriptor
|
||||
)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
@@ -259,10 +300,15 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
|
||||
# from server SQLite so the client reflects the authoritative persisted state (D-138).
|
||||
_outbound_buffer.append({
|
||||
"tick": 0,
|
||||
"action_name": "RequestAllSettings",
|
||||
})
|
||||
(
|
||||
_outbound_buffer
|
||||
. append(
|
||||
{
|
||||
"tick": 0,
|
||||
"action_name": "RequestAllSettings",
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if _bridge == null:
|
||||
@@ -289,7 +335,9 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if err != OK:
|
||||
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
||||
else:
|
||||
push_error("SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size())
|
||||
push_error(
|
||||
"SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size()
|
||||
)
|
||||
StreamPeerTCP.STATUS_CONNECTING:
|
||||
pass # Should not happen in CONNECTED state
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
@@ -304,6 +352,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
|
||||
# -- Input / snapshot ----------------------------------------------------------
|
||||
|
||||
|
||||
# Send input to simulation server.
|
||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
||||
# In test mode, inputs are delegated to the test harness.
|
||||
@@ -330,13 +379,14 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
if action_name.is_empty():
|
||||
return ERR_INVALID_PARAMETER
|
||||
var tick: int = GameState.current_tick
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
var entry: Dictionary = {"tick": tick, "action_name": action_name}
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data != null:
|
||||
entry["action_data"] = action_data
|
||||
_outbound_buffer.append(entry)
|
||||
return OK
|
||||
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
@@ -356,6 +406,7 @@ func poll_snapshot() -> Variant:
|
||||
|
||||
return null
|
||||
|
||||
|
||||
# Called by transport layer when raw bytes arrive from the server.
|
||||
# Latest-wins for positional state (stale frames are worthless), but one-shot
|
||||
# events (monologue, dialogue) are carried forward from overwritten snapshots
|
||||
@@ -367,12 +418,21 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
return
|
||||
if _last_snapshot != null:
|
||||
# Carry forward one-shot events the client hasn't consumed yet.
|
||||
if snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null:
|
||||
if (
|
||||
snapshot.get("current_monologue") == null
|
||||
and _last_snapshot.get("current_monologue") != null
|
||||
):
|
||||
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
|
||||
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
|
||||
if (
|
||||
snapshot.get("current_dialogue") == null
|
||||
and _last_snapshot.get("current_dialogue") != null
|
||||
):
|
||||
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
|
||||
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
|
||||
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
|
||||
if (
|
||||
snapshot.get("dialogue_response") == null
|
||||
and _last_snapshot.get("dialogue_response") != null
|
||||
):
|
||||
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
|
||||
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
|
||||
if old_conv_events.size() > 0:
|
||||
@@ -386,10 +446,14 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
|
||||
snapshot["save_result"] = _last_snapshot["save_result"]
|
||||
# #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot)
|
||||
if snapshot.get("settings_response") == null and _last_snapshot.get("settings_response") != null:
|
||||
if (
|
||||
snapshot.get("settings_response") == null
|
||||
and _last_snapshot.get("settings_response") != null
|
||||
):
|
||||
snapshot["settings_response"] = _last_snapshot["settings_response"]
|
||||
_last_snapshot = snapshot
|
||||
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
func drain_outbound() -> Array[Dictionary]:
|
||||
var inputs = _outbound_buffer.duplicate()
|
||||
@@ -399,24 +463,39 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
|
||||
# -- Wire protocol mapping -----------------------------------------------------
|
||||
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
match action:
|
||||
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
|
||||
InputMapper.Action.MOVE_EAST: return "MoveEast"
|
||||
InputMapper.Action.MOVE_SOUTHEAST: return "MoveSoutheast"
|
||||
InputMapper.Action.MOVE_SOUTH: return "MoveSouth"
|
||||
InputMapper.Action.MOVE_SOUTHWEST: return "MoveSouthwest"
|
||||
InputMapper.Action.MOVE_WEST: return "MoveWest"
|
||||
InputMapper.Action.MOVE_NORTHWEST: return "MoveNorthwest"
|
||||
InputMapper.Action.INTERACT: return "Interact"
|
||||
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
|
||||
InputMapper.Action.PAUSE: return "Pause"
|
||||
InputMapper.Action.UNPAUSE: return "Unpause"
|
||||
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
|
||||
InputMapper.Action.MOVE_NORTH:
|
||||
return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST:
|
||||
return "MoveNortheast"
|
||||
InputMapper.Action.MOVE_EAST:
|
||||
return "MoveEast"
|
||||
InputMapper.Action.MOVE_SOUTHEAST:
|
||||
return "MoveSoutheast"
|
||||
InputMapper.Action.MOVE_SOUTH:
|
||||
return "MoveSouth"
|
||||
InputMapper.Action.MOVE_SOUTHWEST:
|
||||
return "MoveSouthwest"
|
||||
InputMapper.Action.MOVE_WEST:
|
||||
return "MoveWest"
|
||||
InputMapper.Action.MOVE_NORTHWEST:
|
||||
return "MoveNorthwest"
|
||||
InputMapper.Action.INTERACT:
|
||||
return "Interact"
|
||||
InputMapper.Action.USE_PERCEPTION_MODE:
|
||||
return "UsePerceptionMode"
|
||||
InputMapper.Action.PAUSE:
|
||||
return "Pause"
|
||||
InputMapper.Action.UNPAUSE:
|
||||
return "Unpause"
|
||||
InputMapper.Action.TOGGLE_STANCE_UP:
|
||||
return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN:
|
||||
return "ToggleStanceDown"
|
||||
InputMapper.Action.OPEN_MENU:
|
||||
return "" # Client-only action, not part of wire protocol
|
||||
InputMapper.Action.BUG_REPORT:
|
||||
@@ -432,11 +511,11 @@ static func action_enum_to_wire(action: int) -> String:
|
||||
InputMapper.Action.DEBUG_COMMAND:
|
||||
return "DebugCommand" # #581: debug console command dispatch
|
||||
InputMapper.Action.CHANGE_SETTINGS:
|
||||
return "ChangeSettings" # #646: persist setting to server SQLite (D-138)
|
||||
return "ChangeSettings" # #646: persist setting to server SQLite (D-138)
|
||||
InputMapper.Action.REQUEST_ALL_SETTINGS:
|
||||
return "RequestAllSettings" # #646: unit variant — server sends full settings dump
|
||||
InputMapper.Action.DELETE_SETTING:
|
||||
return "DeleteSetting" # #646: struct variant — delete setting by key
|
||||
return "DeleteSetting" # #646: struct variant — delete setting by key
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
@@ -9,9 +9,11 @@ const STRINGS_PATH: String = "res://data/ui-strings.yaml"
|
||||
var _strings: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_strings()
|
||||
|
||||
|
||||
func _load_strings() -> void:
|
||||
if not FileAccess.file_exists(STRINGS_PATH):
|
||||
push_warning("UIStrings: file not found: %s" % STRINGS_PATH)
|
||||
@@ -26,6 +28,7 @@ func _load_strings() -> void:
|
||||
_loaded = true
|
||||
print("UIStrings: loaded %d strings from %s" % [_strings.size(), STRINGS_PATH])
|
||||
|
||||
|
||||
## Get a UI string by dotted key. Returns the key itself if not found.
|
||||
func get_text(key: String) -> String:
|
||||
if _strings.has(key):
|
||||
@@ -33,20 +36,24 @@ func get_text(key: String) -> String:
|
||||
push_warning("UIStrings: missing key '%s'" % key)
|
||||
return key
|
||||
|
||||
|
||||
## Check if a key exists.
|
||||
func has_key(key: String) -> bool:
|
||||
return _strings.has(key)
|
||||
|
||||
|
||||
## Get all keys.
|
||||
func get_all_keys() -> PackedStringArray:
|
||||
return PackedStringArray(_strings.keys())
|
||||
|
||||
|
||||
## Reload from disk (useful for hot-reload during development).
|
||||
func reload() -> void:
|
||||
_strings.clear()
|
||||
_loaded = false
|
||||
_load_strings()
|
||||
|
||||
|
||||
## Parse YAML with arbitrary nesting depth.
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
|
||||
@@ -15,11 +15,11 @@ extends RefCounted
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497).
|
||||
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _current_room_id: String = ""
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
@@ -54,8 +54,7 @@ func load_room(room_id: String) -> void:
|
||||
_latched = kept
|
||||
|
||||
# Load per-room checklist
|
||||
var room_path := _content_base.path_join(
|
||||
"gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_path := _content_base.path_join("gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_data := _load_checklist_file(room_path)
|
||||
if room_data.has("conditions"):
|
||||
_room_conditions = room_data["conditions"]
|
||||
@@ -90,12 +89,17 @@ func get_results() -> Array:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty():
|
||||
continue
|
||||
results.append({
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
})
|
||||
(
|
||||
results
|
||||
. append(
|
||||
{
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
}
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@@ -136,12 +140,17 @@ static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||
for i in conditions.size():
|
||||
if conditions[i].get("id", "").is_empty():
|
||||
push_warning(
|
||||
"ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
(
|
||||
"ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results"
|
||||
% [i, path]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
@@ -225,6 +234,7 @@ func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
|
||||
func _find_entity(entity_id: int) -> bool:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
@@ -236,6 +246,7 @@ func _find_entity(entity_id: int) -> bool:
|
||||
|
||||
# -- YAML parsing --------------------------------------------------------------
|
||||
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
|
||||
+46
-34
@@ -12,35 +12,35 @@ const TILE_SIZE: int = 32
|
||||
# All world content composited into one texture, then fog drawn over it.
|
||||
# Y-sort contract: all children of YSortGroup MUST use z_index = 0.
|
||||
# z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715).
|
||||
const Z_FLOOR: int = 0 # Floor tiles — ground plane
|
||||
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
|
||||
const Z_FLOOR: int = 0 # Floor tiles — ground plane
|
||||
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
|
||||
# z:20-99 reserved: liquid surface (z:110), surface effects
|
||||
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
|
||||
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
|
||||
# z:110 reserved: liquid surface occlusion (alpha by depth)
|
||||
# z:150-199 reserved: ground VFX (smoke origins, gas pools, sparks)
|
||||
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
|
||||
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
|
||||
# z:250-299 reserved: mid-air VFX (rising smoke, floating particles)
|
||||
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
|
||||
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
|
||||
# z:325-349 reserved: ceiling VFX (smoke through ceiling)
|
||||
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
|
||||
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
|
||||
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
|
||||
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
|
||||
# z:500-899 reserved: edge cases
|
||||
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
|
||||
const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060)
|
||||
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
|
||||
const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060)
|
||||
# Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101)
|
||||
# z:-75 to z:-51 reserved: lower floor VFX
|
||||
#
|
||||
# INSERT SCOPE (CanvasLayer 10)
|
||||
# Bloom-rendered, not affected by fog or camera transform.
|
||||
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
|
||||
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
|
||||
#
|
||||
# UI SCOPE (CanvasLayer 20)
|
||||
# HUD, monologue, cursor — always visible.
|
||||
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
|
||||
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
|
||||
#
|
||||
# MODAL SCOPE (CanvasLayer 30)
|
||||
# Full-screen overlays: pause, inventory modal, death screen.
|
||||
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
|
||||
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
|
||||
#
|
||||
# Rendering ceiling: 10 floors (25m) above current floor.
|
||||
# Above this: no sprites, ground shadows + environmental effects only.
|
||||
@@ -52,18 +52,18 @@ const VISIBLE_FLOOR_DEPTH: int = 2
|
||||
# Color represents the player's RELATIONSHIP to the entity, not an objective property.
|
||||
# Phase 1: default colors mapped by entity kind (Player/Npc/Object/Terrain).
|
||||
# Phase 2 (#361): colors derived from RelationshipState via the knowledge graph.
|
||||
const ENTITY_COLOR_UNKNOWN: Color = Color("#4a9ebb") # Unknown/Neutral — cool teal
|
||||
const ENTITY_COLOR_UNKNOWN: Color = Color("#4a9ebb") # Unknown/Neutral — cool teal
|
||||
const ENTITY_COLOR_FRIENDLY: Color = Color("#6bc9a6") # Known/Friendly — soft green
|
||||
const ENTITY_COLOR_POI: Color = Color("#e8c547") # Person of Interest — warm amber
|
||||
const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — muted red
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
const ENTITY_COLOR_POI: Color = Color("#e8c547") # Person of Interest — warm amber
|
||||
const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous — muted red
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# D-048/D-056: Insert-styled UI color palette
|
||||
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
|
||||
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
|
||||
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI
|
||||
const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
|
||||
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
|
||||
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI
|
||||
const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
|
||||
|
||||
# D-015: Peripheral vision dimming
|
||||
const PERIPHERAL_ALPHA: float = 0.5
|
||||
@@ -87,29 +87,41 @@ const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
const CAMERA_SMOOTHING_SPEED: float = 8.0
|
||||
|
||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
|
||||
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
"Friendly":
|
||||
return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest":
|
||||
return ENTITY_COLOR_POI
|
||||
"Hostile":
|
||||
return ENTITY_COLOR_HOSTILE
|
||||
"Unknown":
|
||||
return ENTITY_COLOR_UNKNOWN
|
||||
_:
|
||||
return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
"Player":
|
||||
return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain":
|
||||
return ENTITY_COLOR_OBJECT
|
||||
"Npc":
|
||||
return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_:
|
||||
return ENTITY_COLOR_OBJECT
|
||||
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
|
||||
@@ -13,7 +13,7 @@ var journal_panel: Node = null
|
||||
# main.gd and this coordinator both append to the same array.
|
||||
var _pending_record_inputs: Array = []
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1
|
||||
@@ -44,6 +44,7 @@ func connect_signals() -> void:
|
||||
|
||||
# -- Snapshot consumers (registered with SnapshotEventRouter) -----------------
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
func consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
@@ -102,17 +103,24 @@ func consume_dialogue_response() -> void:
|
||||
|
||||
# -- Signal handlers ----------------------------------------------------------
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection -> send to server
|
||||
func on_dialogue_option_selected(response_id: String, _text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data":
|
||||
{
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# D-063: Confrontation beat monologue -> show on monologue display (layer 7)
|
||||
@@ -141,14 +149,21 @@ func on_dialogue_unpause_requested() -> void:
|
||||
|
||||
# D-064: Handle walk-away -> send WalkAway{npc_id} to server
|
||||
func on_dialogue_dismissed() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data":
|
||||
{
|
||||
"target_entity_id":
|
||||
_last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes.
|
||||
|
||||
+67
-36
@@ -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:
|
||||
|
||||
@@ -86,15 +86,23 @@ func _try_extract_message() -> PackedByteArray:
|
||||
if _pending_length < 0:
|
||||
if _read_buffer.size() < 4:
|
||||
return PackedByteArray()
|
||||
_pending_length = (_read_buffer[0] << 24) | (_read_buffer[1] << 16) | \
|
||||
(_read_buffer[2] << 8) | _read_buffer[3]
|
||||
_pending_length = (
|
||||
(_read_buffer[0] << 24)
|
||||
| (_read_buffer[1] << 16)
|
||||
| (_read_buffer[2] << 8)
|
||||
| _read_buffer[3]
|
||||
)
|
||||
_read_buffer = _read_buffer.slice(4)
|
||||
|
||||
if _pending_length > MAX_MESSAGE_SIZE:
|
||||
# Stream is corrupt — we can't find the next valid frame boundary.
|
||||
# Disconnect rather than silently discarding valid buffered data.
|
||||
push_error(
|
||||
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
|
||||
(
|
||||
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting"
|
||||
% [_pending_length, MAX_MESSAGE_SIZE]
|
||||
)
|
||||
)
|
||||
_corrupt = true
|
||||
_pending_length = -1
|
||||
_read_buffer.clear()
|
||||
@@ -129,6 +137,7 @@ func reset() -> void:
|
||||
# These exist for unit tests that verify framing logic without a live TCP
|
||||
# connection. Not used in production code paths.
|
||||
|
||||
|
||||
## Encode a payload into a framed byte array: [4-byte BE length][payload].
|
||||
static func frame_encode(payload: PackedByteArray) -> PackedByteArray:
|
||||
var len := payload.size()
|
||||
|
||||
@@ -14,9 +14,9 @@ extends Node
|
||||
## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
|
||||
const PROTOCOL_VERSION: int = 20
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
|
||||
## Decode an ObserverSnapshot from MessagePack bytes.
|
||||
## Returns decoded snapshot Dictionary or null on error.
|
||||
## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty
|
||||
@@ -36,7 +36,11 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var version: Variant = raw.get("version")
|
||||
if version != PROTOCOL_VERSION:
|
||||
push_error(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
|
||||
(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync."
|
||||
% [version, PROTOCOL_VERSION]
|
||||
)
|
||||
)
|
||||
return null
|
||||
|
||||
var entities: Array[Dictionary] = []
|
||||
@@ -51,7 +55,11 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
if dropped > 0:
|
||||
push_error(
|
||||
"Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
|
||||
(
|
||||
"Protocol: %d/%d entities failed to decode (D-010 information boundary violation)"
|
||||
% [dropped, raw_entities.size()]
|
||||
)
|
||||
)
|
||||
|
||||
# GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63
|
||||
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
|
||||
@@ -71,7 +79,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var raw_vtiles: Variant = raw.get("visible_tiles")
|
||||
if raw_vtiles is Array:
|
||||
for raw_tile in raw_vtiles:
|
||||
if raw_tile is Dictionary and raw_tile.has("x") and raw_tile.has("y") and raw_tile.has("z"):
|
||||
if (
|
||||
raw_tile is Dictionary
|
||||
and raw_tile.has("x")
|
||||
and raw_tile.has("y")
|
||||
and raw_tile.has("z")
|
||||
):
|
||||
var tile_entry := {
|
||||
"x": int(raw_tile["x"]),
|
||||
"y": int(raw_tile["y"]),
|
||||
@@ -119,11 +132,16 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_inventory is Array:
|
||||
for raw_item in raw_inventory:
|
||||
if raw_item is Dictionary and raw_item.has("item_id") and raw_item.has("name"):
|
||||
player_inventory.append({
|
||||
"item_id": int(raw_item["item_id"]),
|
||||
"name": str(raw_item["name"]),
|
||||
"slot": int(raw_item.get("slot", 0)),
|
||||
})
|
||||
(
|
||||
player_inventory
|
||||
. append(
|
||||
{
|
||||
"item_id": int(raw_item["item_id"]),
|
||||
"name": str(raw_item["name"]),
|
||||
"slot": int(raw_item.get("slot", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060) — cognitive delay fog entities
|
||||
# Bounded: server sends at most ~50 pending recognitions per snapshot (practical limit
|
||||
@@ -135,17 +153,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var count := 0
|
||||
for raw_pr in raw_recognitions:
|
||||
if count >= MAX_PENDING_RECOGNITIONS:
|
||||
push_warning("Protocol: pending_recognitions truncated at %d entries" % MAX_PENDING_RECOGNITIONS)
|
||||
push_warning(
|
||||
(
|
||||
"Protocol: pending_recognitions truncated at %d entries"
|
||||
% MAX_PENDING_RECOGNITIONS
|
||||
)
|
||||
)
|
||||
break
|
||||
if raw_pr is Dictionary and raw_pr.has("entity_id") and raw_pr.has("x") and raw_pr.has("y"):
|
||||
pending_recognitions.append({
|
||||
"entity_id": int(raw_pr["entity_id"]),
|
||||
"x": float(raw_pr["x"]),
|
||||
"y": float(raw_pr["y"]),
|
||||
"z": int(raw_pr.get("z", 0)),
|
||||
"remaining_ticks": int(raw_pr.get("remaining_ticks", 0)),
|
||||
"total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)),
|
||||
})
|
||||
if (
|
||||
raw_pr is Dictionary
|
||||
and raw_pr.has("entity_id")
|
||||
and raw_pr.has("x")
|
||||
and raw_pr.has("y")
|
||||
):
|
||||
(
|
||||
pending_recognitions
|
||||
. append(
|
||||
{
|
||||
"entity_id": int(raw_pr["entity_id"]),
|
||||
"x": float(raw_pr["x"]),
|
||||
"y": float(raw_pr["y"]),
|
||||
"z": int(raw_pr.get("z", 0)),
|
||||
"remaining_ticks": int(raw_pr.get("remaining_ticks", 0)),
|
||||
"total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)),
|
||||
}
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
|
||||
# v7: current_dialogue (#435, D-061/D-062) — NPC speech + player response options
|
||||
@@ -159,12 +192,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_options is Array:
|
||||
for raw_opt in raw_options:
|
||||
if raw_opt is Dictionary and raw_opt.has("text"):
|
||||
dialogue_options.append({
|
||||
"text": str(raw_opt["text"]),
|
||||
"response_id": str(raw_opt.get("response_id", "")),
|
||||
"priority": int(raw_opt.get("priority", 0)),
|
||||
"confrontation": bool(raw_opt.get("confrontation", false)),
|
||||
})
|
||||
(
|
||||
dialogue_options
|
||||
. append(
|
||||
{
|
||||
"text": str(raw_opt["text"]),
|
||||
"response_id": str(raw_opt.get("response_id", "")),
|
||||
"priority": int(raw_opt.get("priority", 0)),
|
||||
"confrontation": bool(raw_opt.get("confrontation", false)),
|
||||
}
|
||||
)
|
||||
)
|
||||
current_dialogue = {
|
||||
"npc_name": str(raw_dialogue.get("npc_name", "")),
|
||||
"npc_entity_id": int(raw_dialogue.get("npc_entity_id", -1)),
|
||||
@@ -190,13 +228,18 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_conv_events is Array:
|
||||
for raw_ce in raw_conv_events:
|
||||
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
|
||||
conversation_events.append({
|
||||
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||
"target_id": int(raw_ce.get("target_id", 0)),
|
||||
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||
"target_name": str(raw_ce.get("target_name", "")),
|
||||
"occluded_line": str(raw_ce["occluded_line"]),
|
||||
})
|
||||
(
|
||||
conversation_events
|
||||
. append(
|
||||
{
|
||||
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||
"target_id": int(raw_ce.get("target_id", 0)),
|
||||
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||
"target_name": str(raw_ce.get("target_name", "")),
|
||||
"occluded_line": str(raw_ce["occluded_line"]),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
|
||||
var conversation_ended: Array = []
|
||||
@@ -204,10 +247,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_conv_ended is Array:
|
||||
for raw_end in raw_conv_ended:
|
||||
if raw_end is Dictionary:
|
||||
conversation_ended.append({
|
||||
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
(
|
||||
conversation_ended
|
||||
. append(
|
||||
{
|
||||
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||
@@ -215,15 +263,26 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
for raw_poi in raw_pois:
|
||||
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||
poi_list.append({
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
if (
|
||||
raw_poi is Dictionary
|
||||
and raw_poi.has("poi_id")
|
||||
and raw_poi.has("x")
|
||||
and raw_poi.has("y")
|
||||
):
|
||||
(
|
||||
poi_list
|
||||
. append(
|
||||
{
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category":
|
||||
str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
@@ -268,9 +327,14 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_tce is Array:
|
||||
for raw_ev in raw_tce:
|
||||
if raw_ev is Dictionary and raw_ev.has("triangle_id"):
|
||||
triangle_crisis_events.append({
|
||||
"triangle_id": int(raw_ev["triangle_id"]),
|
||||
})
|
||||
(
|
||||
triangle_crisis_events
|
||||
. append(
|
||||
{
|
||||
"triangle_id": int(raw_ev["triangle_id"]),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v20: settings_response (#627, D-138) — server ack after ChangeSettings / full settings dump
|
||||
# after RequestAllSettings. kind = "full"|"ack". "full": settings array [{key, value}].
|
||||
@@ -285,10 +349,15 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_sr_settings is Array:
|
||||
for raw_s in raw_sr_settings:
|
||||
if raw_s is Dictionary and raw_s.has("key"):
|
||||
sr_settings.append({
|
||||
"key": str(raw_s["key"]),
|
||||
"value": raw_s.get("value"),
|
||||
})
|
||||
(
|
||||
sr_settings
|
||||
. append(
|
||||
{
|
||||
"key": str(raw_s["key"]),
|
||||
"value": raw_s.get("value"),
|
||||
}
|
||||
)
|
||||
)
|
||||
settings_response = {"kind": "full", "settings": sr_settings}
|
||||
elif sr_kind == "ack":
|
||||
settings_response = {
|
||||
@@ -338,27 +407,37 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_kg_entities is Array:
|
||||
for raw_ke in raw_kg_entities:
|
||||
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||
kg_entities.append({
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
})
|
||||
(
|
||||
kg_entities
|
||||
. append(
|
||||
{
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
var kg_facts: Array = []
|
||||
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||
if raw_kg_facts is Array:
|
||||
for raw_kf in raw_kg_facts:
|
||||
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||
kg_facts.append({
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
})
|
||||
(
|
||||
kg_facts
|
||||
. append(
|
||||
{
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
player_knowledge = {
|
||||
"entities": kg_entities,
|
||||
"facts": kg_facts,
|
||||
@@ -396,8 +475,13 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
## Decode a single VisibleEntity from a raw msgpack map.
|
||||
static func _decode_entity(raw: Dictionary) -> Variant:
|
||||
if not raw.has("entity_id") or not raw.has("x") or not raw.has("y") \
|
||||
or not raw.has("z") or not raw.has("kind"):
|
||||
if (
|
||||
not raw.has("entity_id")
|
||||
or not raw.has("x")
|
||||
or not raw.has("y")
|
||||
or not raw.has("z")
|
||||
or not raw.has("kind")
|
||||
):
|
||||
push_warning("Protocol: entity missing required fields: %s" % str(raw.keys()))
|
||||
return null
|
||||
|
||||
@@ -485,23 +569,25 @@ static func _decode_verb_option(raw) -> Variant:
|
||||
## Returns { "variant": String, "data": Variant } in both cases.
|
||||
static func _decode_enum_variant(raw) -> Dictionary:
|
||||
if raw is String:
|
||||
return { "variant": raw, "data": null }
|
||||
return {"variant": raw, "data": null}
|
||||
if raw is Dictionary and raw.size() == 1:
|
||||
var variant_name: String = raw.keys()[0]
|
||||
return { "variant": variant_name, "data": raw[variant_name] }
|
||||
return {"variant": variant_name, "data": raw[variant_name]}
|
||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||
return { "variant": "Unknown", "data": raw }
|
||||
return {"variant": "Unknown", "data": raw}
|
||||
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
|
||||
static func encode_startup_message(
|
||||
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null) -> PackedByteArray:
|
||||
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null
|
||||
) -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
# Explicit match prevents unknown strings silently reaching the server as
|
||||
# garbage enum values — fail loudly and fall back to "Detective".
|
||||
@@ -512,7 +598,12 @@ static func encode_startup_message(
|
||||
"smuggler":
|
||||
archetype_variant = "Smuggler"
|
||||
_:
|
||||
push_error("Protocol: unknown character_archetype '%s' — defaulting to 'Detective'" % character_archetype)
|
||||
push_error(
|
||||
(
|
||||
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
|
||||
% character_archetype
|
||||
)
|
||||
)
|
||||
archetype_variant = "Detective"
|
||||
var msg := {
|
||||
"world_seed": world_seed,
|
||||
@@ -531,7 +622,9 @@ static func encode_startup_message(
|
||||
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
## action_data: null for unit variants, String for UsePerceptionMode
|
||||
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
|
||||
static func encode_player_input(
|
||||
tick: int, action_name: String, action_data: Variant = null
|
||||
) -> PackedByteArray:
|
||||
var action_value: Variant = _encode_action(action_name, action_data)
|
||||
|
||||
var input := {
|
||||
@@ -555,10 +648,15 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
wire_inputs.append({
|
||||
"tick": input["tick"],
|
||||
"action": _encode_action(action_name, action_data),
|
||||
})
|
||||
(
|
||||
wire_inputs
|
||||
. append(
|
||||
{
|
||||
"tick": input["tick"],
|
||||
"action": _encode_action(action_name, action_data),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
@@ -573,10 +671,10 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
|
||||
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
|
||||
if action_data != null:
|
||||
return { action_name: action_data }
|
||||
return {action_name: action_data}
|
||||
# Interact is a struct variant — server expects named fields, not a bare string
|
||||
if action_name == "Interact":
|
||||
return { "Interact": { "target_entity_id": null, "verb": null } }
|
||||
return {"Interact": {"target_entity_id": null, "verb": null}}
|
||||
return action_name
|
||||
|
||||
|
||||
@@ -586,11 +684,13 @@ static func _encode_action(action_name: String, action_data: Variant) -> Variant
|
||||
## queuing via SimBridge._outbound_buffer.
|
||||
## The server receives this as a PlayerAction::ChangeSettings variant.
|
||||
static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
var entries: Array = [{
|
||||
"tick": 0,
|
||||
"action_name": "ChangeSettings",
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}]
|
||||
var entries: Array = [
|
||||
{
|
||||
"tick": 0,
|
||||
"action_name": "ChangeSettings",
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}
|
||||
]
|
||||
var result = Messagepack.encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_change_settings failed: %s" % result.status)
|
||||
|
||||
@@ -7,14 +7,34 @@ extends RefCounted
|
||||
|
||||
const _WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
Vector2i(7, 7),
|
||||
Vector2i(8, 7),
|
||||
Vector2i(9, 7),
|
||||
Vector2i(10, 7),
|
||||
Vector2i(11, 7),
|
||||
Vector2i(12, 7),
|
||||
Vector2i(13, 7),
|
||||
Vector2i(14, 7),
|
||||
Vector2i(7, 14),
|
||||
Vector2i(8, 14),
|
||||
Vector2i(9, 14),
|
||||
Vector2i(10, 14),
|
||||
Vector2i(11, 14),
|
||||
Vector2i(12, 14),
|
||||
Vector2i(13, 14),
|
||||
Vector2i(14, 14),
|
||||
Vector2i(7, 8),
|
||||
Vector2i(7, 9),
|
||||
Vector2i(7, 10),
|
||||
Vector2i(7, 11),
|
||||
Vector2i(7, 12),
|
||||
Vector2i(7, 13),
|
||||
Vector2i(14, 8),
|
||||
Vector2i(14, 9),
|
||||
Vector2i(14, 10),
|
||||
Vector2i(14, 11),
|
||||
Vector2i(14, 12),
|
||||
Vector2i(14, 13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
@@ -48,6 +68,7 @@ func process_facing(new_facing: String) -> void:
|
||||
|
||||
# -- Snapshot generation -------------------------------------------------------
|
||||
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
tick += 1
|
||||
|
||||
@@ -76,42 +97,60 @@ func snapshot() -> Dictionary:
|
||||
var py := player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
var entities: Array = [
|
||||
{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
"visibility": "Forward",
|
||||
}
|
||||
]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
})
|
||||
(
|
||||
entities
|
||||
. append(
|
||||
{
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
(
|
||||
nearby
|
||||
. append(
|
||||
{
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs":
|
||||
[
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{
|
||||
"kind": "ExamineNpc",
|
||||
"label": "Observe",
|
||||
"priority": 2,
|
||||
"available": true
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
@@ -128,11 +167,28 @@ func snapshot() -> Dictionary:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, # gdlint:ignore = max-line-length
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, # gdlint:ignore = max-line-length
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, # gdlint:ignore = max-line-length
|
||||
"speech":
|
||||
"Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options":
|
||||
[
|
||||
{
|
||||
"text": "Just arrived. Still getting my bearings.",
|
||||
"response_id": "kael_greet_01",
|
||||
"priority": 1,
|
||||
"confrontation": false
|
||||
}, # gdlint:ignore = max-line-length
|
||||
{
|
||||
"text": "Passing through. Know where I can find work?",
|
||||
"response_id": "kael_greet_02",
|
||||
"priority": 2,
|
||||
"confrontation": false
|
||||
}, # gdlint:ignore = max-line-length
|
||||
{
|
||||
"text": "I saw you near the cargo bay last night.",
|
||||
"response_id": "kael_confront_01",
|
||||
"priority": 3,
|
||||
"confrontation": true
|
||||
}, # gdlint:ignore = max-line-length
|
||||
],
|
||||
}
|
||||
|
||||
@@ -142,26 +198,55 @@ func snapshot() -> Dictionary:
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
(
|
||||
pending_recs
|
||||
. append(
|
||||
{
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, # gdlint:ignore = max-line-length
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
{
|
||||
"speaker": "Mira",
|
||||
"target": "Soren",
|
||||
"line": "The cargo manifests don't add up. Three containers unaccounted for."
|
||||
},
|
||||
{
|
||||
"speaker": "Soren",
|
||||
"target": "Mira",
|
||||
"line": "Could be a logging error. Happens every... cycle."
|
||||
},
|
||||
{
|
||||
"speaker": "Mira",
|
||||
"target": "Soren",
|
||||
"line": "Not like this. Someone moved them after... check."
|
||||
},
|
||||
{
|
||||
"speaker": "Soren",
|
||||
"target": "Mira",
|
||||
"line": "You're reading too much into it. The docks are... these days."
|
||||
},
|
||||
{
|
||||
"speaker": "Mira",
|
||||
"target": "Soren",
|
||||
"line": "Then explain the weight discrepancy. Two hundred kilos... just gone."
|
||||
}, # gdlint:ignore = max-line-length
|
||||
{
|
||||
"speaker": "Soren",
|
||||
"target": "Mira",
|
||||
"line": "Fine. I'll pull the bay... tonight. But keep this between us."
|
||||
},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
@@ -170,20 +255,26 @@ func snapshot() -> Dictionary:
|
||||
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
(
|
||||
conv_events
|
||||
. append(
|
||||
{
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
}
|
||||
)
|
||||
)
|
||||
elif tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"game_time":
|
||||
{
|
||||
"day": 0,
|
||||
"time_of_day": tick * 10,
|
||||
"day_phase": "Morning",
|
||||
@@ -207,6 +298,7 @@ func snapshot() -> Dictionary:
|
||||
|
||||
# -- Map generation ------------------------------------------------------------
|
||||
|
||||
|
||||
func _tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
@@ -216,8 +308,9 @@ func _tiles() -> Array:
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var is_edge := (
|
||||
x == room_x or x == room_x + room_w - 1 or y == room_y or y == room_y + room_h - 1
|
||||
)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
@@ -253,7 +346,9 @@ func _visible_tiles() -> Array:
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
|
||||
vtiles.append(
|
||||
{"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)}
|
||||
)
|
||||
return vtiles
|
||||
|
||||
|
||||
@@ -262,8 +357,9 @@ func _get_tile_type(x: int, y: int) -> String:
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var is_edge := (
|
||||
x == room_x or x == room_x + room_w - 1 or y == room_y or y == room_y + room_h - 1
|
||||
)
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
return "door"
|
||||
@@ -273,6 +369,7 @@ func _get_tile_type(x: int, y: int) -> String:
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
@@ -302,25 +399,43 @@ func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
|
||||
static func action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
"MoveNorth":
|
||||
return Vector2i(0, -1)
|
||||
"MoveNortheast":
|
||||
return Vector2i(1, -1)
|
||||
"MoveEast":
|
||||
return Vector2i(1, 0)
|
||||
"MoveSoutheast":
|
||||
return Vector2i(1, 1)
|
||||
"MoveSouth":
|
||||
return Vector2i(0, 1)
|
||||
"MoveSouthwest":
|
||||
return Vector2i(-1, 1)
|
||||
"MoveWest":
|
||||
return Vector2i(-1, 0)
|
||||
"MoveNorthwest":
|
||||
return Vector2i(-1, -1)
|
||||
_:
|
||||
return Vector2i.ZERO
|
||||
|
||||
|
||||
static func delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
Vector2i(0, -1):
|
||||
return "North"
|
||||
Vector2i(1, -1):
|
||||
return "Northeast"
|
||||
Vector2i(1, 0):
|
||||
return "East"
|
||||
Vector2i(1, 1):
|
||||
return "Southeast"
|
||||
Vector2i(0, 1):
|
||||
return "South"
|
||||
Vector2i(-1, 1):
|
||||
return "Southwest"
|
||||
Vector2i(-1, 0):
|
||||
return "West"
|
||||
Vector2i(-1, -1):
|
||||
return "Northwest"
|
||||
_:
|
||||
return "North"
|
||||
|
||||
@@ -40,12 +40,25 @@ const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
|
||||
## All body segment names in assembly order. D-160: 14 base + 2 swappable torso + 2 face = 18.
|
||||
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
|
||||
const ALL_SEGMENTS: Array[String] = [
|
||||
"head", "neck", "torso_upper", "torso", "hips",
|
||||
"arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r",
|
||||
"hand_l", "hand_r",
|
||||
"leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r",
|
||||
"foot_l", "foot_r",
|
||||
"eyes", "eyebrows",
|
||||
"head",
|
||||
"neck",
|
||||
"torso_upper",
|
||||
"torso",
|
||||
"hips",
|
||||
"arm_upper_l",
|
||||
"arm_upper_r",
|
||||
"arm_lower_l",
|
||||
"arm_lower_r",
|
||||
"hand_l",
|
||||
"hand_r",
|
||||
"leg_upper_l",
|
||||
"leg_upper_r",
|
||||
"leg_lower_l",
|
||||
"leg_lower_r",
|
||||
"foot_l",
|
||||
"foot_r",
|
||||
"eyes",
|
||||
"eyebrows",
|
||||
]
|
||||
|
||||
## Segments that do NOT receive the skin tone shader — preserve original embedded material.
|
||||
@@ -107,14 +120,14 @@ var _overhead_attachment: BoneAttachment3D = null
|
||||
|
||||
# Inspectable state for tests
|
||||
var _active_torso_variant: String = "full"
|
||||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||||
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
|
||||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||||
var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
|
||||
|
||||
var _toon_shader: Shader = null
|
||||
var _toon_masked_shader: Shader = null
|
||||
var _outline_shader: Shader = null
|
||||
var _white_mask: ImageTexture = null # 1x1 white pixel — forces full-body tinting
|
||||
var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png
|
||||
var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -140,6 +153,7 @@ func _load_shaders() -> void:
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Rebuild the full character from a CharacterVisualDescriptor.
|
||||
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
|
||||
_clear()
|
||||
@@ -168,8 +182,14 @@ func set_facing(direction: Variant) -> void:
|
||||
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
|
||||
elif direction is String:
|
||||
var angles: Dictionary = {
|
||||
"south": 0.0, "southwest": 45.0, "west": 90.0, "northwest": 135.0,
|
||||
"north": 180.0, "northeast": -135.0, "east": -90.0, "southeast": -45.0,
|
||||
"south": 0.0,
|
||||
"southwest": 45.0,
|
||||
"west": 90.0,
|
||||
"northwest": 135.0,
|
||||
"north": 180.0,
|
||||
"northeast": -135.0,
|
||||
"east": -90.0,
|
||||
"southeast": -45.0,
|
||||
}
|
||||
rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
|
||||
|
||||
@@ -232,6 +252,7 @@ func get_overhead_anchor() -> Marker3D:
|
||||
# Internal — teardown
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _clear() -> void:
|
||||
# Outline nodes are duplicates of body/clothing meshes — must be freed FIRST,
|
||||
# before the source meshes are removed. _rebuild_outlines() is not called here
|
||||
@@ -287,6 +308,7 @@ func _clear() -> void:
|
||||
# Internal — skeleton
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_skeleton() -> void:
|
||||
if not ResourceLoader.exists(SKELETON_PATH):
|
||||
push_error("CharacterVisual: skeleton not found at %s" % SKELETON_PATH)
|
||||
@@ -317,7 +339,12 @@ func _validate_slot_bones() -> void:
|
||||
for slot: String in SLOT_TO_BONE:
|
||||
var bone_name: String = SLOT_TO_BONE[slot]
|
||||
if _skeleton.find_bone(bone_name) == -1:
|
||||
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
|
||||
push_warning(
|
||||
(
|
||||
"CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton"
|
||||
% [slot, bone_name]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
|
||||
@@ -344,6 +371,7 @@ func _create_overhead_anchor() -> void:
|
||||
# Internal — body segments (D-160)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
|
||||
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
|
||||
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)]
|
||||
@@ -420,6 +448,7 @@ func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary)
|
||||
# Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_head(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.head_id.is_empty():
|
||||
return
|
||||
@@ -452,7 +481,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
|
||||
|
||||
|
||||
func _attach_to_bone(
|
||||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0) -> BoneAttachment3D:
|
||||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0
|
||||
) -> BoneAttachment3D:
|
||||
if _skeleton == null:
|
||||
return null
|
||||
if not ResourceLoader.exists(path):
|
||||
@@ -517,6 +547,7 @@ func _attach_to_bone(
|
||||
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||||
if _skeleton == null or desc.clothing_slots.is_empty():
|
||||
return
|
||||
@@ -566,7 +597,6 @@ func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||||
# Hiding is reserved for amputation/prosthetics via the debug tab or game logic.
|
||||
|
||||
|
||||
|
||||
func _read_coverage(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
@@ -608,6 +638,7 @@ func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2
|
||||
# Internal — accessories (BoneAttachment3D per slot→bone)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
for slot: String in desc.accessory_slots:
|
||||
var item_id: String = desc.accessory_slots[slot]
|
||||
@@ -629,8 +660,10 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _apply_tinted_shader(
|
||||
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, _render_priority: int = 0) -> void:
|
||||
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, _render_priority: int = 0
|
||||
) -> void:
|
||||
if mi.mesh == null:
|
||||
return
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
@@ -657,6 +690,7 @@ func _apply_tinted_shader(
|
||||
# Internal — outline pass (inverted hull, cull_front)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _rebuild_outlines() -> void:
|
||||
for node in _outline_nodes:
|
||||
if is_instance_valid(node):
|
||||
@@ -693,6 +727,7 @@ func _rebuild_outlines() -> void:
|
||||
# Animation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_animations() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
@@ -764,6 +799,7 @@ func stop_animation() -> void:
|
||||
# Static helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
static func _find_skeleton(root: Node) -> Skeleton3D:
|
||||
if root is Skeleton3D:
|
||||
return root as Skeleton3D
|
||||
@@ -791,7 +827,9 @@ static func _get_albedo_texture(mat: Material) -> Texture2D:
|
||||
if mat is BaseMaterial3D:
|
||||
return (mat as BaseMaterial3D).albedo_texture
|
||||
if mat is ShaderMaterial:
|
||||
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"]:
|
||||
for p: String in [
|
||||
"albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"
|
||||
]:
|
||||
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
|
||||
if val is Texture2D:
|
||||
return val as Texture2D
|
||||
|
||||
@@ -166,6 +166,7 @@ func to_dict() -> Dictionary:
|
||||
# -- Color serialization helpers --
|
||||
# Wire format: [r, g, b, a] float array (rmp_serde serializes Color as tuple).
|
||||
|
||||
|
||||
static func _decode_color(value: Variant, fallback: Color) -> Color:
|
||||
if value is Array and value.size() >= 3:
|
||||
return Color(value[0], value[1], value[2], value[3] if value.size() >= 4 else 1.0)
|
||||
@@ -198,5 +199,3 @@ static func _encode_tint_map(data: Dictionary) -> Dictionary:
|
||||
encoded.append(_encode_color(c))
|
||||
result[key] = encoded
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -74,8 +74,7 @@ func play_close_sound_events() -> void:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
evt.get("event_type", ""), Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
@@ -20,15 +20,23 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
GameState.visible_entities = snapshot.entities
|
||||
var found_player := false
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
|
||||
if (
|
||||
entity.has("kind")
|
||||
and entity.kind is Dictionary
|
||||
and entity.kind.get("variant") == "Player"
|
||||
):
|
||||
GameState.player_position = Vector2(entity.x, entity.y)
|
||||
if entity.has("entity_id"):
|
||||
GameState.player_entity_id = entity.entity_id
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and GameState.visible_entities.size() > 0:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
GameState.visible_entities.size()])
|
||||
push_warning(
|
||||
(
|
||||
"GameState: no Player entity found in %d entities"
|
||||
% [GameState.visible_entities.size()]
|
||||
)
|
||||
)
|
||||
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
@@ -44,7 +52,11 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
GameState.visible_tiles = snapshot.tiles
|
||||
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
elif (
|
||||
snapshot.has("visible_tiles")
|
||||
and snapshot.visible_tiles is Array
|
||||
and snapshot.visible_tiles.size() > 0
|
||||
):
|
||||
var has_type := false
|
||||
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
|
||||
has_type = snapshot.visible_tiles[0].has("type")
|
||||
@@ -195,12 +207,17 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
if entry.get("key") == "ai_dialogue.enabled":
|
||||
var val: Variant = entry.get("value")
|
||||
if val != null:
|
||||
GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting(
|
||||
"ai_dialogue.enabled", val
|
||||
)
|
||||
else:
|
||||
GameState.settings_response = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
|
||||
if (
|
||||
snapshot.has("character_visual_descriptor")
|
||||
and snapshot.character_visual_descriptor is Dictionary
|
||||
):
|
||||
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
|
||||
if CVD != null:
|
||||
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
|
||||
@@ -220,12 +237,18 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
for vtile in GameState.visible_tiles:
|
||||
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||
tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||
var player_pos_key := Vector2i(int(GameState.player_position.x), int(GameState.player_position.y))
|
||||
var player_pos_key := Vector2i(
|
||||
int(GameState.player_position.x), int(GameState.player_position.y)
|
||||
)
|
||||
var player_tile = tile_by_coord.get(player_pos_key, null)
|
||||
GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
if (
|
||||
snapshot.has("visible_tiles")
|
||||
and snapshot.visible_tiles is Array
|
||||
and snapshot.visible_tiles.size() > 0
|
||||
):
|
||||
GameState.visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
|
||||
@@ -19,9 +19,9 @@ static func parse(text: String) -> Dictionary:
|
||||
# Stack: [{indent: int, key: String}] — path of open section headers
|
||||
var stack: Array = []
|
||||
# Array state
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
var stripped := raw_line.strip_edges(false, true)
|
||||
@@ -126,19 +126,21 @@ static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
# Strip inline comment outside quotes
|
||||
if not val.begins_with("\""):
|
||||
if not val.begins_with('"'):
|
||||
var comment_pos: int = val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
# Quoted string — extract content between quotes
|
||||
if val.begins_with("\""):
|
||||
var end_quote: int = val.find("\"", 1)
|
||||
if val.begins_with('"'):
|
||||
var end_quote: int = val.find('"', 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
# Boolean
|
||||
if val == "true": return true
|
||||
if val == "false": return false
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
# Float (must have decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
Reference in New Issue
Block a user