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()
|
||||
|
||||
@@ -46,11 +46,11 @@ var _captured_screenshot: Image = null
|
||||
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
|
||||
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
|
||||
# debug tool that is always active during Gauntlet play.
|
||||
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
|
||||
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
|
||||
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
|
||||
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
|
||||
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
|
||||
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
|
||||
|
||||
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
|
||||
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
|
||||
var _snapshot_head: int = 0
|
||||
var _snapshot_count: int = 0
|
||||
|
||||
@@ -74,6 +74,7 @@ func _ready() -> void:
|
||||
# Public API for main.gd: record one tick's data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
|
||||
## - tick: current server tick number
|
||||
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
|
||||
@@ -180,18 +181,23 @@ func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
|
||||
# Test-accessible accessors (ring buffer introspection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _get_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
|
||||
func _get_snapshot_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
|
||||
func _get_input_buffer() -> Array:
|
||||
return _input_ring
|
||||
|
||||
|
||||
func _get_filled_input_count() -> int:
|
||||
return _input_count
|
||||
|
||||
|
||||
func _get_filled_snapshot_count() -> int:
|
||||
return _snapshot_count
|
||||
|
||||
@@ -200,6 +206,7 @@ func _get_filled_snapshot_count() -> int:
|
||||
# UI / capture flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func start_capture() -> void:
|
||||
if _active:
|
||||
return
|
||||
@@ -209,10 +216,15 @@ func start_capture() -> void:
|
||||
visible = true
|
||||
|
||||
# Pause the simulation
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Create the LineEdit dynamically
|
||||
_line_edit = LineEdit.new()
|
||||
@@ -254,10 +266,15 @@ func _close() -> void:
|
||||
_captured_screenshot = null
|
||||
|
||||
# Unpause the simulation
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _save_report(description: String) -> void:
|
||||
@@ -301,7 +318,9 @@ func _save_report(description: String) -> void:
|
||||
if desc_file:
|
||||
desc_file.store_string("Description: %s\n" % description)
|
||||
desc_file.store_string("Tick: %d\n" % tick)
|
||||
desc_file.store_string("Room: %s\n" % str(GameState.room_id if GameState.room_id else "none"))
|
||||
desc_file.store_string(
|
||||
"Room: %s\n" % str(GameState.room_id if GameState.room_id else "none")
|
||||
)
|
||||
desc_file.store_string("Stance: %s\n" % GameState.player_stance)
|
||||
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
|
||||
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
|
||||
@@ -343,8 +362,11 @@ func _save_report(description: String) -> void:
|
||||
var seed_val: Variant = _get_current_seed()
|
||||
seed_file.store_string(str(seed_val) + "\n")
|
||||
if seed_val == "unavailable":
|
||||
seed_file.store_string(
|
||||
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
|
||||
(
|
||||
seed_file
|
||||
. store_string(
|
||||
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
|
||||
)
|
||||
)
|
||||
seed_file.close()
|
||||
files_saved += 1
|
||||
@@ -360,8 +382,12 @@ func _save_report(description: String) -> void:
|
||||
else:
|
||||
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
|
||||
|
||||
print("BugReport: saved %d/7 files to %s (ring: %d ticks)" % [
|
||||
files_saved, base_path, _input_count])
|
||||
print(
|
||||
(
|
||||
"BugReport: saved %d/7 files to %s (ring: %d ticks)"
|
||||
% [files_saved, base_path, _input_count]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Simplified client-side text render of the current snapshot.
|
||||
@@ -369,14 +395,24 @@ func _save_report(description: String) -> void:
|
||||
func _render_snapshot_text() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
lines.append("=== Snapshot t%d ===" % GameState.current_tick)
|
||||
lines.append("Player: %s facing %s (%s)" % [
|
||||
str(GameState.player_position), GameState.player_facing, GameState.player_stance])
|
||||
lines.append(
|
||||
(
|
||||
"Player: %s facing %s (%s)"
|
||||
% [str(GameState.player_position), GameState.player_facing, GameState.player_stance]
|
||||
)
|
||||
)
|
||||
|
||||
if GameState.game_time.size() > 0:
|
||||
lines.append("Time: day %s, %s, %s" % [
|
||||
str(GameState.game_time.get("day", "?")),
|
||||
str(GameState.game_time.get("day_phase", "?")),
|
||||
str(GameState.game_time.get("tick_rate", "?"))])
|
||||
lines.append(
|
||||
(
|
||||
"Time: day %s, %s, %s"
|
||||
% [
|
||||
str(GameState.game_time.get("day", "?")),
|
||||
str(GameState.game_time.get("day_phase", "?")),
|
||||
str(GameState.game_time.get("tick_rate", "?"))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Entities (%d):" % GameState.visible_entities.size())
|
||||
@@ -387,12 +423,18 @@ func _render_snapshot_text() -> String:
|
||||
elif entity.has("kind") and entity.kind is String:
|
||||
kind_str = entity.kind
|
||||
var vis: String = entity.get("visibility", "?")
|
||||
lines.append(" #%s %s at (%s, %s) [%s]" % [
|
||||
str(entity.get("entity_id", "?")),
|
||||
kind_str,
|
||||
str(entity.get("x", "?")),
|
||||
str(entity.get("y", "?")),
|
||||
vis])
|
||||
lines.append(
|
||||
(
|
||||
" #%s %s at (%s, %s) [%s]"
|
||||
% [
|
||||
str(entity.get("entity_id", "?")),
|
||||
kind_str,
|
||||
str(entity.get("x", "?")),
|
||||
str(entity.get("y", "?")),
|
||||
vis
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
|
||||
@@ -400,9 +442,15 @@ func _render_snapshot_text() -> String:
|
||||
if GameState.current_monologue != null:
|
||||
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
|
||||
if GameState.current_dialogue != null:
|
||||
lines.append("Dialogue: %s says '%s'" % [
|
||||
str(GameState.current_dialogue.get("npc_name", "?")),
|
||||
str(GameState.current_dialogue.get("speech", ""))])
|
||||
lines.append(
|
||||
(
|
||||
"Dialogue: %s says '%s'"
|
||||
% [
|
||||
str(GameState.current_dialogue.get("npc_name", "?")),
|
||||
str(GameState.current_dialogue.get("speech", ""))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -416,8 +464,7 @@ func _draw() -> void:
|
||||
|
||||
# Center box
|
||||
var box_pos := Vector2(
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0,
|
||||
(viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
||||
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
||||
@@ -425,15 +472,21 @@ func _draw() -> void:
|
||||
|
||||
# Title
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
box_pos + Vector2(PADDING, 24),
|
||||
"WRONG — Describe the issue (Enter to save, Esc to cancel):",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, LABEL_FONT_SIZE, TEXT_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
LABEL_FONT_SIZE,
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return _active
|
||||
|
||||
+235
-94
@@ -16,10 +16,10 @@ signal creation_cancelled
|
||||
const BG_COLOR := Color(0.03, 0.03, 0.06, 1.0)
|
||||
const PANEL_BG := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const BORDER_COLOR := Color(0.12, 0.15, 0.20, 1.0)
|
||||
const TEXT_COLOR := Color(0.784, 0.816, 0.878, 1.0) # INSERT_COLOR_TEXT
|
||||
const TEXT_COLOR := Color(0.784, 0.816, 0.878, 1.0) # INSERT_COLOR_TEXT
|
||||
const TEXT_DIM := Color(0.53, 0.56, 0.63, 1.0)
|
||||
const HIGHLIGHT_COLOR := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER
|
||||
const ACTIVE_COLOR := Color(0.42, 0.79, 0.65, 1.0) # INSERT_COLOR_ACTIVE
|
||||
const ACTIVE_COLOR := Color(0.42, 0.79, 0.65, 1.0) # INSERT_COLOR_ACTIVE
|
||||
const ITEM_NORMAL_BG := Color(0.07, 0.07, 0.10, 1.0)
|
||||
const ITEM_SELECTED_BG := Color(0.10, 0.13, 0.18, 1.0)
|
||||
const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HOVER — selection border
|
||||
@@ -27,7 +27,7 @@ const ITEM_SELECTED_BORDER := Color(0.906, 0.773, 0.278, 1.0) # INSERT_COLOR_HO
|
||||
# --- Camera presets (D-158) ---
|
||||
# Pitch is degrees below horizontal: 5=near eye-level, 30=steeper, 80=near top-down.
|
||||
# Negative values = tilt downward (camera looks toward ground).
|
||||
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
|
||||
const CAM_PITCHES: Array[float] = [-5.0, -30.0, -80.0] # frontal, dramatic, overhead
|
||||
const CAM_PITCH_NAMES := ["Frontal", "Dramatic", "Overhead"]
|
||||
const CAM_DISTANCE: float = 3.8
|
||||
const CAM_TARGET_HEIGHT: float = 0.85
|
||||
@@ -79,35 +79,111 @@ const CLOTHING_SLOT_LABELS := ["Torso", "Legs", "Feet", "Hands"]
|
||||
|
||||
# --- Accessory slots (no held_l/held_r — play-time decisions per wireframe spec) ---
|
||||
const ACCESSORY_SLOTS := [
|
||||
"hat", "goggles", "mask", "backpack", "belt",
|
||||
"wrist_l", "wrist_r", "earring_l", "earring_r", "necklace",
|
||||
"hat",
|
||||
"goggles",
|
||||
"mask",
|
||||
"backpack",
|
||||
"belt",
|
||||
"wrist_l",
|
||||
"wrist_r",
|
||||
"earring_l",
|
||||
"earring_r",
|
||||
"necklace",
|
||||
]
|
||||
const ACCESSORY_SLOT_LABELS := [
|
||||
"Hat", "Goggles", "Mask", "Backpack", "Belt",
|
||||
"Wrist L", "Wrist R", "Earring L", "Earring R", "Necklace",
|
||||
"Hat",
|
||||
"Goggles",
|
||||
"Mask",
|
||||
"Backpack",
|
||||
"Belt",
|
||||
"Wrist L",
|
||||
"Wrist R",
|
||||
"Earring L",
|
||||
"Earring R",
|
||||
"Necklace",
|
||||
]
|
||||
|
||||
# --- D-165 palette (5 chromatic rows + 1 neutral row, 9 cols each) ---
|
||||
# Hardcoded hex per D-165 spec (HSL L=18→76% per chromatic row, S fixed per row).
|
||||
const PALETTE_COLORS: Array[Array] = [
|
||||
# Row 1: Reds H=0° S=35%
|
||||
["#3d1d1d", "#522727", "#673131", "#7f3d3d", "#974848", "#af5959", "#bc7575", "#c99090", "#d7acac"],
|
||||
[
|
||||
"#3d1d1d",
|
||||
"#522727",
|
||||
"#673131",
|
||||
"#7f3d3d",
|
||||
"#974848",
|
||||
"#af5959",
|
||||
"#bc7575",
|
||||
"#c99090",
|
||||
"#d7acac"
|
||||
],
|
||||
# Row 2: Greens H=150° S=28%
|
||||
["#213a2d", "#2c4e3d", "#37614c", "#43785e", "#508f70", "#62a684", "#7cb599", "#96c4ad", "#b0d2c1"],
|
||||
[
|
||||
"#213a2d",
|
||||
"#2c4e3d",
|
||||
"#37614c",
|
||||
"#43785e",
|
||||
"#508f70",
|
||||
"#62a684",
|
||||
"#7cb599",
|
||||
"#96c4ad",
|
||||
"#b0d2c1"
|
||||
],
|
||||
# Row 3: Blues H=215° S=35%
|
||||
["#1d2b3d", "#273952", "#314867", "#3d587f", "#486997", "#597daf", "#7593bc", "#90a8c9", "#acbed7"],
|
||||
[
|
||||
"#1d2b3d",
|
||||
"#273952",
|
||||
"#314867",
|
||||
"#3d587f",
|
||||
"#486997",
|
||||
"#597daf",
|
||||
"#7593bc",
|
||||
"#90a8c9",
|
||||
"#acbed7"
|
||||
],
|
||||
# Row 4: Purples H=275° S=28%
|
||||
["#30213a", "#402c4e", "#503761", "#624378", "#75508f", "#8a62a6", "#9d7cb5", "#b196c4", "#c4b0d2"],
|
||||
[
|
||||
"#30213a",
|
||||
"#402c4e",
|
||||
"#503761",
|
||||
"#624378",
|
||||
"#75508f",
|
||||
"#8a62a6",
|
||||
"#9d7cb5",
|
||||
"#b196c4",
|
||||
"#c4b0d2"
|
||||
],
|
||||
# Row 5: Browns H=28° S=35%
|
||||
["#3d2c1d", "#523b27", "#674a31", "#7f5c3d", "#976d48", "#af8159", "#bc9675", "#c9ab90", "#d7c0ac"],
|
||||
[
|
||||
"#3d2c1d",
|
||||
"#523b27",
|
||||
"#674a31",
|
||||
"#7f5c3d",
|
||||
"#976d48",
|
||||
"#af8159",
|
||||
"#bc9675",
|
||||
"#c9ab90",
|
||||
"#d7c0ac"
|
||||
],
|
||||
# Row 6: Neutral grays
|
||||
["#000000", "#1f1f1f", "#3f3f3f", "#5f5f5f", "#7f7f7f", "#9f9f9f", "#bfbfbf", "#dfdfdf", "#ffffff"],
|
||||
[
|
||||
"#000000",
|
||||
"#1f1f1f",
|
||||
"#3f3f3f",
|
||||
"#5f5f5f",
|
||||
"#7f7f7f",
|
||||
"#9f9f9f",
|
||||
"#bfbfbf",
|
||||
"#dfdfdf",
|
||||
"#ffffff"
|
||||
],
|
||||
]
|
||||
const PALETTE_COLS := 9
|
||||
const RECENT_SLOTS := 9
|
||||
|
||||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||
const CAM_ZOOM_STEP: float = 0.12
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
@@ -118,7 +194,7 @@ const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||
# --- Descriptor and preview state ---
|
||||
var _descriptor: CharacterVisualDescriptor
|
||||
var _char_visual: CharacterVisual = null
|
||||
var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face-forward)
|
||||
var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face-forward)
|
||||
var _cam_pitch_idx: int = 0 # 0=frontal(-5°), 1=dramatic(-30°), 2=overhead(-80°)
|
||||
var _cam_zoom: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
|
||||
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed
|
||||
@@ -128,42 +204,42 @@ var _active_clothing_slot: String = "torso"
|
||||
var _active_accessory_slot: String = "hat"
|
||||
|
||||
# --- Body/skin tone buttons shared across Body + Head tabs ---
|
||||
var _body_type_btns: Dictionary = {} # BodyType int -> Button
|
||||
var _body_type_btns: Dictionary = {} # BodyType int -> Button
|
||||
var _body_skin_btns: Array[Button] = [] # 9 buttons in Body tab skin dock
|
||||
var _head_skin_btns: Array[Button] = [] # 9 buttons in Head tab skin dock
|
||||
|
||||
# --- Head / hair buttons ---
|
||||
var _head_item_btns: Dictionary = {} # head_id -> Button
|
||||
var _hair_item_btns: Dictionary = {} # hair_id -> Button
|
||||
var _head_item_btns: Dictionary = {} # head_id -> Button
|
||||
var _hair_item_btns: Dictionary = {} # hair_id -> Button
|
||||
var _facial_hair_btns: Array[Button] = []
|
||||
var _eyebrow_btns: Array[Button] = []
|
||||
|
||||
# --- Clothing/accessory grid content per slot ---
|
||||
var _clothing_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _clothing_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _clothing_slot_btns: Array[Button] = []
|
||||
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
var _accessory_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _clothing_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
var _accessory_grids: Dictionary = {} # slot -> GridContainer
|
||||
var _accessory_slot_btns: Array[Button] = []
|
||||
var _accessory_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
var _accessory_item_btns: Dictionary = {} # slot -> Dictionary {item_id -> Button}
|
||||
|
||||
# --- Color dock swatch references ---
|
||||
var _hair_primary_swatch: Button = null
|
||||
var _hair_highlight_swatch: Button = null
|
||||
var _eyebrow_tint_swatch: Button = null
|
||||
var _facial_hair_tint_swatch: Button = null
|
||||
var _clothing_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_accent_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
var _clothing_accent_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_primary_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
var _accessory_secondary_swatches: Dictionary = {} # slot -> Button
|
||||
|
||||
# --- Auto-derive flags (true = auto-derive from hair/clothing primary) ---
|
||||
# Note: hair highlight is always auto-derived (display-only swatch, no override).
|
||||
var _eyebrow_tint_auto: bool = true
|
||||
var _facial_hair_tint_auto: bool = true
|
||||
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
|
||||
var _clothing_accent_auto: Dictionary = {} # slot -> bool
|
||||
var _accessory_secondary_auto: Dictionary = {} # slot -> bool
|
||||
var _clothing_secondary_auto: Dictionary = {} # slot -> bool
|
||||
var _clothing_accent_auto: Dictionary = {} # slot -> bool
|
||||
var _accessory_secondary_auto: Dictionary = {} # slot -> bool
|
||||
|
||||
# --- Color picker modal state ---
|
||||
var _modal_callback: Callable
|
||||
@@ -171,7 +247,7 @@ var _modal_original_color: Color = Color.WHITE
|
||||
var _modal_preview_swatch: Button = null # the swatch button being edited
|
||||
var _modal_hex_input: LineEdit = null
|
||||
var _modal_swatch_btns: Array[Button] = [] # 54 palette swatches
|
||||
var _recent_colors: Array[Color] = [] # persisted recent custom colors
|
||||
var _recent_colors: Array[Color] = [] # persisted recent custom colors
|
||||
var _modal_recent_btns: Array[Button] = []
|
||||
|
||||
# --- Dock container references (set during tab build; avoid fragile child-index traversal) ---
|
||||
@@ -207,25 +283,31 @@ var _tab_container: TabContainer = null
|
||||
|
||||
# --- @onready references to .tscn nodes ---
|
||||
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
|
||||
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||||
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||||
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||||
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||||
@onready
|
||||
var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||||
@onready
|
||||
var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||||
@onready
|
||||
var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||||
@onready
|
||||
var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||||
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
||||
@onready var _footer_back: Button = $Footer/BackBtn
|
||||
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
|
||||
@onready var _footer_start: Button = $Footer/StartBtn
|
||||
@onready var _modal_root: Control = $ColorPickerModal
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Lifecycle
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_manifest() -> void:
|
||||
var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("CharacterCreation: manifest not found at %s — using empty defaults" % MANIFEST_PATH)
|
||||
push_warning(
|
||||
"CharacterCreation: manifest not found at %s — using empty defaults" % MANIFEST_PATH
|
||||
)
|
||||
_manifest = {}
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
@@ -277,12 +359,16 @@ func _ready() -> void:
|
||||
if has_hair:
|
||||
tab_builders.append({"name": "Hair", "build": _build_hair_tab, "always": false})
|
||||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||||
var has_clothing: bool = clothing_data is Dictionary and not (clothing_data as Dictionary).is_empty()
|
||||
var has_clothing: bool = (
|
||||
clothing_data is Dictionary and not (clothing_data as Dictionary).is_empty()
|
||||
)
|
||||
if has_clothing:
|
||||
tab_builders.append({"name": "Clothing", "build": _build_clothing_tab, "always": false})
|
||||
var has_accessories := not _manifest_array("accessories").is_empty()
|
||||
if has_accessories:
|
||||
tab_builders.append({"name": "Accessories", "build": _build_accessories_tab, "always": false})
|
||||
tab_builders.append(
|
||||
{"name": "Accessories", "build": _build_accessories_tab, "always": false}
|
||||
)
|
||||
tab_builders.append({"name": "Debug", "build": _build_debug_tab, "always": true})
|
||||
|
||||
for tb in tab_builders:
|
||||
@@ -337,6 +423,7 @@ func _ready() -> void:
|
||||
# Camera management (D-158)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _update_camera_angle() -> void:
|
||||
var pitch_deg := CAM_PITCHES[_cam_pitch_idx]
|
||||
var pitch_rad := deg_to_rad(-pitch_deg) # negative = looking downward
|
||||
@@ -359,8 +446,10 @@ func _cam_zoom_toward_cursor(_screen_pos: Vector2, zoom_delta: float) -> void:
|
||||
if _char_visual and _char_visual._skeleton:
|
||||
var head_idx := _char_visual._skeleton.find_bone("Head")
|
||||
if head_idx >= 0:
|
||||
var head_pos := _char_visual._skeleton.global_transform \
|
||||
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||||
var head_pos := (
|
||||
_char_visual._skeleton.global_transform
|
||||
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||||
)
|
||||
# Blend from body center toward head as zoom increases
|
||||
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom
|
||||
_cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend
|
||||
@@ -384,6 +473,7 @@ func _on_cam_angle_toggle() -> void:
|
||||
# Rotation (D-155: cardinal only, Q=CCW, E=CW)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_rotate_left() -> void:
|
||||
_facing_idx = (_facing_idx + 1) % CARDINAL_DIRS.size()
|
||||
_char_visual.set_facing(CARDINAL_DIRS[_facing_idx])
|
||||
@@ -398,6 +488,7 @@ func _on_rotate_right() -> void:
|
||||
# Preview refresh
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _refresh_preview() -> void:
|
||||
if _char_visual and is_instance_valid(_char_visual):
|
||||
_char_visual.load_descriptor(_descriptor)
|
||||
@@ -408,6 +499,7 @@ func _refresh_preview() -> void:
|
||||
# Tab: Body (Task #2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_body_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -475,10 +567,13 @@ func _build_body_tab(tab: Control) -> void:
|
||||
var eye_row := HBoxContainer.new()
|
||||
eye_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(eye_row)
|
||||
var eye_swatch := _make_color_swatch(_descriptor.eye_color, "Iris",
|
||||
var eye_swatch := _make_color_swatch(
|
||||
_descriptor.eye_color,
|
||||
"Iris",
|
||||
func(c: Color) -> void:
|
||||
_descriptor.eye_color = c
|
||||
_refresh_preview())
|
||||
_refresh_preview()
|
||||
)
|
||||
eye_row.add_child(eye_swatch)
|
||||
|
||||
_update_body_type_btns()
|
||||
@@ -527,6 +622,7 @@ func _update_body_type_btns() -> void:
|
||||
# Tab: Head (Task #3)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_head_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -564,10 +660,13 @@ func _build_head_tab(tab: Control) -> void:
|
||||
var eye_row := HBoxContainer.new()
|
||||
eye_row.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(eye_row)
|
||||
var eye_swatch := _make_color_swatch(_descriptor.eye_color, "Iris",
|
||||
var eye_swatch := _make_color_swatch(
|
||||
_descriptor.eye_color,
|
||||
"Iris",
|
||||
func(c: Color) -> void:
|
||||
_descriptor.eye_color = c
|
||||
_refresh_preview())
|
||||
_refresh_preview()
|
||||
)
|
||||
eye_row.add_child(eye_swatch)
|
||||
|
||||
_update_head_btns()
|
||||
@@ -589,6 +688,7 @@ func _update_head_btns() -> void:
|
||||
# Tab: Hair (Task #4)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_hair_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -669,7 +769,9 @@ func _update_hair_btns() -> void:
|
||||
for hid: String in _hair_item_btns:
|
||||
_set_item_selected(_hair_item_btns[hid], hid == _descriptor.hair_id)
|
||||
for i in FACIAL_HAIR_OPTIONS.size():
|
||||
_set_item_selected(_facial_hair_btns[i], FACIAL_HAIR_OPTIONS[i] == _descriptor.facial_hair_id)
|
||||
_set_item_selected(
|
||||
_facial_hair_btns[i], FACIAL_HAIR_OPTIONS[i] == _descriptor.facial_hair_id
|
||||
)
|
||||
# Eyebrow buttons removed — eyebrows come from body segment only
|
||||
|
||||
|
||||
@@ -684,21 +786,25 @@ func _build_hair_color_dock() -> Control:
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
dock.add_child(row)
|
||||
|
||||
_hair_primary_swatch = _make_color_swatch(Color(0.55, 0.35, 0.20), "Primary",
|
||||
func(c): _on_hair_primary_changed(c))
|
||||
_hair_primary_swatch = _make_color_swatch(
|
||||
Color(0.55, 0.35, 0.20), "Primary", func(c): _on_hair_primary_changed(c)
|
||||
)
|
||||
row.add_child(_hair_primary_swatch)
|
||||
|
||||
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
|
||||
_hair_highlight_swatch = _make_display_swatch(
|
||||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight")
|
||||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight"
|
||||
)
|
||||
row.add_child(_hair_highlight_swatch)
|
||||
|
||||
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
|
||||
func(c): _on_eyebrow_tint_changed(c))
|
||||
_eyebrow_tint_swatch = _make_color_swatch(
|
||||
_descriptor.hair_tint, "Brows ●", func(c): _on_eyebrow_tint_changed(c)
|
||||
)
|
||||
row.add_child(_eyebrow_tint_swatch)
|
||||
|
||||
_facial_hair_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Facial ●",
|
||||
func(c): _on_facial_hair_tint_changed(c))
|
||||
_facial_hair_tint_swatch = _make_color_swatch(
|
||||
_descriptor.hair_tint, "Facial ●", func(c): _on_facial_hair_tint_changed(c)
|
||||
)
|
||||
row.add_child(_facial_hair_tint_swatch)
|
||||
|
||||
return dock
|
||||
@@ -743,6 +849,7 @@ func _on_facial_hair_tint_changed(color: Color) -> void:
|
||||
# Tab: Clothing (Task #5)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_clothing_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -851,23 +958,25 @@ func _rebuild_clothing_color_dock(container: Control) -> void:
|
||||
container.add_child(row)
|
||||
|
||||
var item_id: String = str(_descriptor.clothing_slots.get(_active_clothing_slot, ""))
|
||||
var tints: Array = _descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else [] as Array
|
||||
var tints: Array = (
|
||||
_descriptor.clothing_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||||
as Array
|
||||
)
|
||||
var primary: Color = tints[0] as Color if tints.size() > 0 else Color(0.7, 0.65, 0.6)
|
||||
var secondary: Color = tints[1] as Color if tints.size() > 1 else _derive_secondary(primary)
|
||||
var accent: Color = tints[2] as Color if tints.size() > 2 else _derive_accent(primary)
|
||||
|
||||
var p_swatch := _make_color_swatch(primary, "Primary",
|
||||
func(c): _on_clothing_primary_changed(c))
|
||||
var p_swatch := _make_color_swatch(primary, "Primary", func(c): _on_clothing_primary_changed(c))
|
||||
row.add_child(p_swatch)
|
||||
_clothing_primary_swatches[_active_clothing_slot] = p_swatch
|
||||
|
||||
var s_swatch := _make_color_swatch(secondary, "Secondary ●",
|
||||
func(c): _on_clothing_secondary_changed(c))
|
||||
var s_swatch := _make_color_swatch(
|
||||
secondary, "Secondary ●", func(c): _on_clothing_secondary_changed(c)
|
||||
)
|
||||
row.add_child(s_swatch)
|
||||
_clothing_secondary_swatches[_active_clothing_slot] = s_swatch
|
||||
|
||||
var a_swatch := _make_color_swatch(accent, "Accent ●",
|
||||
func(c): _on_clothing_accent_changed(c))
|
||||
var a_swatch := _make_color_swatch(accent, "Accent ●", func(c): _on_clothing_accent_changed(c))
|
||||
row.add_child(a_swatch)
|
||||
_clothing_accent_swatches[_active_clothing_slot] = a_swatch
|
||||
|
||||
@@ -902,7 +1011,9 @@ func _on_clothing_primary_changed(color: Color) -> void:
|
||||
tints[0] = color
|
||||
_descriptor.clothing_tints[item_id] = tints
|
||||
if _clothing_secondary_auto.get(_active_clothing_slot, true):
|
||||
_set_swatch_color(_clothing_secondary_swatches.get(_active_clothing_slot), _derive_secondary(color))
|
||||
_set_swatch_color(
|
||||
_clothing_secondary_swatches.get(_active_clothing_slot), _derive_secondary(color)
|
||||
)
|
||||
_refresh_preview()
|
||||
|
||||
|
||||
@@ -953,6 +1064,7 @@ func _update_clothing_item_btns() -> void:
|
||||
# Tab: Accessories (Task #6)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_accessories_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -1032,17 +1144,22 @@ func _rebuild_accessory_color_dock(container: Control) -> void:
|
||||
container.add_child(row)
|
||||
|
||||
var item_id: String = str(_descriptor.accessory_slots.get(_active_accessory_slot, ""))
|
||||
var tints_arr: Array = _descriptor.accessory_tints.get(item_id, []) as Array if not item_id.is_empty() else [] as Array
|
||||
var tints_arr: Array = (
|
||||
_descriptor.accessory_tints.get(item_id, []) as Array if not item_id.is_empty() else []
|
||||
as Array
|
||||
)
|
||||
var primary: Color = tints_arr[0] as Color if not tints_arr.is_empty() else Color.WHITE
|
||||
var secondary: Color = _derive_secondary(primary)
|
||||
|
||||
var p_swatch := _make_color_swatch(primary, "Primary",
|
||||
func(c): _on_accessory_primary_changed(c))
|
||||
var p_swatch := _make_color_swatch(
|
||||
primary, "Primary", func(c): _on_accessory_primary_changed(c)
|
||||
)
|
||||
row.add_child(p_swatch)
|
||||
_accessory_primary_swatches[_active_accessory_slot] = p_swatch
|
||||
|
||||
var s_swatch := _make_color_swatch(secondary, "Secondary ●",
|
||||
func(c): _on_accessory_secondary_changed(c))
|
||||
var s_swatch := _make_color_swatch(
|
||||
secondary, "Secondary ●", func(c): _on_accessory_secondary_changed(c)
|
||||
)
|
||||
row.add_child(s_swatch)
|
||||
_accessory_secondary_swatches[_active_accessory_slot] = s_swatch
|
||||
|
||||
@@ -1103,6 +1220,7 @@ func _update_accessory_item_btns() -> void:
|
||||
# Screenshot & automated testing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _schedule_screenshot() -> void:
|
||||
_screenshot_pending = true
|
||||
_screenshot_frame_count = 0
|
||||
@@ -1125,10 +1243,10 @@ func _take_screenshot(suffix: String = "") -> void:
|
||||
_char_visual.set_facing(CARDINAL_DIRS[_screenshot_cardinal_idx])
|
||||
suffix = dir_name
|
||||
|
||||
var filename := "charcreator_%s_%s.png" % [
|
||||
_descriptor.body_type_key(),
|
||||
suffix if not suffix.is_empty() else "default"
|
||||
]
|
||||
var filename := (
|
||||
"charcreator_%s_%s.png"
|
||||
% [_descriptor.body_type_key(), suffix if not suffix.is_empty() else "default"]
|
||||
)
|
||||
var path := SCREENSHOT_DIR + filename
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
img.save_png(path)
|
||||
@@ -1212,6 +1330,7 @@ func _load_test_config() -> void:
|
||||
# Debug tab — segment visibility toggles
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_debug_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -1308,6 +1427,7 @@ func _on_debug_toggle_outlines(pressed: bool) -> void:
|
||||
# Color picker modal (Task #7) — D-165 palette
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_color_picker_modal() -> void:
|
||||
var modal := _modal_root
|
||||
|
||||
@@ -1496,6 +1616,7 @@ func _update_modal_recent_btns() -> void:
|
||||
# Input handling (Task #8 keyboard nav)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -1547,7 +1668,9 @@ func _input(event: InputEvent) -> void:
|
||||
KEY_TAB:
|
||||
var count := _tab_container.get_tab_count()
|
||||
if key.shift_pressed:
|
||||
_tab_container.current_tab = ((_tab_container.current_tab - 1) % count + count) % count
|
||||
_tab_container.current_tab = (
|
||||
((_tab_container.current_tab - 1) % count + count) % count
|
||||
)
|
||||
else:
|
||||
_tab_container.current_tab = (_tab_container.current_tab + 1) % count
|
||||
get_viewport().set_input_as_handled()
|
||||
@@ -1557,6 +1680,7 @@ func _input(event: InputEvent) -> void:
|
||||
# Game flow (Task #8)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_back() -> void:
|
||||
creation_cancelled.emit()
|
||||
|
||||
@@ -1569,6 +1693,7 @@ func _on_start() -> void:
|
||||
# Randomize
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _on_randomize() -> void:
|
||||
# Body type — pick from manifest only
|
||||
var available_types: Array = _manifest_array("body_types")
|
||||
@@ -1595,7 +1720,9 @@ func _on_randomize() -> void:
|
||||
_descriptor.facial_hair_id = ""
|
||||
|
||||
# Eye color — random natural tones
|
||||
_descriptor.eye_color = Color.from_hsv(randf() * 0.15 + 0.05, 0.3 + randf() * 0.5, 0.2 + randf() * 0.5)
|
||||
_descriptor.eye_color = Color.from_hsv(
|
||||
randf() * 0.15 + 0.05, 0.3 + randf() * 0.5, 0.2 + randf() * 0.5
|
||||
)
|
||||
|
||||
# Sync auto tints
|
||||
_eyebrow_tint_auto = true
|
||||
@@ -1615,6 +1742,7 @@ func _on_randomize() -> void:
|
||||
# Shared helpers — skin tone dock
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _build_skin_tone_dock() -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
@@ -1659,6 +1787,7 @@ func _update_skin_tone_btns() -> void:
|
||||
# Shared helpers — color swatches
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Get the current Color displayed in a swatch button (VBoxContainer → ColorRect).
|
||||
func _get_swatch_color(btn: Button) -> Color:
|
||||
for child in btn.get_children():
|
||||
@@ -1698,9 +1827,7 @@ func _make_color_swatch(color: Color, label_text: String, callback: Callable) ->
|
||||
vbox.add_child(lbl)
|
||||
|
||||
# Use _get_swatch_color to read current color at click time (avoids stale closure).
|
||||
btn.pressed.connect(func():
|
||||
_open_color_picker(_get_swatch_color(btn), btn, callback)
|
||||
)
|
||||
btn.pressed.connect(func(): _open_color_picker(_get_swatch_color(btn), btn, callback))
|
||||
return btn
|
||||
|
||||
|
||||
@@ -1754,6 +1881,7 @@ func _set_swatch_color(btn: Button, color: Color) -> void:
|
||||
# Shared helpers — layout builders
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _make_tab_vbox(tab: Control) -> VBoxContainer:
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -1772,15 +1900,16 @@ func _make_search_bar(tab_idx: int) -> LineEdit:
|
||||
search.placeholder_text = UIStrings.get_text("character_creation.search_placeholder")
|
||||
search.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
search.add_theme_font_size_override("font_size", 12)
|
||||
search.text_changed.connect(func(q: String):
|
||||
_tab_search[tab_idx] = q
|
||||
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
|
||||
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
|
||||
_apply_search_filter(_clothing_grids[_active_clothing_slot], q)
|
||||
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
|
||||
_apply_search_filter(_accessory_grids[_active_accessory_slot], q)
|
||||
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
|
||||
_apply_search_filter(_tab_grids[tab_idx], q)
|
||||
search.text_changed.connect(
|
||||
func(q: String):
|
||||
_tab_search[tab_idx] = q
|
||||
# Tabs 3/4 filter the active slot's grid; others use _tab_grids by index
|
||||
if tab_idx == 3 and _clothing_grids.has(_active_clothing_slot):
|
||||
_apply_search_filter(_clothing_grids[_active_clothing_slot], q)
|
||||
elif tab_idx == 4 and _accessory_grids.has(_active_accessory_slot):
|
||||
_apply_search_filter(_accessory_grids[_active_accessory_slot], q)
|
||||
elif tab_idx < _tab_grids.size() and _tab_grids[tab_idx] != null:
|
||||
_apply_search_filter(_tab_grids[tab_idx], q)
|
||||
)
|
||||
return search
|
||||
|
||||
@@ -1849,7 +1978,9 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void:
|
||||
var lower_q := query.to_lower()
|
||||
for child in grid.get_children():
|
||||
if child is Button:
|
||||
child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||||
child.visible = (
|
||||
lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||||
)
|
||||
|
||||
|
||||
func _get_clothing_ids_for_slot(slot: String) -> Array:
|
||||
@@ -1872,21 +2003,31 @@ func _get_accessory_ids_for_slot(slot: String) -> Array:
|
||||
# Accessory items from manifest, filtered by slot name prefix convention.
|
||||
var all_ids: Array = _manifest_array("accessories")
|
||||
match slot:
|
||||
"hat": return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
|
||||
"goggles": return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
|
||||
"mask": return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
|
||||
"backpack": return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
|
||||
"belt": return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
|
||||
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
|
||||
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
|
||||
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
|
||||
_: return all_ids # gdlint:ignore = max-returns
|
||||
"hat":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("hat"))
|
||||
"goggles":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("goggle"))
|
||||
"mask":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("mask"))
|
||||
"backpack":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("backpack"))
|
||||
"belt":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("belt"))
|
||||
"wrist_l", "wrist_r":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
|
||||
"earring_l", "earring_r":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
|
||||
"necklace":
|
||||
return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
|
||||
_:
|
||||
return all_ids # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Color derivation helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _derive_hair_highlight(primary: Color) -> Color:
|
||||
return primary.lightened(0.3)
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ extends Control
|
||||
const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
|
||||
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
|
||||
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending
|
||||
const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary
|
||||
const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met
|
||||
const FONT_SIZE := 11
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(8, 6)
|
||||
@@ -79,13 +79,17 @@ func _draw() -> void:
|
||||
var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT
|
||||
|
||||
# Calculate box width from longest line
|
||||
var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
var max_width: float = (
|
||||
font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
for r in results:
|
||||
var desc: String = r.get("description", r.get("id", ""))
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var prefix: String = "[x] " if r.get("met", false) else "[ ] "
|
||||
var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
var line_width: float = (
|
||||
font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
if line_width > max_width:
|
||||
max_width = line_width
|
||||
|
||||
@@ -96,7 +100,15 @@ func _draw() -> void:
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
header_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
header_color
|
||||
)
|
||||
|
||||
# Condition lines
|
||||
for r in results:
|
||||
@@ -107,11 +119,20 @@ func _draw() -> void:
|
||||
if desc.length() > MAX_DESC_CHARS:
|
||||
desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..."
|
||||
var color: Color = MET_COLOR if is_met else UNMET_COLOR
|
||||
draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
prefix + desc,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
color
|
||||
)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_evaluator():
|
||||
return _evaluator
|
||||
|
||||
|
||||
+34
-19
@@ -7,7 +7,7 @@ extends Control
|
||||
## Settings-toggled; enabled state persisted in user://settings.cfg.
|
||||
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
|
||||
|
||||
signal pause_requested # D-088: pause sim while console is open
|
||||
signal pause_requested # D-088: pause sim while console is open
|
||||
signal unpause_requested # D-088: unpause sim when console closes
|
||||
|
||||
const PREFS_PATH := "user://settings.cfg"
|
||||
@@ -97,6 +97,7 @@ func _update_panel_layout() -> void:
|
||||
|
||||
# -- Input handling --
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
@@ -155,6 +156,7 @@ func is_open() -> bool:
|
||||
|
||||
# -- Command input --
|
||||
|
||||
|
||||
func _on_input_submitted(text: String) -> void:
|
||||
var trimmed := text.strip_edges()
|
||||
_input_line.clear()
|
||||
@@ -199,7 +201,9 @@ func _dispatch(line: String) -> void:
|
||||
z = int(parts[3])
|
||||
else:
|
||||
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
|
||||
_send_debug({"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}})
|
||||
_send_debug(
|
||||
{"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}
|
||||
)
|
||||
else:
|
||||
var loc := " ".join(PackedStringArray(parts.slice(1)))
|
||||
_send_debug({"TeleportToLocation": loc})
|
||||
@@ -226,17 +230,23 @@ func _dispatch(line: String) -> void:
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.DEBUG_COMMAND,
|
||||
"action_data": kind,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
var err := (
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.DEBUG_COMMAND,
|
||||
"action_data": kind,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
if err != OK:
|
||||
_append_text("send error: %s" % error_string(err), ERROR_COLOR)
|
||||
|
||||
|
||||
# -- Response display --
|
||||
|
||||
|
||||
## Append a server debug response to the output log. Auto-opens console if closed
|
||||
## (only if console is enabled — respect user's settings toggle).
|
||||
func append_response(response: Dictionary) -> void:
|
||||
@@ -250,6 +260,7 @@ func append_response(response: Dictionary) -> void:
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
|
||||
func _append_text(text: String, color: Color) -> void:
|
||||
var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped])
|
||||
@@ -261,24 +272,27 @@ func _append_text(text: String, color: Color) -> void:
|
||||
|
||||
func _print_help() -> void:
|
||||
_append_text(
|
||||
"Commands:\n"
|
||||
+ " ticks <n> — fast-forward N ticks\n"
|
||||
+ " contaminate — skip to contamination phase\n"
|
||||
+ " tp <x> <y> [z] — teleport to tile position\n"
|
||||
+ " tp <location> — teleport to named location\n"
|
||||
+ " activate — force contamination activate\n"
|
||||
+ " triangle <id> — force triangle activation\n"
|
||||
+ " npc <entity_id> — inspect NPC state\n"
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ " help — this list",
|
||||
(
|
||||
"Commands:\n"
|
||||
+ " ticks <n> — fast-forward N ticks\n"
|
||||
+ " contaminate — skip to contamination phase\n"
|
||||
+ " tp <x> <y> [z] — teleport to tile position\n"
|
||||
+ " tp <location> — teleport to named location\n"
|
||||
+ " activate — force contamination activate\n"
|
||||
+ " triangle <id> — force triangle activation\n"
|
||||
+ " npc <entity_id> — inspect NPC state\n"
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ " help — this list"
|
||||
),
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# -- Command history --
|
||||
|
||||
|
||||
func _history_up() -> void:
|
||||
if _history.is_empty():
|
||||
return
|
||||
@@ -299,6 +313,7 @@ func _history_down() -> void:
|
||||
|
||||
# -- Settings --
|
||||
|
||||
|
||||
func set_enabled(enabled: bool) -> void:
|
||||
_enabled = enabled
|
||||
if not _enabled and _open:
|
||||
|
||||
+143
-43
@@ -37,7 +37,7 @@ const GRAPH_WARN_COLOR := Color("#e8c547")
|
||||
# Vision cone geometry (radians)
|
||||
# Forward: ±60° around facing direction (120° total)
|
||||
# Peripheral: ±60° to ±120° on each side (60° band each side)
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_FORWARD_HALF: float = PI / 3.0 # 60°
|
||||
const CONE_PERIPHERAL_HALF: float = PI * 2.0 / 3.0 # 120°
|
||||
const CONE_ARC_STEPS: int = 20
|
||||
|
||||
@@ -65,14 +65,14 @@ var _npc_paths: Dictionary = {}
|
||||
var _last_tick_processed: int = -1
|
||||
|
||||
# Tick timing ring
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_times: Array = [] # Time.get_ticks_msec() on each snapshot
|
||||
var _tick_deltas: Array = [] # ms between consecutive snapshots
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_dev_mode = OS.is_debug_build()
|
||||
visible = false
|
||||
@@ -147,6 +147,7 @@ func _update_npc_paths() -> void:
|
||||
# Draw dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
@@ -159,6 +160,7 @@ func _draw() -> void:
|
||||
# Panel 1: Stats text (top-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw_stats_panel() -> void:
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
@@ -173,7 +175,9 @@ func _draw_stats_panel() -> void:
|
||||
right_lines.append(["facing", GameState.player_facing])
|
||||
|
||||
left_lines.append(["stance", GameState.player_stance])
|
||||
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
|
||||
right_lines.append(
|
||||
["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"]
|
||||
)
|
||||
|
||||
left_lines.append(["entities", str(GameState.visible_entities.size())])
|
||||
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
|
||||
@@ -190,7 +194,9 @@ func _draw_stats_panel() -> void:
|
||||
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
|
||||
|
||||
var gt := GameState.game_time
|
||||
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
var time_str := (
|
||||
"%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
)
|
||||
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
|
||||
left_lines.append(["time", time_str])
|
||||
right_lines.append(["tick_rate", rate_str])
|
||||
@@ -210,14 +216,26 @@ func _draw_stats_panel() -> void:
|
||||
var right_value_w: float = 0.0
|
||||
|
||||
for line in left_lines:
|
||||
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_label_w = max(
|
||||
left_label_w,
|
||||
font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
left_value_w = max(
|
||||
left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
for line in right_lines:
|
||||
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_label_w = max(
|
||||
right_label_w,
|
||||
font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
right_value_w = max(
|
||||
right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x
|
||||
)
|
||||
|
||||
var header_text := "F3 DEBUG"
|
||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
var header_w := (
|
||||
font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
)
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
@@ -226,21 +244,57 @@ func _draw_stats_panel() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
header_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE + 1,
|
||||
HEADER_COLOR
|
||||
)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
draw_string(font, Vector2(PADDING.x, y), left_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y),
|
||||
left_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
LABEL_COLOR
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x + left_label_w, y),
|
||||
left_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
VALUE_COLOR
|
||||
)
|
||||
if i < right_lines.size():
|
||||
draw_string(font, Vector2(right_x, y), right_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(right_x, y),
|
||||
right_lines[i][0] + ": ",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
LABEL_COLOR
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(right_x + right_label_w, y),
|
||||
right_lines[i][1],
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
VALUE_COLOR
|
||||
)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
|
||||
@@ -248,6 +302,7 @@ func _draw_stats_panel() -> void:
|
||||
# Panel 2: World overlays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw_world_overlays() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
@@ -291,9 +346,15 @@ func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
var perip_r_from := facing_angle + CONE_FORWARD_HALF
|
||||
var perip_r_to := facing_angle + CONE_PERIPHERAL_HALF
|
||||
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR)
|
||||
draw_colored_polygon(
|
||||
_arc_polygon(player_screen, r, forward_from, forward_to), CONE_FORWARD_COLOR
|
||||
)
|
||||
draw_colored_polygon(
|
||||
_arc_polygon(player_screen, r, perip_l_from, perip_l_to), CONE_PERIPHERAL_COLOR
|
||||
)
|
||||
draw_colored_polygon(
|
||||
_arc_polygon(player_screen, r, perip_r_from, perip_r_to), CONE_PERIPHERAL_COLOR
|
||||
)
|
||||
|
||||
# Forward arc boundary ring
|
||||
draw_arc(player_screen, r, forward_from, forward_to, CONE_ARC_STEPS, CONE_RING_COLOR, 1.0)
|
||||
@@ -303,7 +364,9 @@ func _draw_vision_cone(player_screen: Vector2, canvas_xf: Transform2D) -> void:
|
||||
|
||||
|
||||
# Build a filled polygon fan from center through an arc
|
||||
func _arc_polygon(center: Vector2, radius: float, angle_from: float, angle_to: float) -> PackedVector2Array:
|
||||
func _arc_polygon(
|
||||
center: Vector2, radius: float, angle_from: float, angle_to: float
|
||||
) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
pts.append(center)
|
||||
for i in range(CONE_ARC_STEPS + 1):
|
||||
@@ -339,8 +402,14 @@ func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
var a_screen := _w2s(path[i - 1], canvas_xf)
|
||||
var b_screen := _w2s(path[i], canvas_xf)
|
||||
var alpha := float(i) / float(path.size())
|
||||
draw_line(a_screen, b_screen,
|
||||
Color(NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha), 1.5)
|
||||
draw_line(
|
||||
a_screen,
|
||||
b_screen,
|
||||
Color(
|
||||
NPC_PATH_COLOR.r, NPC_PATH_COLOR.g, NPC_PATH_COLOR.b, NPC_PATH_COLOR.a * alpha
|
||||
),
|
||||
1.5
|
||||
)
|
||||
draw_circle(_w2s(path.back(), canvas_xf), NPC_DOT_RADIUS, NPC_DOT_COLOR)
|
||||
|
||||
|
||||
@@ -377,17 +446,25 @@ func _draw_info_tags(canvas_xf: Transform2D) -> void:
|
||||
entity_screen.x - text_w / 2.0 - 3.0,
|
||||
tag_baseline - FONT_SIZE + 2.0,
|
||||
text_w + 6.0,
|
||||
FONT_SIZE)
|
||||
FONT_SIZE
|
||||
)
|
||||
draw_rect(tag_rect, TAG_BG_COLOR)
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(entity_screen.x - text_w / 2.0, tag_baseline),
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, TAG_TEXT_COLOR)
|
||||
label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE - 1,
|
||||
TAG_TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panel 3: Tick timing sparkline (bottom-left)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _draw_tick_graph() -> void:
|
||||
if _tick_deltas.size() < 2:
|
||||
return
|
||||
@@ -398,10 +475,15 @@ func _draw_tick_graph() -> void:
|
||||
var box_y := vp_size.y - GRAPH_H - label_h - GRAPH_MARGIN
|
||||
|
||||
draw_rect(Rect2(box_x, box_y, GRAPH_W, GRAPH_H + label_h), GRAPH_BG_COLOR)
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(box_x + 4, box_y + FONT_SIZE + 1),
|
||||
"tick ms (n=%d)" % _tick_deltas.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE - 1,
|
||||
LABEL_COLOR
|
||||
)
|
||||
|
||||
var chart_top := box_y + label_h
|
||||
var chart_left := box_x + 4.0
|
||||
@@ -418,9 +500,12 @@ func _draw_tick_graph() -> void:
|
||||
# Warn threshold dashed line
|
||||
var warn_y := chart_top + chart_h * (1.0 - TICK_WARN_MS / max_ms)
|
||||
draw_dashed_line(
|
||||
Vector2(chart_left, warn_y), Vector2(chart_left + chart_w, warn_y),
|
||||
Vector2(chart_left, warn_y),
|
||||
Vector2(chart_left + chart_w, warn_y),
|
||||
Color(GRAPH_WARN_COLOR.r, GRAPH_WARN_COLOR.g, GRAPH_WARN_COLOR.b, 0.3),
|
||||
1.0, 4.0)
|
||||
1.0,
|
||||
4.0
|
||||
)
|
||||
|
||||
# Sparkline
|
||||
var n := _tick_deltas.size()
|
||||
@@ -441,25 +526,40 @@ func _draw_tick_graph() -> void:
|
||||
for d in _tick_deltas:
|
||||
avg_ms += float(d)
|
||||
avg_ms /= float(_tick_deltas.size())
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(chart_left + chart_w - 54.0, chart_top + chart_h + FONT_SIZE - 2),
|
||||
"avg %.0fms" % avg_ms,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE - 1, LABEL_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE - 1,
|
||||
LABEL_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Convert facing string to angle in radians (Godot 2D: 0=East, -PI/2=North)
|
||||
static func _facing_to_angle(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return -PI / 2.0
|
||||
"Northeast": return -PI / 4.0
|
||||
"East": return 0.0
|
||||
"Southeast": return PI / 4.0
|
||||
"South": return PI / 2.0
|
||||
"Southwest": return PI * 3.0 / 4.0
|
||||
"West": return PI
|
||||
"Northwest": return -PI * 3.0 / 4.0
|
||||
_: return -PI / 2.0
|
||||
"North":
|
||||
return -PI / 2.0
|
||||
"Northeast":
|
||||
return -PI / 4.0
|
||||
"East":
|
||||
return 0.0
|
||||
"Southeast":
|
||||
return PI / 4.0
|
||||
"South":
|
||||
return PI / 2.0
|
||||
"Southwest":
|
||||
return PI * 3.0 / 4.0
|
||||
"West":
|
||||
return PI
|
||||
"Northwest":
|
||||
return -PI * 3.0 / 4.0
|
||||
_:
|
||||
return -PI / 2.0
|
||||
|
||||
+87
-40
@@ -13,7 +13,7 @@ extends Control
|
||||
signal option_selected(response_id: String, text: String)
|
||||
signal dialogue_dismissed # Walk-away or conversation end
|
||||
signal confrontation_monologue(text: String, duration: float) # D-063: beat monologue
|
||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||
signal pause_requested # D-061: auto-pause — main.gd routes through input recording (#507)
|
||||
signal unpause_requested # D-061: auto-unpause
|
||||
# D-020 (#558): Decoupled signals — dialogue_box emits, main.gd (coordinator) handles.
|
||||
# Replaces direct GameState.dialogue_active mutation and AudioManager calls.
|
||||
@@ -25,8 +25,8 @@ const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||
|
||||
const FADE_IN: float = 0.2
|
||||
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
|
||||
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
|
||||
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||
@@ -38,8 +38,14 @@ const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness a
|
||||
|
||||
# D-064: movement actions that trigger walk-away
|
||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||
&"move_north", &"move_south", &"move_east", &"move_west",
|
||||
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest",
|
||||
&"move_north",
|
||||
&"move_south",
|
||||
&"move_east",
|
||||
&"move_west",
|
||||
&"move_northeast",
|
||||
&"move_southeast",
|
||||
&"move_southwest",
|
||||
&"move_northwest",
|
||||
]
|
||||
|
||||
# -- Log state --
|
||||
@@ -54,7 +60,6 @@ var _log_dirty: bool = false # Dirty flag — prevents per-frame O(n) BBCode re
|
||||
# Populated from server events; drives retroactive re-render when NPC names resolve.
|
||||
var _entity_display: Dictionary = {}
|
||||
|
||||
|
||||
# -- Option state --
|
||||
var _option_controls: Array[Control] = []
|
||||
var _option_response_ids: Array[String] = []
|
||||
@@ -68,8 +73,8 @@ var _npc_name: String = ""
|
||||
# v0.1: colors are per-conversation — cleared in _end_player_conversation() to avoid
|
||||
# palette exhaustion (8 entries) across long sessions with 9+ NPCs.
|
||||
var _npc_entity_colors: Dictionary = {} # entity_id -> Color
|
||||
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
|
||||
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
|
||||
var _npc_entity_id: int = -1 # Entity ID of the current player conversation NPC
|
||||
var _next_npc_color: int = 0 # Round-robin palette index for client-side assignment
|
||||
|
||||
# -- UI state --
|
||||
var _active_tween: Tween = null
|
||||
@@ -86,7 +91,8 @@ var _entry_fade: float = 5.0
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
@onready
|
||||
var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -114,15 +120,17 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# Number keys 1-3 select dialogue options
|
||||
if event is InputEventKey and event.pressed:
|
||||
var key_index := -1
|
||||
if event.keycode == KEY_1: key_index = 0
|
||||
elif event.keycode == KEY_2: key_index = 1
|
||||
elif event.keycode == KEY_3: key_index = 2
|
||||
if event.keycode == KEY_1:
|
||||
key_index = 0
|
||||
elif event.keycode == KEY_2:
|
||||
key_index = 1
|
||||
elif event.keycode == KEY_3:
|
||||
key_index = 2
|
||||
if key_index >= 0 and key_index < _option_controls.size():
|
||||
get_viewport().set_input_as_handled()
|
||||
_on_option_pressed(key_index)
|
||||
return
|
||||
|
||||
|
||||
# D-064: WASD during active player dialogue → walk-away
|
||||
if event is InputEventKey and event.pressed:
|
||||
for action in _WALK_AWAY_ACTIONS:
|
||||
@@ -136,11 +144,18 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
# -- Theme loading --
|
||||
|
||||
|
||||
func _load_theme() -> void:
|
||||
# Default NPC palette in case file not found
|
||||
_npc_colors = [
|
||||
Color("#4a9ebb"), Color("#6bc9a6"), Color("#e8c547"), Color("#d49e5d"),
|
||||
Color("#b586d4"), Color("#d45d5d"), Color("#5daa7d"), Color("#7daccc"),
|
||||
Color("#4a9ebb"),
|
||||
Color("#6bc9a6"),
|
||||
Color("#e8c547"),
|
||||
Color("#d49e5d"),
|
||||
Color("#b586d4"),
|
||||
Color("#d45d5d"),
|
||||
Color("#5daa7d"),
|
||||
Color("#7daccc"),
|
||||
]
|
||||
|
||||
if not FileAccess.file_exists(THEME_PATH):
|
||||
@@ -180,6 +195,7 @@ func _load_theme() -> void:
|
||||
|
||||
# -- Responsive layout --
|
||||
|
||||
|
||||
func _update_layout() -> void:
|
||||
var vp := get_viewport_rect().size
|
||||
var max_h := vp.y * MAX_HEIGHT_RATIO
|
||||
@@ -191,14 +207,21 @@ func _update_layout() -> void:
|
||||
|
||||
# -- Log entry management --
|
||||
|
||||
|
||||
## Append a dialogue line to the log.
|
||||
## speaker/target: display names. text: the spoken line.
|
||||
## is_passive: true for overheard NPC-NPC (renders with ┃ prefix + desaturated).
|
||||
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
|
||||
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
|
||||
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
|
||||
func append_line(speaker: String, target: String, text: String,
|
||||
is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
|
||||
func append_line(
|
||||
speaker: String,
|
||||
target: String,
|
||||
text: String,
|
||||
is_passive: bool = false,
|
||||
speaker_entity_id: int = -1,
|
||||
target_entity_id: int = -1
|
||||
) -> void:
|
||||
var pinned := not is_passive and _in_player_conversation
|
||||
var entry: Dictionary = {
|
||||
"speaker": speaker,
|
||||
@@ -294,10 +317,13 @@ func append_dialogue_response(npc_name: String, text: String, entity_id: int = -
|
||||
|
||||
# -- Active player conversation --
|
||||
|
||||
|
||||
## Show dialogue with NPC speech and response options.
|
||||
## npc_name: who is speaking. speech: the NPC's line. options: player choices.
|
||||
## npc_entity_id: entity ID of the NPC for stable color assignment (#573).
|
||||
func show_dialogue(npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1) -> void:
|
||||
func show_dialogue(
|
||||
npc_name: String, speech: String, options: Array = [], npc_entity_id: int = -1
|
||||
) -> void:
|
||||
_npc_name = npc_name
|
||||
_npc_entity_id = npc_entity_id
|
||||
_cancel_beat()
|
||||
@@ -379,6 +405,7 @@ func has_active_entries() -> bool:
|
||||
|
||||
# -- Options --
|
||||
|
||||
|
||||
func _show_options(options: Array) -> void:
|
||||
var sorted_opts: Array = options.duplicate()
|
||||
sorted_opts.sort_custom(func(a, b): return a.get("priority", 0) < b.get("priority", 0))
|
||||
@@ -398,12 +425,15 @@ func _show_options(options: Array) -> void:
|
||||
rtl.fit_content = true
|
||||
rtl.scroll_active = false
|
||||
rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
rtl.add_theme_color_override("default_color", Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)) # Slight warm tint for confrontation weight
|
||||
rtl.add_theme_color_override(
|
||||
"default_color",
|
||||
Color(
|
||||
Constants.INSERT_COLOR_TEXT.r * 1.08,
|
||||
Constants.INSERT_COLOR_TEXT.g * 0.96,
|
||||
Constants.INSERT_COLOR_TEXT.b * 0.90,
|
||||
1.0
|
||||
)
|
||||
) # Slight warm tint for confrontation weight
|
||||
rtl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
rtl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
rtl.text = "[i]%d. %s[/i]" % [i + 1, raw_text]
|
||||
@@ -419,9 +449,14 @@ func _show_options(options: Array) -> void:
|
||||
ctrl = label
|
||||
|
||||
var idx := i
|
||||
ctrl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_option_pressed(idx)
|
||||
ctrl.gui_input.connect(
|
||||
func(event: InputEvent):
|
||||
if (
|
||||
event is InputEventMouseButton
|
||||
and event.pressed
|
||||
and event.button_index == MOUSE_BUTTON_LEFT
|
||||
):
|
||||
_on_option_pressed(idx)
|
||||
)
|
||||
ctrl.mouse_entered.connect(_make_hover_on(ctrl))
|
||||
ctrl.mouse_exited.connect(_make_hover_off(ctrl))
|
||||
@@ -438,7 +473,9 @@ func _on_option_pressed(index: int) -> void:
|
||||
return
|
||||
var rid: String = _option_response_ids[index] if index < _option_response_ids.size() else ""
|
||||
var text: String = _option_texts[index] if index < _option_texts.size() else ""
|
||||
var is_confront: bool = _option_is_confrontation[index] if index < _option_is_confrontation.size() else false
|
||||
var is_confront: bool = (
|
||||
_option_is_confrontation[index] if index < _option_is_confrontation.size() else false
|
||||
)
|
||||
|
||||
# Append player's chosen response to the log
|
||||
append_player_line(_npc_name, text)
|
||||
@@ -452,6 +489,7 @@ func _on_option_pressed(index: int) -> void:
|
||||
|
||||
# -- Confrontation beat (D-063) --
|
||||
|
||||
|
||||
func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
for ctrl in _option_controls:
|
||||
if is_instance_valid(ctrl):
|
||||
@@ -462,15 +500,18 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
confrontation_monologue.emit(UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION)
|
||||
confrontation_monologue.emit(
|
||||
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
|
||||
)
|
||||
audio_dip_requested.emit("confrontation") # D-069: coordinator routes to AudioManager
|
||||
|
||||
_beat_tween = create_tween()
|
||||
_beat_tween.tween_interval(CONFRONTATION_BEAT_DURATION)
|
||||
_beat_tween.tween_callback(func():
|
||||
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||
option_selected.emit(response_id, text)
|
||||
_end_player_conversation()
|
||||
_beat_tween.tween_callback(
|
||||
func():
|
||||
audio_dip_cleared.emit() # D-069: coordinator routes to AudioManager
|
||||
option_selected.emit(response_id, text)
|
||||
_end_player_conversation()
|
||||
)
|
||||
|
||||
|
||||
@@ -483,6 +524,7 @@ func _cancel_beat() -> void:
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
|
||||
## Rebuild the dialogue log BBCode from all non-expired entries.
|
||||
func _rebuild_log() -> void:
|
||||
if not dialogue_log:
|
||||
@@ -550,7 +592,9 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
target_color = _npc_entity_colors[tg_eid]
|
||||
else:
|
||||
target_color = _color_for_name(entry.get("target", "?"))
|
||||
involves_player = (entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
|
||||
involves_player = (
|
||||
(entry.get("speaker", "") == PLAYER_NAME) or (entry.get("target", "") == PLAYER_NAME)
|
||||
)
|
||||
|
||||
var text: String = _escape_bbcode(entry.text)
|
||||
var is_passive: bool = entry.is_passive
|
||||
@@ -570,13 +614,14 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
# Non-blocking: simplify 1-on-1 player dialogue — no arrow for Speaker → You or You → Speaker
|
||||
if involves_player and not is_passive:
|
||||
# Just "Speaker: text" or "You: text"
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, txc, text
|
||||
]
|
||||
return (
|
||||
"%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [prefix, sc, speaker, txc, text]
|
||||
)
|
||||
# Full "Speaker → Target: text" for overheard
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, ac, tc, target, txc, text
|
||||
]
|
||||
return (
|
||||
"%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]"
|
||||
% [prefix, sc, speaker, ac, tc, target, txc, text]
|
||||
)
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
@@ -644,6 +689,7 @@ static func _color_with_alpha(base: Color, alpha: float) -> String:
|
||||
|
||||
# -- Entry expiry --
|
||||
|
||||
|
||||
## Remove fully expired entries. Mark dirty if any fading entries exist.
|
||||
## Pinned entries (active conversation) skip expiry entirely (Araminta #2).
|
||||
func _expire_entries() -> void:
|
||||
@@ -689,6 +735,7 @@ func _expire_entries() -> void:
|
||||
|
||||
# -- Visibility --
|
||||
|
||||
|
||||
## No-op — panel is always visible as a permanent insert UI element.
|
||||
func _ensure_visible() -> void:
|
||||
pass
|
||||
|
||||
@@ -9,7 +9,7 @@ extends Control
|
||||
## Positioned in InsertOverlay (CanvasLayer 10, z-layer 6).
|
||||
## Only one examine result is shown at a time — new result replaces old.
|
||||
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const DISMISS_DELAY: float = 5.0 # Auto-dismiss after 5 seconds
|
||||
const FADE_IN: float = 0.18
|
||||
const FADE_OUT: float = 0.35
|
||||
|
||||
@@ -27,6 +27,7 @@ var _active: bool = false
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
modulate.a = 0.0
|
||||
@@ -48,8 +49,12 @@ func show_result(result: Dictionary) -> void:
|
||||
|
||||
# Apply confidence-based alpha to the insert color
|
||||
var alpha: float = CONFIDENCE_ALPHA.get(confidence, 0.75)
|
||||
var col := Color(Constants.INSERT_COLOR_TEXT.r, Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b, alpha)
|
||||
var col := Color(
|
||||
Constants.INSERT_COLOR_TEXT.r,
|
||||
Constants.INSERT_COLOR_TEXT.g,
|
||||
Constants.INSERT_COLOR_TEXT.b,
|
||||
alpha
|
||||
)
|
||||
text_label.add_theme_color_override("default_color", col)
|
||||
text_label.text = text
|
||||
|
||||
@@ -69,9 +74,10 @@ func _start_fade_out() -> void:
|
||||
return
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func():
|
||||
visible = false
|
||||
_active = false
|
||||
t.tween_callback(
|
||||
func():
|
||||
visible = false
|
||||
_active = false
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ extends Control
|
||||
## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json.
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
|
||||
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
|
||||
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
|
||||
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
|
||||
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
|
||||
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
|
||||
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
|
||||
const FONT_SIZE := 13
|
||||
const PADDING := Vector2(10, 6)
|
||||
const STATS_PATH := "user://dev/gauntlet-stats.json"
|
||||
@@ -109,13 +109,28 @@ func _draw() -> void:
|
||||
# Timer text
|
||||
var y_offset := PADDING.y + text_size.y
|
||||
var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
|
||||
draw_string(font, Vector2(PADDING.x, y_offset), timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, TIMER_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, y_offset),
|
||||
timer_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
TIMER_COLOR
|
||||
)
|
||||
|
||||
# PB text (different color)
|
||||
if not pb_text.is_empty():
|
||||
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
|
||||
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x + timer_size.x, y_offset),
|
||||
pb_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
pb_color
|
||||
)
|
||||
|
||||
|
||||
static func _format_time(seconds: float) -> String:
|
||||
@@ -154,9 +169,15 @@ func print_session_summary() -> void:
|
||||
for room_id in _session_rooms:
|
||||
var entry: Dictionary = _session_rooms[room_id]
|
||||
var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--"
|
||||
var pb_str := _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
|
||||
print(" Room %s: %d attempts, session best %s, all-time PB %s" % [
|
||||
room_id, entry["attempts"], best_str, pb_str])
|
||||
var pb_str := (
|
||||
_format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
|
||||
)
|
||||
print(
|
||||
(
|
||||
" Room %s: %d attempts, session best %s, all-time PB %s"
|
||||
% [room_id, entry["attempts"], best_str, pb_str]
|
||||
)
|
||||
)
|
||||
print("================================")
|
||||
|
||||
|
||||
@@ -170,14 +191,18 @@ func finalize() -> void:
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_timer_seconds() -> float:
|
||||
return _timer_seconds
|
||||
|
||||
|
||||
func is_timer_running() -> bool:
|
||||
return _timer_running
|
||||
|
||||
|
||||
func get_current_room_id() -> Variant:
|
||||
return _current_room_id
|
||||
|
||||
|
||||
func get_personal_best(for_room_id: String) -> float:
|
||||
return _personal_bests.get(for_room_id, INF)
|
||||
|
||||
@@ -8,10 +8,10 @@ extends Node
|
||||
## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector").
|
||||
## Settings dialog reads classification strings and power-state methods to configure the UI.
|
||||
|
||||
const RAM_PASS_THRESHOLD_MB := 2000.0
|
||||
const RAM_PASS_THRESHOLD_MB := 2000.0
|
||||
const RAM_MARGINAL_THRESHOLD_MB := 1600.0
|
||||
const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently
|
||||
const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note
|
||||
const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently
|
||||
const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note
|
||||
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
|
||||
|
||||
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
|
||||
@@ -28,9 +28,9 @@ var inference_suspended: bool = false
|
||||
# User's preference at the moment battery suspension activated (to restore on plug-in).
|
||||
var _pre_battery_pref: bool = true
|
||||
|
||||
|
||||
# -- Lifecycle ----------------------------------------------------------------
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
assert(PlatformInfo != null, "PlatformInfo must load before HardwareDetector")
|
||||
load_ai_pref()
|
||||
@@ -40,6 +40,7 @@ func _ready() -> void:
|
||||
|
||||
# -- Layer 1: RAM classification ----------------------------------------------
|
||||
|
||||
|
||||
## Layer 1 — RAM classification.
|
||||
## Accepts free MB as input; returns "pass" | "marginal" | "fail".
|
||||
func classify_ram(free_mb: float) -> String:
|
||||
@@ -54,11 +55,15 @@ func classify_ram(free_mb: float) -> String:
|
||||
## Returns: {classification: String, free_mb: float}
|
||||
func check_ram() -> Dictionary:
|
||||
PlatformInfo.refresh_memory()
|
||||
return {"classification": classify_ram(PlatformInfo.free_memory_mb), "free_mb": PlatformInfo.free_memory_mb}
|
||||
return {
|
||||
"classification": classify_ram(PlatformInfo.free_memory_mb),
|
||||
"free_mb": PlatformInfo.free_memory_mb
|
||||
}
|
||||
|
||||
|
||||
# -- Layer 2: TPT benchmark classification ------------------------------------
|
||||
|
||||
|
||||
## Layer 2 — TPT benchmark classification.
|
||||
## Accepts tokens/sec as input; returns "green" | "yellow" | "red".
|
||||
func classify_tpt(tps: float) -> String:
|
||||
@@ -90,6 +95,7 @@ func read_benchmark_cache() -> Variant:
|
||||
|
||||
# -- Layer 3: degradation + battery -------------------------------------------
|
||||
|
||||
|
||||
## Layer 3 — Ongoing TPT degradation classification.
|
||||
## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable).
|
||||
## Returns "ok" | "yellow".
|
||||
@@ -130,6 +136,7 @@ func is_inference_suspended() -> bool:
|
||||
|
||||
# -- Startup pref load --------------------------------------------------------
|
||||
|
||||
|
||||
## Load AI dialogue preference from settings.cfg and apply to GameState.
|
||||
## Called from _ready() to restore the toggle state across session restarts.
|
||||
## Spec: D-138 §8 — toggle persists via ConfigFile (client-local) + server SQLite (#627).
|
||||
@@ -144,6 +151,7 @@ func load_ai_pref() -> void:
|
||||
|
||||
# -- Battery suspend/resume ---------------------------------------------------
|
||||
|
||||
|
||||
func _on_power_profile_changed(old_profile: int, new_profile: int) -> void:
|
||||
if new_profile == PlatformInfo.PowerProfile.BATTERY:
|
||||
_suspend_for_battery()
|
||||
@@ -176,34 +184,49 @@ func _resume_from_battery() -> void:
|
||||
func _send_settings_change(enabled: bool) -> void:
|
||||
if SimBridge.state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -- Status message -----------------------------------------------------------
|
||||
|
||||
|
||||
## Compose a human-readable status message for the settings dialog.
|
||||
## classification: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT).
|
||||
## Returns empty string when no message is needed.
|
||||
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
|
||||
func status_message(
|
||||
ram_classification: String, tpt_classification: String, free_mb: float, tps: float
|
||||
) -> String:
|
||||
if ram_classification == "fail":
|
||||
return (
|
||||
"AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available."
|
||||
(
|
||||
"AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available."
|
||||
+ " Close other applications and try again, or leave the setting off — the game is complete either way."
|
||||
) % free_mb
|
||||
)
|
||||
% free_mb
|
||||
)
|
||||
if ram_classification == "marginal":
|
||||
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
|
||||
match tpt_classification:
|
||||
"yellow":
|
||||
return (
|
||||
"Running at %.0f t/s — pre-voicing will work for main characters and key scenes."
|
||||
(
|
||||
"Running at %.0f t/s — pre-voicing will work for main characters and key scenes."
|
||||
+ " Background NPCs may show base text until the queue catches up."
|
||||
) % tps
|
||||
)
|
||||
% tps
|
||||
)
|
||||
"red":
|
||||
return (
|
||||
"Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours."
|
||||
) % tps
|
||||
("Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours.")
|
||||
% tps
|
||||
)
|
||||
return ""
|
||||
|
||||
@@ -6,9 +6,11 @@ extends Control
|
||||
@onready var perception_label: Label = $MarginContainer/VBoxContainer/PerceptionLabel
|
||||
@onready var time_label: Label = $MarginContainer/VBoxContainer/TimeLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
print("HUD: Initialized")
|
||||
|
||||
|
||||
# Update HUD from snapshot data
|
||||
func update_from_hud_data(data: Dictionary) -> void:
|
||||
# Update perception mode display
|
||||
@@ -27,6 +29,7 @@ func update_from_hud_data(data: Dictionary) -> void:
|
||||
else:
|
||||
time_label.text = prefix + ": " + data.time
|
||||
|
||||
|
||||
# Update player health (from player data, not hud_data)
|
||||
func update_health(health: int) -> void:
|
||||
health_label.text = UIStrings.get_text("hud.health") + ": " + str(health)
|
||||
|
||||
@@ -20,9 +20,4 @@ func _draw() -> void:
|
||||
if not _theme:
|
||||
return
|
||||
var y: float = _theme.sep_above
|
||||
draw_line(
|
||||
Vector2(0, y),
|
||||
Vector2(size.x, y),
|
||||
_theme.separator,
|
||||
1.0
|
||||
)
|
||||
draw_line(Vector2(0, y), Vector2(size.x, y), _theme.separator, 1.0)
|
||||
|
||||
@@ -25,5 +25,6 @@ func set_content(content: String, max_lines: int = -1) -> void:
|
||||
|
||||
func apply_implant_theme(t: ImplantTheme) -> void:
|
||||
add_theme_font_size_override("normal_font_size", t.font_small)
|
||||
add_theme_color_override("default_color",
|
||||
Color(t.text_primary.r, t.text_primary.g, t.text_primary.b, _text_alpha))
|
||||
add_theme_color_override(
|
||||
"default_color", Color(t.text_primary.r, t.text_primary.g, t.text_primary.b, _text_alpha)
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
@@ -113,14 +114,21 @@ func _rebuild_labels() -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = " %s " % verb.get("label", "")
|
||||
lbl.add_theme_font_size_override("font_size", 14)
|
||||
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||
lbl.add_theme_color_override(
|
||||
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM
|
||||
)
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
lbl.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
var idx := i
|
||||
lbl.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
_select_and_interact(idx)
|
||||
lbl.gui_input.connect(
|
||||
func(event: InputEvent):
|
||||
if (
|
||||
event is InputEventMouseButton
|
||||
and event.pressed
|
||||
and event.button_index == MOUSE_BUTTON_LEFT
|
||||
):
|
||||
_select_and_interact(idx)
|
||||
)
|
||||
lbl.mouse_entered.connect(func(): _hover_index(idx))
|
||||
lbl.mouse_exited.connect(func(): _unhover_index(idx))
|
||||
@@ -155,7 +163,9 @@ func _update_screen_position() -> void:
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
var cam_center := camera.get_screen_center_position()
|
||||
var zoom: Vector2 = camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM
|
||||
var zoom: Vector2 = (
|
||||
camera.zoom if camera.zoom.length_squared() > 0.01 else Constants.CAMERA_DEFAULT_ZOOM
|
||||
)
|
||||
var world_px := _entity_world_pos * Constants.TILE_SIZE
|
||||
var screen_pos := (world_px - cam_center) * zoom + viewport_size / 2.0
|
||||
# Anchor above the entity, centered horizontally
|
||||
@@ -184,17 +194,19 @@ func _hide() -> void:
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
# Guard: tween callback from previous _hide() may fire after labels already freed
|
||||
_active_tween.tween_callback(func():
|
||||
visible = false
|
||||
for lbl in _verb_labels:
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_verb_labels.clear()
|
||||
_active_tween.tween_callback(
|
||||
func():
|
||||
visible = false
|
||||
for lbl in _verb_labels:
|
||||
if is_instance_valid(lbl):
|
||||
lbl.queue_free()
|
||||
_verb_labels.clear()
|
||||
)
|
||||
|
||||
|
||||
# -- Lazy sync for getters (tests may set GameState without calling update) --
|
||||
|
||||
|
||||
func _ensure_synced() -> void:
|
||||
if _current_target_id == -1 and not GameState.nearby_interactions.is_empty():
|
||||
update_from_state()
|
||||
@@ -202,6 +214,7 @@ func _ensure_synced() -> void:
|
||||
|
||||
# -- Public API (QA test contract) ------------------------------------------
|
||||
|
||||
|
||||
func get_visible_verb_count() -> int:
|
||||
_ensure_synced()
|
||||
return _verb_items.size()
|
||||
@@ -254,7 +267,8 @@ func _update_label_colors() -> void:
|
||||
for i in range(_verb_labels.size()):
|
||||
if is_instance_valid(_verb_labels[i]):
|
||||
_verb_labels[i].add_theme_color_override(
|
||||
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
|
||||
"font_color", INSERT_FG if i == _selected_index else INSERT_DIM
|
||||
)
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
|
||||
@@ -19,11 +19,13 @@ var _insert_active: bool = true
|
||||
|
||||
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
modulate.a = 0.0
|
||||
visible = false
|
||||
_is_showing = false
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# OQ-07: insert off means no verb labels (z-layer 6 insert overlay suppressed)
|
||||
if not _insert_active:
|
||||
@@ -36,6 +38,7 @@ func _process(_delta: float) -> void:
|
||||
elif _is_showing:
|
||||
_hide_prompt()
|
||||
|
||||
|
||||
func _show_prompt(interaction: Dictionary) -> void:
|
||||
var target_id: int = interaction.get("entity_id", -1)
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
@@ -61,6 +64,7 @@ func _show_prompt(interaction: Dictionary) -> void:
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
|
||||
|
||||
|
||||
func _hide_prompt() -> void:
|
||||
if not _is_showing:
|
||||
return
|
||||
@@ -72,10 +76,12 @@ func _hide_prompt() -> void:
|
||||
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
_active_tween.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
## Returns the current interaction target entity ID, or -1 if no interaction.
|
||||
func get_interaction_target() -> int:
|
||||
return _current_target_id
|
||||
|
||||
|
||||
## OQ-07 (#522): insert off hides prompt (diegetic: no insert data on z-layer 6).
|
||||
## Cursor shape changes still fire on cursor_renderer.gd.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
|
||||
@@ -67,10 +67,7 @@ func _draw() -> void:
|
||||
|
||||
var col: int = slot_idx % GRID_COLS
|
||||
var row: int = slot_idx / GRID_COLS
|
||||
var pos := Vector2(
|
||||
col * (SLOT_SIZE + SLOT_GAP),
|
||||
row * (SLOT_SIZE + SLOT_GAP)
|
||||
)
|
||||
var pos := Vector2(col * (SLOT_SIZE + SLOT_GAP), row * (SLOT_SIZE + SLOT_GAP))
|
||||
var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE))
|
||||
|
||||
# Slot background
|
||||
@@ -89,13 +86,22 @@ func _draw() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := 11
|
||||
var text_size := font.get_string_size(item_name, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
|
||||
var text_pos := pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
|
||||
var text_pos := (
|
||||
pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
|
||||
)
|
||||
draw_string(font, text_pos, item_name, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, SLOT_TEXT)
|
||||
|
||||
# Hotkey number (top-left corner)
|
||||
var hotkey := str(slot_idx + 1)
|
||||
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
|
||||
draw_string(
|
||||
font,
|
||||
pos + Vector2(3, HOTKEY_SIZE + 2),
|
||||
hotkey,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
HOTKEY_SIZE,
|
||||
SLOT_TEXT_DIM
|
||||
)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
@@ -125,6 +131,7 @@ func _select_slot(slot_idx: int) -> void:
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_slot_count() -> int:
|
||||
return _slots.size()
|
||||
|
||||
|
||||
+19
-12
@@ -29,8 +29,9 @@ var _last_rendered_tick: int = -1
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
||||
@onready var entries_container: VBoxContainer = \
|
||||
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
@onready
|
||||
var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
@@ -118,12 +119,16 @@ func _add_entity_entry(entity: Dictionary) -> void:
|
||||
header_rtl.add_theme_font_size_override("normal_font_size", 14)
|
||||
|
||||
var rel_color: Color = Constants.color_for_relationship(relationship)
|
||||
var rel_label: String = UIStrings.get_text("relationship_states.%s.label" % relationship.to_lower())
|
||||
var rel_label: String = UIStrings.get_text(
|
||||
"relationship_states.%s.label" % relationship.to_lower()
|
||||
)
|
||||
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
|
||||
rel_label = relationship # fallback if key missing
|
||||
|
||||
var state_color: Color = _state_color(state)
|
||||
var conf_label: String = UIStrings.get_text("knowledge_panel.confidence_%s" % confidence.to_lower())
|
||||
var conf_label: String = UIStrings.get_text(
|
||||
"knowledge_panel.confidence_%s" % confidence.to_lower()
|
||||
)
|
||||
var src_label: String = _resolve_source_label(source)
|
||||
|
||||
# Build BBCode:
|
||||
@@ -133,13 +138,15 @@ func _add_entity_entry(entity: Dictionary) -> void:
|
||||
|
||||
var header_bbcode: String
|
||||
if state == "Contradicted":
|
||||
header_bbcode = "[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_bbcode = (
|
||||
"[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]"
|
||||
% [name_hex, name_str, rel_hex, rel_label]
|
||||
)
|
||||
else:
|
||||
header_bbcode = "[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]" % [
|
||||
name_hex, name_str, rel_hex, rel_label
|
||||
]
|
||||
header_bbcode = (
|
||||
"[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]"
|
||||
% [name_hex, name_str, rel_hex, rel_label]
|
||||
)
|
||||
header_rtl.text = header_bbcode
|
||||
entries_container.add_child(header_rtl)
|
||||
|
||||
@@ -198,9 +205,9 @@ func _entity_name_for_id(entity_id: int) -> String:
|
||||
func _state_color(state: String) -> Color:
|
||||
match state:
|
||||
"Contradicted":
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
|
||||
"Stale":
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
return Constants.IMPLANT_TEXT_DIM # dimmed
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # active — normal
|
||||
|
||||
|
||||
+11
-4
@@ -156,8 +156,15 @@ static func _format_save_entry(save: Dictionary) -> String:
|
||||
if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6:
|
||||
var d := parts[0]
|
||||
var t := parts[1]
|
||||
return "%s-%s-%s %s:%s:%s" % [
|
||||
d.substr(0, 4), d.substr(4, 2), d.substr(6, 2),
|
||||
t.substr(0, 2), t.substr(2, 2), t.substr(4, 2),
|
||||
]
|
||||
return (
|
||||
"%s-%s-%s %s:%s:%s"
|
||||
% [
|
||||
d.substr(0, 4),
|
||||
d.substr(4, 2),
|
||||
d.substr(6, 2),
|
||||
t.substr(0, 2),
|
||||
t.substr(2, 2),
|
||||
t.substr(4, 2),
|
||||
]
|
||||
)
|
||||
return game_id
|
||||
|
||||
+34
-14
@@ -29,27 +29,31 @@ const CARDINAL_TICK_LEN: float = 4.0
|
||||
|
||||
# Colors — insert palette from Constants, tuned for the circular minimap frame
|
||||
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
|
||||
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
|
||||
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
|
||||
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
|
||||
|
||||
var _insert_active: bool = true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(true)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _insert_active:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Called from main.gd when GameState.insert_active changes.
|
||||
## Hides the minimap overlay when the neural insert is inactive.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
visible = active
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center := sz / 2.0
|
||||
@@ -68,8 +72,16 @@ func _draw() -> void:
|
||||
# --- Player dot at center ---
|
||||
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
|
||||
# Soft bloom ring
|
||||
draw_arc(center, PLAYER_DOT_RADIUS + 1.5, 0.0, TAU, 32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22), 1.0, true)
|
||||
draw_arc(
|
||||
center,
|
||||
PLAYER_DOT_RADIUS + 1.5,
|
||||
0.0,
|
||||
TAU,
|
||||
32,
|
||||
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22),
|
||||
1.0,
|
||||
true
|
||||
)
|
||||
|
||||
# --- POIs ---
|
||||
var pois: Array = GameState.discovered_pois
|
||||
@@ -118,7 +130,9 @@ func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
draw_line(
|
||||
center + n_dir * (outer_r - NORTH_TICK_LEN),
|
||||
center + n_dir * outer_r,
|
||||
COLOR_NORTH, FRAME_WIDTH + 0.5, true
|
||||
COLOR_NORTH,
|
||||
FRAME_WIDTH + 0.5,
|
||||
true
|
||||
)
|
||||
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
|
||||
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
|
||||
@@ -126,7 +140,9 @@ func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
|
||||
draw_line(
|
||||
center + dir * (outer_r - CARDINAL_TICK_LEN),
|
||||
center + dir * outer_r,
|
||||
COLOR_CARDINAL, FRAME_WIDTH, true
|
||||
COLOR_CARDINAL,
|
||||
FRAME_WIDTH,
|
||||
true
|
||||
)
|
||||
|
||||
|
||||
@@ -136,10 +152,14 @@ func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
|
||||
# Diamond for danger
|
||||
var s: float = POI_DOT_RADIUS + 1.0
|
||||
draw_polygon(
|
||||
PackedVector2Array([
|
||||
pos + Vector2(0.0, -s), pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s), pos + Vector2(-s, 0.0)
|
||||
]),
|
||||
PackedVector2Array(
|
||||
[
|
||||
pos + Vector2(0.0, -s),
|
||||
pos + Vector2(s, 0.0),
|
||||
pos + Vector2(0.0, s),
|
||||
pos + Vector2(-s, 0.0)
|
||||
]
|
||||
),
|
||||
PackedColorArray([color, color, color, color])
|
||||
)
|
||||
"evidence", "note", "clue":
|
||||
@@ -163,10 +183,10 @@ func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
|
||||
func _category_color(category: String) -> Color:
|
||||
match category.to_lower():
|
||||
"danger", "threat", "hostile":
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
|
||||
"evidence", "note", "clue":
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
|
||||
"contact", "npc", "person":
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
|
||||
_:
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue
|
||||
|
||||
@@ -13,28 +13,30 @@ extends Control
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
const MAX_VISIBLE: int = 3
|
||||
const MAX_QUEUE: int = 5
|
||||
const MAX_QUEUE: int = 5
|
||||
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
|
||||
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented": { # detective
|
||||
"lattice_augmented":
|
||||
{ # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline": { # smuggler
|
||||
"lattice_baseline":
|
||||
{ # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
@@ -67,7 +69,9 @@ func _process(delta: float) -> void:
|
||||
if next.get("is_notification", false):
|
||||
_show_notification_line(next.text)
|
||||
else:
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
_show_line(
|
||||
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
|
||||
)
|
||||
|
||||
|
||||
# Display a monologue line.
|
||||
@@ -86,20 +90,30 @@ func show_notification(text: String) -> void:
|
||||
_show_notification_line(text)
|
||||
else:
|
||||
var entry := {
|
||||
text = text, duration = _NOTIFICATION_DURATION, priority = 1,
|
||||
is_urgent = false, lattice_profile = "", is_notification = true
|
||||
text = text,
|
||||
duration = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
is_urgent = false,
|
||||
lattice_profile = "",
|
||||
is_notification = true
|
||||
}
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append(entry)
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
else:
|
||||
var lowest := _lowest_priority_idx()
|
||||
if 1 >= _queue[lowest].priority:
|
||||
_queue[lowest] = entry
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
|
||||
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
func show_monologue(
|
||||
text: String, duration: float, priority: int = 2, is_urgent: bool = false
|
||||
) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var profile := GameState.lattice_profile
|
||||
@@ -114,11 +128,12 @@ func show_monologue(text: String, duration: float, priority: int = 2, is_urgent:
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _show_notification_line(text: String) -> void:
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
@@ -130,10 +145,10 @@ func _show_notification_line(text: String) -> void:
|
||||
container.add_child(label)
|
||||
_vbox.add_child(container)
|
||||
var slot := {
|
||||
node = container,
|
||||
node = container,
|
||||
expire_timer = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
tween = null,
|
||||
priority = 1,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
@@ -143,15 +158,17 @@ func _show_notification_line(text: String) -> void:
|
||||
tween.tween_property(container, "modulate:a", 0.85, FADE_IN_SEC)
|
||||
|
||||
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
func _show_line(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
var slot := {
|
||||
node = line_node,
|
||||
node = line_node,
|
||||
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
||||
priority = priority,
|
||||
tween = null,
|
||||
priority = priority,
|
||||
tween = null,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
@@ -174,22 +191,36 @@ func _retire_slot(slot: Dictionary) -> void:
|
||||
tween.tween_callback(node.queue_free)
|
||||
|
||||
|
||||
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
func _enqueue(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append({
|
||||
text = text, duration = duration, priority = priority,
|
||||
is_urgent = is_urgent, lattice_profile = lattice_profile
|
||||
})
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.append(
|
||||
{
|
||||
text = text,
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
else:
|
||||
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
||||
var lowest := _lowest_priority_idx()
|
||||
if priority >= _queue[lowest].priority:
|
||||
_queue[lowest] = {
|
||||
text = text, duration = duration, priority = priority,
|
||||
is_urgent = is_urgent, lattice_profile = lattice_profile
|
||||
text = text,
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
_queue.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
|
||||
)
|
||||
# else: incoming line is strictly lower priority — silently drop; no sort needed
|
||||
|
||||
|
||||
@@ -203,13 +234,16 @@ func _lowest_priority_idx() -> int:
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
|
||||
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
|
||||
var color: Color = (
|
||||
palette.get("urgent", _FALLBACK_URGENT)
|
||||
if is_urgent
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
)
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
|
||||
var label := RichTextLabel.new()
|
||||
|
||||
@@ -8,33 +8,33 @@ extends Control
|
||||
|
||||
signal closed
|
||||
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
|
||||
const TITLE_COLOR := Color("#4a9ebb")
|
||||
const STATUS_GREEN := Color("#6bc9a6")
|
||||
const STATUS_GREEN := Color("#6bc9a6")
|
||||
const STATUS_YELLOW := Color("#e8c547")
|
||||
const STATUS_RED := Color("#c84040")
|
||||
const STATUS_RED := Color("#c84040")
|
||||
const FONT_SIZE := 14
|
||||
const FONT_SIZE_SMALL := 11
|
||||
|
||||
## Shared client preferences file — avoids compile-time dependency on DebugConsole for path.
|
||||
const SETTINGS_CFG_PATH := "user://settings.cfg"
|
||||
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_HEIGHT := 500 # +36 debug console, +88 AI dialogue section
|
||||
const PADDING := 20
|
||||
const PADDING := 20
|
||||
const ROW_HEIGHT := 36
|
||||
|
||||
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
|
||||
const BUS_ROWS: Array = [
|
||||
["Music", "Music"],
|
||||
["Ambient", "Ambient"],
|
||||
["World SFX", "WorldSFX"],
|
||||
["Music", "Music"],
|
||||
["Ambient", "Ambient"],
|
||||
["World SFX", "WorldSFX"],
|
||||
["Player Actions", "PlayerActions"],
|
||||
["UI Sounds", "UISounds"],
|
||||
["UI Sounds", "UISounds"],
|
||||
]
|
||||
|
||||
var _active: bool = false
|
||||
@@ -77,10 +77,7 @@ func is_open() -> bool:
|
||||
|
||||
func _build_ui() -> void:
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_pos := Vector2(
|
||||
(vp_size.x - BOX_WIDTH) / 2.0,
|
||||
(vp_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_pos := Vector2((vp_size.x - BOX_WIDTH) / 2.0, (vp_size.y - BOX_HEIGHT) / 2.0)
|
||||
|
||||
_container = VBoxContainer.new()
|
||||
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
|
||||
@@ -121,9 +118,10 @@ func _build_ui() -> void:
|
||||
db_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
hbox.add_child(db_label)
|
||||
|
||||
slider.value_changed.connect(func(value: float) -> void:
|
||||
AudioManager.set_volume(bus_name, value)
|
||||
db_label.text = _format_db(value)
|
||||
slider.value_changed.connect(
|
||||
func(value: float) -> void:
|
||||
AudioManager.set_volume(bus_name, value)
|
||||
db_label.text = _format_db(value)
|
||||
)
|
||||
|
||||
# #581: Debug Console toggle
|
||||
@@ -148,10 +146,10 @@ func _build_ui() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
debug_check.button_pressed = true
|
||||
if cfg.load(DebugConsole.PREFS_PATH) == OK:
|
||||
debug_check.button_pressed = cfg.get_value(DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true)
|
||||
debug_check.toggled.connect(func(enabled: bool) -> void:
|
||||
debug_console_toggled.emit(enabled)
|
||||
)
|
||||
debug_check.button_pressed = cfg.get_value(
|
||||
DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true
|
||||
)
|
||||
debug_check.toggled.connect(func(enabled: bool) -> void: debug_console_toggled.emit(enabled))
|
||||
debug_hbox.add_child(debug_check)
|
||||
|
||||
# #646: AI Dialogue section divider
|
||||
@@ -223,7 +221,8 @@ func _build_ui() -> void:
|
||||
|
||||
# Status message label — only shown when non-empty
|
||||
var status_msg: String = HardwareDetector.status_message(
|
||||
ram_class, tpt_class, ram_result["free_mb"], tpt_tps)
|
||||
ram_class, tpt_class, ram_result["free_mb"], tpt_tps
|
||||
)
|
||||
var ai_status_label := Label.new()
|
||||
ai_status_label.text = status_msg
|
||||
ai_status_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL)
|
||||
@@ -247,16 +246,22 @@ func _build_ui() -> void:
|
||||
_ai_battery_warning_label.visible = _ai_inference_suspended
|
||||
_container.add_child(_ai_battery_warning_label)
|
||||
|
||||
_ai_check_node.toggled.connect(func(enabled: bool) -> void:
|
||||
GameState.ai_enhanced_dialogue_enabled = enabled
|
||||
_save_ai_pref(enabled)
|
||||
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
ai_dialogue_toggled.emit(enabled)
|
||||
_ai_check_node.toggled.connect(
|
||||
func(enabled: bool) -> void:
|
||||
GameState.ai_enhanced_dialogue_enabled = enabled
|
||||
_save_ai_pref(enabled)
|
||||
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.CHANGE_SETTINGS,
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
ai_dialogue_toggled.emit(enabled)
|
||||
)
|
||||
|
||||
# Spacer
|
||||
@@ -284,12 +289,13 @@ func _destroy_ui() -> void:
|
||||
if _container:
|
||||
_container.queue_free()
|
||||
_container = null
|
||||
_ai_check_node = null # freed with _container
|
||||
_ai_check_node = null # freed with _container
|
||||
_ai_battery_warning_label = null
|
||||
|
||||
|
||||
# -- #646: AI Dialogue testable API -------------------------------------------
|
||||
|
||||
|
||||
## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138).
|
||||
## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support.
|
||||
func get_ai_dialogue_label_text() -> String:
|
||||
@@ -354,8 +360,7 @@ func _draw() -> void:
|
||||
|
||||
# Dialog box
|
||||
var box_pos := Vector2(
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0,
|
||||
(viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
||||
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
||||
@@ -363,10 +368,15 @@ func _draw() -> void:
|
||||
|
||||
# Title
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_string(font,
|
||||
draw_string(
|
||||
font,
|
||||
box_pos + Vector2(PADDING, PADDING + 18),
|
||||
"SETTINGS",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE + 2,
|
||||
TITLE_COLOR
|
||||
)
|
||||
|
||||
|
||||
func _on_quit_to_menu() -> void:
|
||||
|
||||
@@ -5,10 +5,10 @@ extends Control
|
||||
## Lives on UILayer (z-layer 7).
|
||||
|
||||
const STANCE_COLORS := {
|
||||
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
|
||||
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
|
||||
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
|
||||
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
|
||||
"Careful": Color("#6bc9a6"), # Green — quiet, observant
|
||||
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
|
||||
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
|
||||
}
|
||||
|
||||
const STANCE_DEFAULT_COLOR := Color("#c8d0e0")
|
||||
@@ -42,10 +42,19 @@ func _draw() -> void:
|
||||
|
||||
# Stance text
|
||||
var color: Color = STANCE_COLORS.get(_current_stance, STANCE_DEFAULT_COLOR)
|
||||
draw_string(font, PADDING + Vector2(0, text_size.y), text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
|
||||
draw_string(
|
||||
font,
|
||||
PADDING + Vector2(0, text_size.y),
|
||||
text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE,
|
||||
color
|
||||
)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func get_current_stance() -> String:
|
||||
return _current_stance
|
||||
|
||||
+80
-31
@@ -16,9 +16,9 @@ const DATA_PATH := "res://data/star_map_data.json"
|
||||
|
||||
# Layout
|
||||
const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control
|
||||
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
|
||||
const RING_SPACING: float = 22.0 # pixels between hop rings
|
||||
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
|
||||
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
|
||||
const RING_SPACING: float = 22.0 # pixels between hop rings
|
||||
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
|
||||
|
||||
# Dot sizing
|
||||
const DOT_RADIUS_HUB: float = 4.5
|
||||
@@ -75,8 +75,8 @@ const SECTOR_ANGLE_CENTER: Dictionary = {
|
||||
"west_reach": PI,
|
||||
}
|
||||
const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc
|
||||
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
|
||||
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
|
||||
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
|
||||
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
|
||||
|
||||
# Pan/zoom
|
||||
const ZOOM_MIN: float = 0.3
|
||||
@@ -87,7 +87,7 @@ const ZOOM_STEP: float = 0.15
|
||||
var _nodes: Array = []
|
||||
var _edges: Array = []
|
||||
var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center)
|
||||
var _node_lookup: Dictionary = {} # system_id -> node dict
|
||||
var _node_lookup: Dictionary = {} # system_id -> node dict
|
||||
var _selected_system: String = ""
|
||||
var _hovered_system: String = ""
|
||||
|
||||
@@ -99,8 +99,8 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
var _data_loaded: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
var _info_panel: ImplantPanel # D-169: component-based info panel
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
var _info_panel: ImplantPanel # D-169: component-based info panel
|
||||
var _implant_theme: ImplantTheme
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ func get_edge_count() -> int:
|
||||
# Data loading
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _load_data() -> void:
|
||||
if not FileAccess.file_exists(DATA_PATH):
|
||||
push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH)
|
||||
@@ -195,6 +196,7 @@ func _load_data() -> void:
|
||||
# Layout — place systems on concentric rings by hop distance
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _compute_layout() -> void:
|
||||
_node_positions.clear()
|
||||
|
||||
@@ -260,7 +262,9 @@ func _compute_layout() -> void:
|
||||
var jitter: float = _system_hash(node["system_id"]) * 0.08
|
||||
angle += jitter
|
||||
# Slight radial variation to avoid perfect circles
|
||||
var r_var: float = radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
|
||||
var r_var: float = (
|
||||
radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
|
||||
)
|
||||
_node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var
|
||||
|
||||
|
||||
@@ -275,13 +279,20 @@ func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool:
|
||||
func _sector_sort_key(node: Dictionary) -> float:
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
match sector:
|
||||
"core": return 0.0
|
||||
"north_reach": return 1.0
|
||||
"east_reach": return 2.0
|
||||
"south_reach": return 3.0
|
||||
"west_reach": return 4.0
|
||||
"deep_frontier": return 5.0
|
||||
_: return 6.0 # gdlint:ignore = max-returns
|
||||
"core":
|
||||
return 0.0
|
||||
"north_reach":
|
||||
return 1.0
|
||||
"east_reach":
|
||||
return 2.0
|
||||
"south_reach":
|
||||
return 3.0
|
||||
"west_reach":
|
||||
return 4.0
|
||||
"deep_frontier":
|
||||
return 5.0
|
||||
_:
|
||||
return 6.0 # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
## Deterministic float in [-1, 1] from a string key.
|
||||
@@ -294,6 +305,7 @@ func _system_hash(key: String) -> float:
|
||||
# Drawing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _data_loaded:
|
||||
return
|
||||
@@ -357,8 +369,12 @@ func _draw_sector_labels(center: Vector2) -> void:
|
||||
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
||||
var font := get_theme_default_font()
|
||||
var font_size: int = 10
|
||||
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color)
|
||||
var text_size: Vector2 = font.get_string_size(
|
||||
label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size
|
||||
)
|
||||
draw_string(
|
||||
font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color
|
||||
)
|
||||
|
||||
|
||||
func _draw_systems(center: Vector2) -> void:
|
||||
@@ -383,7 +399,9 @@ func _draw_systems(center: Vector2) -> void:
|
||||
|
||||
# Hover highlight
|
||||
if sid == _hovered_system and sid != _selected_system:
|
||||
draw_arc(pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true)
|
||||
draw_arc(
|
||||
pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true
|
||||
)
|
||||
|
||||
draw_circle(pos, radius, color)
|
||||
|
||||
@@ -415,8 +433,15 @@ func _draw_selection(center: Vector2) -> void:
|
||||
if label.is_empty():
|
||||
label = _selected_system
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
|
||||
draw_string(
|
||||
font,
|
||||
pos + Vector2(SELECTION_RING_RADIUS + 4, 4),
|
||||
label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
12,
|
||||
COLOR_SELECTION
|
||||
)
|
||||
|
||||
|
||||
## Rebuild the info panel with components for the selected system (D-169).
|
||||
@@ -462,9 +487,13 @@ func _rebuild_info_panel() -> void:
|
||||
elif not population.is_empty():
|
||||
stat_line_3 = "Population: " + population
|
||||
else:
|
||||
stat_line_3 = "%d aperture%s" % [
|
||||
int(node.get("aperture_count", 0)),
|
||||
"s" if int(node.get("aperture_count", 0)) != 1 else ""]
|
||||
stat_line_3 = (
|
||||
"%d aperture%s"
|
||||
% [
|
||||
int(node.get("aperture_count", 0)),
|
||||
"s" if int(node.get("aperture_count", 0)) != 1 else ""
|
||||
]
|
||||
)
|
||||
_info_panel.add_component(ImplantDataRow.new(stat_line_3))
|
||||
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
@@ -487,23 +516,43 @@ func _rebuild_info_panel() -> void:
|
||||
|
||||
func _draw_title() -> void:
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT)
|
||||
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(16, 28),
|
||||
"THE REACH — NAVIGATOR",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
16,
|
||||
COLOR_TEXT
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(16, 44),
|
||||
"Concord Assembly Gate Network — %d Systems" % _nodes.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
10,
|
||||
COLOR_TEXT_DIM
|
||||
)
|
||||
|
||||
|
||||
func _dot_radius(topology: String) -> float:
|
||||
match topology:
|
||||
"hub": return DOT_RADIUS_HUB
|
||||
"junction": return DOT_RADIUS_JUNCTION
|
||||
"dead_end": return DOT_RADIUS_DEAD_END
|
||||
_: return DOT_RADIUS_DEFAULT
|
||||
"hub":
|
||||
return DOT_RADIUS_HUB
|
||||
"junction":
|
||||
return DOT_RADIUS_JUNCTION
|
||||
"dead_end":
|
||||
return DOT_RADIUS_DEAD_END
|
||||
_:
|
||||
return DOT_RADIUS_DEFAULT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input — selection, pan, zoom
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
|
||||
+31
-10
@@ -15,10 +15,10 @@ const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
|
||||
|
||||
# Day phase colors — station lighting cycle (D-031)
|
||||
const PHASE_COLORS := {
|
||||
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
|
||||
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
|
||||
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
|
||||
"Night": Color("#4a7080"), # dark teal — station nightwatch
|
||||
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
|
||||
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
|
||||
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
|
||||
"Night": Color("#4a7080"), # dark teal — station nightwatch
|
||||
}
|
||||
|
||||
var _time_str: String = "--:--"
|
||||
@@ -86,12 +86,33 @@ func _draw() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
|
||||
# HH:MM (primary, full brightness)
|
||||
draw_string(font, Vector2(PADDING.x, PADDING.y + _time_size.y),
|
||||
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, PADDING.y + _time_size.y),
|
||||
_time_str,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE_TIME,
|
||||
Constants.IMPLANT_TEXT_COLOR
|
||||
)
|
||||
|
||||
# Phase + day number (secondary, dimmed + phase-tinted)
|
||||
var meta_y := PADDING.y + _time_size.y + 3 + _meta_h
|
||||
draw_string(font, Vector2(PADDING.x, meta_y),
|
||||
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
|
||||
draw_string(font, Vector2(PADDING.x + _phase_size.x, meta_y),
|
||||
_day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x, meta_y),
|
||||
_phase_str,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE_META,
|
||||
_phase_color
|
||||
)
|
||||
draw_string(
|
||||
font,
|
||||
Vector2(PADDING.x + _phase_size.x, meta_y),
|
||||
_day_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
FONT_SIZE_META,
|
||||
Constants.IMPLANT_TEXT_DIM
|
||||
)
|
||||
|
||||
@@ -144,20 +144,30 @@ func _activate_insert() -> void:
|
||||
# if already paused (e.g. Gauntlet interlude), server ignores duplicate.
|
||||
if not _insert_active:
|
||||
_insert_active = true
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func deactivate_insert() -> void:
|
||||
# #518/D-058: Send ResumeSimulation when insert closes.
|
||||
if _insert_active:
|
||||
_insert_active = false
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
(
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
@@ -217,11 +227,17 @@ func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
|
||||
var hh := ICON_SIZE * 0.55
|
||||
draw_rect(Rect2(center - Vector2(hw, hh), Vector2(hw * 2, hh * 2)), color, false, 1.0)
|
||||
# Screen line
|
||||
draw_line(center - Vector2(hw * 0.6, hh * 0.3), center + Vector2(hw * 0.6, -hh * 0.3), color, 1.0)
|
||||
draw_line(
|
||||
center - Vector2(hw * 0.6, hh * 0.3),
|
||||
center + Vector2(hw * 0.6, -hh * 0.3),
|
||||
color,
|
||||
1.0
|
||||
)
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _open
|
||||
|
||||
|
||||
Reference in New Issue
Block a user