Merge remote-tracking branch 'origin/sprint-31/client'
This commit is contained in:
@@ -24,6 +24,7 @@ enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*'
|
||||
enum-name: ([A-Z][a-z0-9]*)+
|
||||
excluded_directories: !!set
|
||||
.git: null
|
||||
addons: null
|
||||
expression-not-assigned: null
|
||||
function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
function-arguments-number: 10
|
||||
@@ -34,7 +35,7 @@ load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
|
||||
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
max-file-lines: 1000
|
||||
max-line-length: 120
|
||||
max-public-methods: 20
|
||||
max-public-methods: 200
|
||||
max-returns: 6
|
||||
mixed-tabs-and-spaces: null
|
||||
no-elif-return: null
|
||||
|
||||
+2193
-301
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ extends Node
|
||||
## No-op fallback when audio assets absent (D-038).
|
||||
## Spatial audio positioning for close-range sounds (D-018).
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
# --- D-067: Recognition chime asset key ---
|
||||
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
@@ -63,6 +65,22 @@ const ZONE_ASSETS: Dictionary = {
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||
# 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
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
@@ -83,10 +101,6 @@ var _ambient_players: Dictionary = {}
|
||||
var _current_zone_id: String = ""
|
||||
var _zone_tweens: Array = []
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
@@ -200,21 +214,6 @@ func stop_all_loops() -> void:
|
||||
stop_loop(key)
|
||||
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||
# 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
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
|
||||
## Play a close-range sound event at a world tile position (D-018, #125).
|
||||
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
|
||||
## world_tile_pos: server tile coordinates — converted to world pixels internally.
|
||||
|
||||
@@ -8,7 +8,8 @@ extends Node
|
||||
# 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_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
|
||||
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 EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
@@ -35,6 +36,14 @@ var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
|
||||
var visibility_texture: ImageTexture
|
||||
var exploration_texture: ImageTexture
|
||||
var zone_tint_texture: ImageTexture
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
var _vis_bytes: PackedByteArray
|
||||
var _exp_bytes: PackedByteArray
|
||||
@@ -47,16 +56,6 @@ var _width: int = 1
|
||||
var _height: int = 1
|
||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
||||
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
|
||||
@@ -19,7 +19,8 @@ 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 boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585)
|
||||
# — visible in fog but not explored
|
||||
|
||||
# v2 fields (D-015, D-031)
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
||||
@@ -50,7 +51,8 @@ var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||
|
||||
# v7 fields (#435, D-061/D-062)
|
||||
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null
|
||||
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]}
|
||||
# or null
|
||||
|
||||
# D-064: true while dialogue box is visible or fading out (300ms).
|
||||
# InputMapper suppresses movement when this is true.
|
||||
@@ -152,8 +154,7 @@ var close_sound_events: Array = []
|
||||
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
# DEPRECATED: _prev_player_position moved to SnapshotHandler (client-side accumulation fallback).
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# D-020: Read directly from snapshot "zone_id" field.
|
||||
@@ -162,276 +163,7 @@ var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previou
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
if snapshot.has("tick"):
|
||||
current_tick = snapshot.tick
|
||||
|
||||
if snapshot.has("entities"):
|
||||
visible_entities = snapshot.entities
|
||||
# Derive player position from the entity with kind.variant == "Player"
|
||||
var found_player := false
|
||||
for entity in visible_entities:
|
||||
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
|
||||
player_position = Vector2(entity.x, entity.y)
|
||||
if entity.has("entity_id"):
|
||||
player_entity_id = entity.entity_id
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and visible_entities.size() > 0:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
# Prefer server-sent value; fall back to client-side accumulation until server populates.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
stationary_ticks = snapshot.stationary_ticks
|
||||
else:
|
||||
# DEPRECATED fallback — client-side accumulation. Remove when server sends
|
||||
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
|
||||
# driving state on the client). Server tracks this in ListeningFocus component.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
# Live server: visible_tiles now includes type from tile_kind field
|
||||
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")
|
||||
if has_type:
|
||||
visible_tiles = snapshot.visible_tiles
|
||||
|
||||
if snapshot.has("visible_positions"):
|
||||
visible_positions.clear()
|
||||
for pos in snapshot.visible_positions:
|
||||
visible_positions[Vector2i(pos.x, pos.y)] = true
|
||||
|
||||
# v2: game_time (D-031)
|
||||
if snapshot.has("game_time") and snapshot.game_time is Dictionary:
|
||||
game_time = snapshot.game_time
|
||||
|
||||
# v2: player_facing (D-015)
|
||||
if snapshot.has("player_facing") and snapshot.player_facing is String:
|
||||
player_facing = snapshot.player_facing
|
||||
|
||||
# v4: nearby_interactions (#404/#405)
|
||||
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
|
||||
nearby_interactions = snapshot.nearby_interactions
|
||||
else:
|
||||
nearby_interactions = []
|
||||
|
||||
# v5: current_monologue (#414)
|
||||
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
|
||||
current_monologue = snapshot.current_monologue
|
||||
else:
|
||||
current_monologue = null
|
||||
|
||||
# #122: lattice_profile — character insert capability level for monologue colour
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
player_stance = snapshot.player_stance
|
||||
|
||||
# v6: player_inventory (#449, D-065)
|
||||
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
|
||||
player_inventory = snapshot.player_inventory
|
||||
else:
|
||||
player_inventory = []
|
||||
|
||||
# v7: current_dialogue (#434, D-061)
|
||||
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
|
||||
current_dialogue = snapshot.current_dialogue
|
||||
else:
|
||||
current_dialogue = null
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060)
|
||||
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
|
||||
pending_recognitions = snapshot.pending_recognitions
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
|
||||
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||
conversation_events = snapshot.conversation_events
|
||||
else:
|
||||
conversation_events = []
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
|
||||
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||
conversation_ended = snapshot.conversation_ended
|
||||
else:
|
||||
conversation_ended = []
|
||||
|
||||
# v8: dialogue_response (#305, D-028) — NPC follow-up after player choice
|
||||
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||
dialogue_response = snapshot.dialogue_response
|
||||
else:
|
||||
dialogue_response = null
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
else:
|
||||
gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
room_id = snapshot.room_id
|
||||
else:
|
||||
room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
|
||||
# Server may send false for characters without an insert in future sprints.
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
insert_active = snapshot.insert_active
|
||||
else:
|
||||
insert_active = true
|
||||
|
||||
# #507: rng_seed — server sends current RNG seed for replay determinism.
|
||||
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
|
||||
if snapshot.has("rng_seed"):
|
||||
rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
rng_seed = null
|
||||
|
||||
# D-018: Sound events from server — partition by range_category.
|
||||
# #126: Medium → fog-edge directional indicators.
|
||||
# #125: Close → positional 2D audio via AudioManager.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
close_sound_events.append(se)
|
||||
else:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||
# Only update if the field is present — absence means "no change since last tick".
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
current_examine_result = snapshot.examine_result
|
||||
else:
|
||||
current_examine_result = null
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
|
||||
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||
save_result = snapshot.save_result
|
||||
else:
|
||||
save_result = null
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
debug_response = snapshot.debug_response
|
||||
else:
|
||||
debug_response = null
|
||||
|
||||
# v20: settings_response (#627, D-138) — one-shot settings ack/dump from server.
|
||||
# "full" kind → iterate settings array and hydrate matching fields.
|
||||
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
|
||||
settings_response = snapshot.settings_response
|
||||
var sr: Dictionary = snapshot.settings_response
|
||||
if sr.get("kind") == "full":
|
||||
var sr_settings: Variant = sr.get("settings")
|
||||
if sr_settings is Array:
|
||||
for entry in sr_settings:
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
if entry.get("key") == "ai_dialogue.enabled":
|
||||
var val: Variant = entry.get("value")
|
||||
if val != null:
|
||||
ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
else:
|
||||
settings_response = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
# Server persists the descriptor and includes it in ObserverSnapshot after load.
|
||||
# Only update when field is present (null means no change).
|
||||
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
|
||||
# load() returns a cached script — safe to call per-tick once the resource is in cache.
|
||||
# Cannot use CharacterVisualDescriptor directly: autoloads compile before global class_names
|
||||
# are registered, causing a parse-time "not declared" error.
|
||||
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
|
||||
if CVD != null:
|
||||
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
|
||||
if restored != null:
|
||||
character_visual_descriptor = restored
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||
# Prefer server-sent top-level value; fall back to client-side tile lookup until
|
||||
# server populates top-level "zone_id" in ObserverSnapshot.
|
||||
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
current_zone_id = snapshot.zone_id
|
||||
else:
|
||||
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
|
||||
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
|
||||
# identity on the client via tile iteration). Server sends zone_id per
|
||||
# VisibleTile but not as a top-level snapshot field.
|
||||
var _tile_by_coord: Dictionary = {}
|
||||
for vtile in 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(player_position.x), int(player_position.y))
|
||||
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode).
|
||||
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
visible_positions.clear()
|
||||
boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
visibility_sectors[pos] = vis_sector
|
||||
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
|
||||
# explored — they don't update the player's exploration memory when they leave LOS.
|
||||
if vis_sector == "BoundaryWall":
|
||||
boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
visible_positions[pos] = true
|
||||
|
||||
|
||||
# -- Helpers ------------------------------------------------------------------
|
||||
|
||||
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
|
||||
## Handles both serde encoding styles; emits push_warning on unrecognised format.
|
||||
static func _extract_bool_setting(key: String, val: Variant) -> bool:
|
||||
if val is bool:
|
||||
return val
|
||||
if val is Dictionary and val.has("Bool"):
|
||||
return bool(val["Bool"])
|
||||
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
|
||||
return false
|
||||
# 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.
|
||||
var SH := load("res://scripts/snapshot_handler.gd")
|
||||
SH.apply(snapshot)
|
||||
|
||||
@@ -30,14 +30,6 @@ enum Action {
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
}
|
||||
|
||||
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 _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
@@ -46,6 +38,14 @@ const MOVE_INTERVAL_MS := {
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
}
|
||||
|
||||
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 _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
var _last_move_msec: int = 0
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ extends Node
|
||||
|
||||
# -- 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
|
||||
@@ -26,18 +29,6 @@ enum PowerProfile {
|
||||
POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet)
|
||||
}
|
||||
|
||||
## Emitted when the detected power profile changes.
|
||||
signal power_profile_changed(old_profile: int, new_profile: int)
|
||||
|
||||
## Current power profile. Updated by the 30-second poll timer.
|
||||
var power_profile: PowerProfile = PowerProfile.FULL
|
||||
|
||||
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
|
||||
var raw_power_state: int = 0
|
||||
|
||||
## Battery charge percentage (0–100). -1 if not available or not on battery.
|
||||
var battery_percent: int = -1
|
||||
|
||||
## How often (seconds) to re-poll OS for power state changes.
|
||||
const POWER_POLL_INTERVAL := 30.0
|
||||
|
||||
@@ -48,6 +39,15 @@ const _POWER_STATE_NO_BATTERY := 2
|
||||
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
|
||||
|
||||
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
|
||||
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 -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
extends Node
|
||||
|
||||
# Connection states
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
# Type is TestHarness — untyped to avoid autoload parse-order issue.
|
||||
var harness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
# Transport layer (non-test mode)
|
||||
var _bridge: LocalBridge = null
|
||||
var _server: ServerProcess = null
|
||||
var server_port: int = 9876 # Default matches server's default bind address
|
||||
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
||||
|
||||
# Connection retry state — handles server startup delay (Critical fix #1)
|
||||
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
|
||||
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
|
||||
# Handshake state (#556)
|
||||
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
# Signals
|
||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||
signal snapshot_received(snapshot: Dictionary)
|
||||
signal handshake_complete(protocol_version: int)
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = load("res://scripts/protocol/test_harness.gd").new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
# Connection states
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
# Connection retry state — handles server startup delay (Critical fix #1)
|
||||
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
|
||||
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
# Handshake state (#556)
|
||||
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||
|
||||
func reset_test_state() -> void:
|
||||
if harness: harness.reset()
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
# Type is TestHarness — untyped to avoid autoload parse-order issue.
|
||||
var harness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var server_port: int = 9876 # Default matches server's default bind address
|
||||
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
# Transport layer (non-test mode)
|
||||
var _bridge: LocalBridge = null
|
||||
var _server: ServerProcess = null
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
@@ -83,6 +67,24 @@ var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = load("res://scripts/protocol/test_harness.gd").new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
func reset_test_state() -> void:
|
||||
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
|
||||
@@ -144,7 +146,7 @@ func _try_connect() -> void:
|
||||
_bridge = null
|
||||
|
||||
# Poll transport layer every frame (non-test mode only)
|
||||
func _process(delta: float) -> void:
|
||||
func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if test_mode:
|
||||
return
|
||||
|
||||
@@ -234,7 +236,8 @@ func _process(delta: float) -> void:
|
||||
|
||||
# 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)
|
||||
var startup_bytes := Protocol.encode_startup_message(
|
||||
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:
|
||||
|
||||
@@ -135,12 +135,13 @@ func reset() -> void:
|
||||
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])
|
||||
push_warning(
|
||||
"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:
|
||||
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
|
||||
+23
-23
@@ -59,24 +59,6 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
@@ -96,11 +78,6 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
@@ -115,3 +92,26 @@ const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — second
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
class_name DialogueCoordinator
|
||||
## Dialogue snapshot consumers and signal handlers extracted from main.gd (#775).
|
||||
##
|
||||
## Owns NPC identity tracking state (_last_dialogue_npc_id/name) shared between
|
||||
## consuming dialogue snapshots and handling dialogue_box signals.
|
||||
## Registered with SnapshotEventRouter; reads from GameState directly.
|
||||
|
||||
var dialogue_box: Node = null
|
||||
var monologue_display: Node = null
|
||||
var journal_panel: Node = null
|
||||
|
||||
# Shared mutable reference — GDScript arrays are reference types.
|
||||
# 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_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1
|
||||
|
||||
|
||||
func init(refs: Dictionary, pending_record_inputs: Array) -> DialogueCoordinator:
|
||||
dialogue_box = refs.get("dialogue_box")
|
||||
monologue_display = refs.get("monologue_display")
|
||||
journal_panel = refs.get("journal_panel")
|
||||
_pending_record_inputs = pending_record_inputs
|
||||
return self
|
||||
|
||||
|
||||
## Connect dialogue_box signals. Call from main.gd._ready() after init().
|
||||
func connect_signals() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
dialogue_box.option_selected.connect(on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(on_dialogue_dismissed)
|
||||
dialogue_box.confrontation_monologue.connect(on_confrontation_monologue)
|
||||
dialogue_box.pause_requested.connect(on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(on_dialogue_unpause_requested)
|
||||
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
||||
dialogue_box.dialogue_state_changed.connect(on_dialogue_state_changed)
|
||||
dialogue_box.audio_dip_requested.connect(on_audio_dip_requested)
|
||||
dialogue_box.audio_dip_cleared.connect(on_audio_dip_cleared)
|
||||
|
||||
|
||||
# -- 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:
|
||||
return
|
||||
if GameState.current_tick == _last_dialogue_tick:
|
||||
return
|
||||
if dialogue_box.is_dialogue_active():
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens
|
||||
if journal_panel and journal_panel.has_method("close"):
|
||||
journal_panel.close()
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||
func consume_conversation_events() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_events:
|
||||
dialogue_box.append_conversation_event(event)
|
||||
GameState.conversation_events = []
|
||||
|
||||
|
||||
# #535: Handle conversation_ended events.
|
||||
func consume_conversation_ended() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_ended:
|
||||
dialogue_box.on_conversation_ended(event)
|
||||
GameState.conversation_ended = []
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
func consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# -- 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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-063: Confrontation beat monologue -> show on monologue display (layer 7)
|
||||
func on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-061: Auto-pause on dialogue open
|
||||
func on_dialogue_pause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-061: Auto-unpause on dialogue close
|
||||
func on_dialogue_unpause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# 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",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes.
|
||||
func on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Route audio dip requests.
|
||||
func on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Route audio dip clear.
|
||||
func on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
+61
-383
@@ -1,5 +1,15 @@
|
||||
extends Node2D
|
||||
|
||||
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
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@@ -26,37 +36,17 @@ extends Node2D
|
||||
@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)
|
||||
|
||||
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 _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _known_triangle_ids: Dictionary = {} # #590: triangle_ids that have already fired the activation chime
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
|
||||
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
||||
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
|
||||
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
|
||||
# would conflict because we'd be setting global_position to the target every frame.
|
||||
camera.position_smoothing_enabled = false
|
||||
|
||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
|
||||
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
|
||||
# In live mode, state is CONNECTING — signal handler dispatches once connected.
|
||||
# #257: Deferred load dispatch
|
||||
if not GameState.pending_load_path.is_empty():
|
||||
if loading_screen:
|
||||
loading_screen.show_loading()
|
||||
@@ -66,46 +56,52 @@ func _ready() -> void:
|
||||
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
|
||||
|
||||
# Camera anchor: snap to player position before the first frame renders.
|
||||
# In test mode poll_snapshot() returns synchronously — position is set
|
||||
# immediately. In live mode the snapshot isn't available yet — _process
|
||||
# handles it via the lerp block in _process().
|
||||
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if first_snapshot != null:
|
||||
GameState.apply_snapshot(first_snapshot)
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# D-061: Connect dialogue box signals
|
||||
if dialogue_box:
|
||||
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
||||
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
|
||||
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
|
||||
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
|
||||
# #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)
|
||||
|
||||
_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
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
|
||||
# #559: Register snapshot dispatch handlers
|
||||
_router = SnapshotEventRouter.new()
|
||||
# Always-run: child nodes that update from GameState on every snapshot tick.
|
||||
if world_renderer:
|
||||
_router.register_always(world_renderer.update_from_state)
|
||||
_router.register_always(_propagate_insert_state)
|
||||
_router.register_always(_update_interaction_list)
|
||||
_router.register_always(_consumers.propagate_insert_state)
|
||||
_router.register_always(_consumers.update_interaction_list)
|
||||
if inventory_grid:
|
||||
_router.register_always(inventory_grid.update_from_state)
|
||||
if stance_indicator:
|
||||
_router.register_always(stance_indicator.update_from_state)
|
||||
if fog_entities:
|
||||
_router.register_always(fog_entities.update_from_state)
|
||||
_router.register_always(_play_recognition_chimes)
|
||||
_router.register_always(_handle_triangle_crisis_events)
|
||||
_router.register_always(_consumers.play_recognition_chimes)
|
||||
_router.register_always(_consumers.handle_triangle_crisis_events)
|
||||
if gauntlet_hud:
|
||||
_router.register_always(gauntlet_hud.update_from_state)
|
||||
if checklist_overlay:
|
||||
@@ -118,27 +114,27 @@ func _ready() -> void:
|
||||
_router.register_always(journal_panel.update_from_state)
|
||||
if debug_overlay:
|
||||
_router.register_always(debug_overlay.update_from_state)
|
||||
_router.register_always(_play_close_sound_events)
|
||||
_router.register_always(_update_zone)
|
||||
_router.register_always(_update_listening_focus)
|
||||
_router.register_always(_consume_examine_result)
|
||||
_router.register_always(_consumers.play_close_sound_events)
|
||||
_router.register_always(_consumers.update_zone)
|
||||
_router.register_always(_consumers.update_listening_focus)
|
||||
_router.register_always(_consumers.consume_examine_result)
|
||||
# Keyed: consume methods guarded by specific snapshot fields.
|
||||
_router.register("current_monologue", _consume_monologue)
|
||||
_router.register("current_dialogue", _consume_dialogue)
|
||||
_router.register("conversation_events", _consume_conversation_events)
|
||||
_router.register("conversation_ended", _consume_conversation_ended)
|
||||
_router.register("dialogue_response", _consume_dialogue_response)
|
||||
_router.register("save_result", _consume_save_result)
|
||||
_router.register("debug_response", _consume_debug_response)
|
||||
_router.register("current_monologue", _consumers.consume_monologue)
|
||||
_router.register("current_dialogue", _dialogue.consume_dialogue)
|
||||
_router.register("conversation_events", _dialogue.consume_conversation_events)
|
||||
_router.register("conversation_ended", _dialogue.consume_conversation_ended)
|
||||
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
|
||||
_router.register("save_result", _consumers.consume_save_result)
|
||||
_router.register("debug_response", _consumers.consume_debug_response)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
|
||||
# #581: Wire settings_dialog debug console toggle
|
||||
if settings_dialog and debug_console:
|
||||
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
|
||||
|
||||
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
|
||||
# #581 D-088: Wire debug console pause/unpause
|
||||
if debug_console:
|
||||
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested)
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
@@ -160,20 +156,15 @@ func _process(delta: float) -> void:
|
||||
_teleport_transition()
|
||||
|
||||
# Late anchor: live mode — first snapshot arrives during _process.
|
||||
# Smoothing is already OFF (disabled in _ready), so setting
|
||||
# global_position takes effect immediately with no lerp.
|
||||
if not _camera_anchored:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
||||
# Init: camera already snapped in _ready() or late-anchor path above.
|
||||
# #117: Manual exponential smoothing.
|
||||
if _camera_anchored:
|
||||
var target := GameState.player_position * Constants.TILE_SIZE
|
||||
if _teleport_in_progress:
|
||||
@@ -184,11 +175,9 @@ func _process(delta: float) -> void:
|
||||
camera.global_position = camera.global_position.lerp(target, weight)
|
||||
|
||||
# Send queued input to simulation
|
||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
|
||||
var inputs = InputMapper.flush_queue()
|
||||
for input in inputs:
|
||||
# #495: F12 WRONG button — client-only, trigger bug report capture
|
||||
# #495: F12 WRONG button — client-only
|
||||
if input.action == InputMapper.Action.BUG_REPORT:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
@@ -197,7 +186,7 @@ func _process(delta: float) -> void:
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails)
|
||||
# #257: LOAD_GAME — send first, then show loading screen
|
||||
if input.action == InputMapper.Action.LOAD_GAME:
|
||||
var err := SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
@@ -224,7 +213,6 @@ func _process(delta: float) -> void:
|
||||
if target_id < 0 and interaction_prompt:
|
||||
target_id = interaction_prompt.get_interaction_target()
|
||||
verb = interaction_prompt.get_selected_verb()
|
||||
# Always send struct form for Interact (#415) — server expects named fields
|
||||
if target_id >= 0:
|
||||
input["action_data"] = {
|
||||
"target_entity_id": target_id,
|
||||
@@ -238,9 +226,7 @@ func _process(delta: float) -> void:
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
|
||||
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
|
||||
# then clears the accumulator for the next tick.
|
||||
# #507: Record tick data to ring buffer
|
||||
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
||||
bug_report_dialog.record_tick(
|
||||
GameState.current_tick,
|
||||
@@ -250,296 +236,13 @@ func _process(delta: float) -> void:
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
func _propagate_insert_state() -> void:
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer:
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise.
|
||||
func _update_interaction_list() -> void:
|
||||
if not interaction_list:
|
||||
return
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
# Consume-once: events are cleared after processing so they don't replay if
|
||||
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
|
||||
func _play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
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))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
# "The chime marks the character's attention shifting" (D-067).
|
||||
# IDs persist for the session — one chime per entity, no re-trigger on
|
||||
# fog oscillation or server re-send. Cleared on room change (teleport).
|
||||
func _play_recognition_chimes() -> void:
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# #590 D-072/D-089: Triangle activation consumer — fires sfx_monologue_chime_urgent once
|
||||
# per triangle_id. The tell_state on the activated NPC and subsequent proximity monologue
|
||||
# lines are the visible consequence (D-039 wow moment #2 "The Character's Eye").
|
||||
# No overlay is shown — the chime is the only client-side reaction (D-039 intent).
|
||||
func _handle_triangle_crisis_events() -> void:
|
||||
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
|
||||
for ev in events:
|
||||
if not ev is Dictionary or not ev.has("triangle_id"):
|
||||
continue
|
||||
var tid: int = ev.triangle_id
|
||||
if not _known_triangle_ids.has(tid):
|
||||
_known_triangle_ids[tid] = true
|
||||
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
|
||||
# Only activates when no other dip (dialogue/confrontation) is running.
|
||||
# Only deactivates its own dip — never touches dialogue/confrontation.
|
||||
# D-070: no UI indicator — the boost is "felt, not computed."
|
||||
func _update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
# Tick guard prevents re-triggering when the same tick is polled multiple
|
||||
# times (client FPS > sim tick rate).
|
||||
func _consume_monologue() -> void:
|
||||
if GameState.current_monologue == null or not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_monologue_tick:
|
||||
return
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
return
|
||||
if GameState.current_tick == _last_dialogue_tick:
|
||||
return
|
||||
if dialogue_box.is_dialogue_active():
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens (cannot be open simultaneously)
|
||||
if journal_panel and journal_panel.has_method("close"):
|
||||
journal_panel.close()
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||
# Each event carries pre-occluded text — render verbatim in the dialogue log.
|
||||
func _consume_conversation_events() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_events:
|
||||
dialogue_box.append_conversation_event(event)
|
||||
GameState.conversation_events = []
|
||||
|
||||
|
||||
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
|
||||
func _consume_conversation_ended() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_ended:
|
||||
dialogue_box.on_conversation_ended(event)
|
||||
GameState.conversation_ended = []
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
# Updates dialogue_box entity display registry with speaker identity from the wire.
|
||||
func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
|
||||
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# #554/#257: Show save/load result notification; hide loading screen on load complete.
|
||||
func _consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null # consume once
|
||||
# #257: Dismiss loading screen regardless of success/failure
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading()
|
||||
var msg: String
|
||||
if result.get("success", false):
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_complete")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_complete")
|
||||
else:
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_failed")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_failed")
|
||||
if monologue_display:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func _consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# 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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
|
||||
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
|
||||
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_pause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_unpause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# 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",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
|
||||
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
|
||||
func _on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
|
||||
func _on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
|
||||
func _on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# #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.
|
||||
# pending_load_path is set by main_menu.gd before scene change.
|
||||
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
@@ -564,19 +267,13 @@ func _dispatch_pending_load() -> void:
|
||||
loading_screen.hide_loading(false)
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
# #501: Detect large position jump indicating a teleport.
|
||||
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
|
||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||
func _teleport_transition() -> void:
|
||||
# Set teleport flag — the camera tracking block in _process() will snap
|
||||
# to the player's new position this frame (no lerp). Flag clears after snap.
|
||||
_camera_anchored = true
|
||||
_teleport_in_progress = true
|
||||
|
||||
@@ -584,12 +281,11 @@ func _teleport_transition() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||
_known_triangle_ids.clear() # #590: reset activation chimes for new room
|
||||
_consumers.clear_recognition_state()
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
# Fade from black: instant black overlay, fades to transparent over 0.3s
|
||||
# Fade from black
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
@@ -602,35 +298,17 @@ func _teleport_transition() -> void:
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
||||
func _consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
# Dismiss existing examine result if dialogue is active (focus priority)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if examine_display.has_method("dismiss"):
|
||||
examine_display.dismiss()
|
||||
else:
|
||||
if examine_display.has_method("show_result"):
|
||||
examine_display.show_result(result)
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
||||
# #264: Toggle journal panel.
|
||||
func _toggle_journal() -> void:
|
||||
if not journal_panel:
|
||||
return
|
||||
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
return
|
||||
if journal_panel.has_method("toggle"):
|
||||
journal_panel.toggle()
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
# #502: Full-screen color flash.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
|
||||
@@ -93,7 +93,8 @@ func _try_extract_message() -> PackedByteArray:
|
||||
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])
|
||||
push_error(
|
||||
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
|
||||
_corrupt = true
|
||||
_pending_length = -1
|
||||
_read_buffer.clear()
|
||||
|
||||
@@ -35,7 +35,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
# Version check: reject snapshots from incompatible server
|
||||
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])
|
||||
push_error(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
|
||||
return null
|
||||
|
||||
var entities: Array[Dictionary] = []
|
||||
@@ -49,7 +50,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
dropped += 1
|
||||
|
||||
if dropped > 0:
|
||||
push_error("Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
|
||||
push_error(
|
||||
"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).
|
||||
@@ -484,12 +486,11 @@ static func _decode_verb_option(raw) -> Variant:
|
||||
static func _decode_enum_variant(raw) -> Dictionary:
|
||||
if raw is String:
|
||||
return { "variant": raw, "data": null }
|
||||
elif raw is Dictionary and raw.size() == 1:
|
||||
if raw is Dictionary and raw.size() == 1:
|
||||
var variant_name: String = raw.keys()[0]
|
||||
return { "variant": variant_name, "data": raw[variant_name] }
|
||||
else:
|
||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||
return { "variant": "Unknown", "data": raw }
|
||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||
return { "variant": "Unknown", "data": raw }
|
||||
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
@@ -499,7 +500,8 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
## 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:
|
||||
static func encode_startup_message(
|
||||
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".
|
||||
|
||||
@@ -5,6 +5,20 @@ extends RefCounted
|
||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||
## boundary (no game logic in the production client autoload).
|
||||
|
||||
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),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
var tick: int = 0
|
||||
var player_pos: Vector2i = Vector2i(10, 10)
|
||||
var facing: String = "North"
|
||||
@@ -116,9 +130,9 @@ func snapshot() -> Dictionary:
|
||||
"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},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
{"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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -146,7 +160,7 @@ func snapshot() -> Dictionary:
|
||||
{"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."},
|
||||
{"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
|
||||
@@ -259,21 +273,6 @@ func _get_tile_type(x: int, y: int) -> String:
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
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),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
|
||||
@@ -451,7 +451,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE, render_priority: int = 0) -> BoneAttachment3D:
|
||||
func _attach_to_bone(
|
||||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0) -> BoneAttachment3D:
|
||||
if _skeleton == null:
|
||||
return null
|
||||
if not ResourceLoader.exists(path):
|
||||
@@ -502,14 +503,13 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE,
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
inst.queue_free()
|
||||
return null
|
||||
else:
|
||||
# Unskinned — rigid attachment via BoneAttachment3D
|
||||
for mi in meshes:
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
attachment.add_child(mi)
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
inst.queue_free()
|
||||
# Unskinned — rigid attachment via BoneAttachment3D
|
||||
for mi in meshes:
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
attachment.add_child(mi)
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
inst.queue_free()
|
||||
return attachment
|
||||
|
||||
|
||||
@@ -629,7 +629,8 @@ 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:
|
||||
func _apply_tinted_shader(
|
||||
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()):
|
||||
|
||||
@@ -5,19 +5,11 @@ extends Node2D
|
||||
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
|
||||
## Detects entity hover via world-space proximity to visible entities.
|
||||
|
||||
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
|
||||
# --- Public ---
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
signal state_changed(new_state: State)
|
||||
signal state_changed(new_state: int)
|
||||
signal hovered_entity_changed(entity_id: int)
|
||||
|
||||
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
|
||||
# D-056 colors
|
||||
const COLOR_DEFAULT := Color("#c8d0e0")
|
||||
const COLOR_OBJECT := Color("#8b8ba0")
|
||||
@@ -31,6 +23,14 @@ const HOVER_RADIUS_PX := 16.0 # World pixels — ~half a tile
|
||||
const BRACKET_HALF := 26.0
|
||||
const BRACKET_ARM := 8.0
|
||||
|
||||
# --- Public ---
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
# --- Transition state ---
|
||||
var _target: State = State.DEFAULT
|
||||
var _t: float = 1.0
|
||||
|
||||
@@ -25,6 +25,8 @@ const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for co
|
||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
|
||||
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)
|
||||
@@ -32,9 +34,6 @@ var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last re
|
||||
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")
|
||||
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
|
||||
func _ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
|
||||
@@ -111,7 +110,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
_entity_facing[entity_id] = direction
|
||||
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])
|
||||
push_error(
|
||||
"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.
|
||||
|
||||
@@ -5,13 +5,14 @@ extends Node2D
|
||||
## Architecture: docs/architecture/fog-shader-spec.md
|
||||
|
||||
signal fog_noise_ready
|
||||
|
||||
const TILE_SIZE := float(Constants.TILE_SIZE)
|
||||
|
||||
var _noise_ready: bool = false
|
||||
|
||||
var _fog_rect: ColorRect
|
||||
var _shader_mat: ShaderMaterial
|
||||
|
||||
const TILE_SIZE := float(Constants.TILE_SIZE)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Create the fog overlay ColorRect — transparent fallback so a shader failure
|
||||
|
||||
@@ -11,11 +11,11 @@ extends TileMapLayer
|
||||
# (3,0) = object — teal
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
"floor": TileType.FLOOR,
|
||||
|
||||
@@ -13,13 +13,13 @@ extends Node2D
|
||||
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
|
||||
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
@onready var tile_renderer = $FogGroup/FloorTiles
|
||||
@onready var fog_renderer = $FogOverlay
|
||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
print("WorldRenderer: Initialized (D-049 z-stack)")
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
class_name SnapshotConsumers
|
||||
## Non-dialogue snapshot consumers and event handlers extracted from main.gd (#775).
|
||||
##
|
||||
## Registered with SnapshotEventRouter; reads from GameState directly.
|
||||
## UI node references passed via init(). All methods are zero-argument
|
||||
## callables compatible with SnapshotEventRouter.
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost
|
||||
|
||||
var monologue_display: Node = null
|
||||
var dialogue_box: Node = null
|
||||
var examine_display: Node = null
|
||||
var loading_screen: Node = null
|
||||
var debug_console: Node = null
|
||||
var cursor_renderer: Node = null
|
||||
var interaction_list: Node = null
|
||||
var interaction_prompt: Node = null
|
||||
var minimap: Node = null
|
||||
var star_map: Node = null
|
||||
|
||||
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
|
||||
|
||||
var _last_monologue_tick: int = -1
|
||||
var _known_recognition_ids: Dictionary = {}
|
||||
var _known_triangle_ids: Dictionary = {}
|
||||
var _current_zone: String = ""
|
||||
|
||||
|
||||
func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
|
||||
monologue_display = refs.get("monologue_display")
|
||||
dialogue_box = refs.get("dialogue_box")
|
||||
examine_display = refs.get("examine_display")
|
||||
loading_screen = refs.get("loading_screen")
|
||||
debug_console = refs.get("debug_console")
|
||||
cursor_renderer = refs.get("cursor_renderer")
|
||||
interaction_list = refs.get("interaction_list")
|
||||
interaction_prompt = refs.get("interaction_prompt")
|
||||
minimap = refs.get("minimap")
|
||||
star_map = refs.get("star_map")
|
||||
_screen_flash_fn = screen_flash
|
||||
return self
|
||||
|
||||
|
||||
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||
func propagate_insert_state() -> void:
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer:
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
if star_map:
|
||||
star_map.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise.
|
||||
func update_interaction_list() -> void:
|
||||
if not interaction_list:
|
||||
return
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
func play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
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))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
func play_recognition_chimes() -> void:
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# #590 D-072/D-089: Triangle activation consumer.
|
||||
func handle_triangle_crisis_events() -> void:
|
||||
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
|
||||
for ev in events:
|
||||
if not ev is Dictionary or not ev.has("triangle_id"):
|
||||
continue
|
||||
var tid: int = ev.triangle_id
|
||||
if not _known_triangle_ids.has(tid):
|
||||
_known_triangle_ids[tid] = true
|
||||
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade.
|
||||
func update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
func update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
func consume_monologue() -> void:
|
||||
if GameState.current_monologue == null or not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_monologue_tick:
|
||||
return
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash_fn.call(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
# #554/#257: Show save/load result notification; hide loading screen on load complete.
|
||||
func consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading()
|
||||
var msg: String
|
||||
if result.get("success", false):
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_complete")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_complete")
|
||||
else:
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_failed")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_failed")
|
||||
if monologue_display:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
func consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if examine_display.has_method("dismiss"):
|
||||
examine_display.dismiss()
|
||||
else:
|
||||
if examine_display.has_method("show_result"):
|
||||
examine_display.show_result(result)
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
## Clear session-scoped recognition state (call on teleport / room change).
|
||||
func clear_recognition_state() -> void:
|
||||
_known_recognition_ids.clear()
|
||||
_known_triangle_ids.clear()
|
||||
@@ -0,0 +1,254 @@
|
||||
class_name SnapshotHandler
|
||||
## Applies ObserverSnapshot data to GameState fields (D-020).
|
||||
##
|
||||
## Extracted from game_state.gd to separate snapshot parsing from state storage.
|
||||
## All methods are static — no instance state required.
|
||||
## Called via GameState.apply_snapshot() which delegates here.
|
||||
|
||||
# DEPRECATED: Client-side stationary_ticks fallback. Remove when server sends
|
||||
# "stationary_ticks" in ObserverSnapshot (D-020 violation).
|
||||
static var _prev_player_position: Vector2 = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
static func apply(snapshot: Dictionary) -> void:
|
||||
GameState.current_snapshot = snapshot
|
||||
|
||||
if snapshot.has("tick"):
|
||||
GameState.current_tick = snapshot.tick
|
||||
|
||||
if snapshot.has("entities"):
|
||||
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":
|
||||
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()])
|
||||
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
GameState.stationary_ticks = snapshot.stationary_ticks
|
||||
else:
|
||||
# DEPRECATED fallback — client-side accumulation. Remove when server sends field.
|
||||
if GameState.player_position == _prev_player_position:
|
||||
GameState.stationary_ticks += 1
|
||||
else:
|
||||
GameState.stationary_ticks = 0
|
||||
_prev_player_position = GameState.player_position
|
||||
|
||||
# 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:
|
||||
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")
|
||||
if has_type:
|
||||
GameState.visible_tiles = snapshot.visible_tiles
|
||||
|
||||
if snapshot.has("visible_positions"):
|
||||
GameState.visible_positions.clear()
|
||||
for pos in snapshot.visible_positions:
|
||||
GameState.visible_positions[Vector2i(pos.x, pos.y)] = true
|
||||
|
||||
# v2: game_time (D-031)
|
||||
if snapshot.has("game_time") and snapshot.game_time is Dictionary:
|
||||
GameState.game_time = snapshot.game_time
|
||||
|
||||
# v2: player_facing (D-015)
|
||||
if snapshot.has("player_facing") and snapshot.player_facing is String:
|
||||
GameState.player_facing = snapshot.player_facing
|
||||
|
||||
# v4: nearby_interactions (#404/#405)
|
||||
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
|
||||
GameState.nearby_interactions = snapshot.nearby_interactions
|
||||
else:
|
||||
GameState.nearby_interactions = []
|
||||
|
||||
# v5: current_monologue (#414)
|
||||
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
|
||||
GameState.current_monologue = snapshot.current_monologue
|
||||
else:
|
||||
GameState.current_monologue = null
|
||||
|
||||
# #122: lattice_profile
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
GameState.lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
GameState.player_stance = snapshot.player_stance
|
||||
|
||||
# v6: player_inventory (#449, D-065)
|
||||
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
|
||||
GameState.player_inventory = snapshot.player_inventory
|
||||
else:
|
||||
GameState.player_inventory = []
|
||||
|
||||
# v7: current_dialogue (#434, D-061)
|
||||
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
|
||||
GameState.current_dialogue = snapshot.current_dialogue
|
||||
else:
|
||||
GameState.current_dialogue = null
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060)
|
||||
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
|
||||
GameState.pending_recognitions = snapshot.pending_recognitions
|
||||
else:
|
||||
GameState.pending_recognitions = []
|
||||
|
||||
# v9: conversation_events (#535, D-078)
|
||||
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||
GameState.conversation_events = snapshot.conversation_events
|
||||
else:
|
||||
GameState.conversation_events = []
|
||||
|
||||
# v9: conversation_ended (#535, D-078)
|
||||
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||
GameState.conversation_ended = snapshot.conversation_ended
|
||||
else:
|
||||
GameState.conversation_ended = []
|
||||
|
||||
# v8: dialogue_response (#305, D-028)
|
||||
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||
GameState.dialogue_response = snapshot.dialogue_response
|
||||
else:
|
||||
GameState.dialogue_response = null
|
||||
|
||||
# v8: gauntlet mode (#496)
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
GameState.gauntlet_mode = true
|
||||
else:
|
||||
GameState.gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
GameState.room_id = snapshot.room_id
|
||||
else:
|
||||
GameState.room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
GameState.insert_active = snapshot.insert_active
|
||||
else:
|
||||
GameState.insert_active = true
|
||||
|
||||
# #507: rng_seed
|
||||
if snapshot.has("rng_seed"):
|
||||
GameState.rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
GameState.rng_seed = null
|
||||
|
||||
# D-018: Sound events — partition by range_category.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
GameState.medium_sound_events = []
|
||||
GameState.close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
GameState.medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
GameState.close_sound_events.append(se)
|
||||
else:
|
||||
GameState.medium_sound_events = []
|
||||
GameState.close_sound_events = []
|
||||
|
||||
# v10: discovered_pois (#151, D-013)
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
GameState.discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
GameState.discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242)
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
GameState.current_examine_result = snapshot.examine_result
|
||||
else:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
# v15: save_result (#554, D-085)
|
||||
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||
GameState.save_result = snapshot.save_result
|
||||
else:
|
||||
GameState.save_result = null
|
||||
|
||||
# v18: debug_response (#580)
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
GameState.debug_response = snapshot.debug_response
|
||||
else:
|
||||
GameState.debug_response = null
|
||||
|
||||
# v20: settings_response (#627, D-138)
|
||||
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
|
||||
GameState.settings_response = snapshot.settings_response
|
||||
var sr: Dictionary = snapshot.settings_response
|
||||
if sr.get("kind") == "full":
|
||||
var sr_settings: Variant = sr.get("settings")
|
||||
if sr_settings is Array:
|
||||
for entry in sr_settings:
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
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)
|
||||
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:
|
||||
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
|
||||
if CVD != null:
|
||||
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
|
||||
if restored != null:
|
||||
GameState.character_visual_descriptor = restored
|
||||
|
||||
# v14: player_knowledge (#264, D-041)
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
GameState.player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||
GameState.current_zone_id = snapshot.zone_id
|
||||
else:
|
||||
# DEPRECATED fallback — client-side tile lookup. Remove when server sends top-level "zone_id".
|
||||
var tile_by_coord: Dictionary = {}
|
||||
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_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:
|
||||
GameState.visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
GameState.visibility_sectors[pos] = vis_sector
|
||||
if vis_sector == "BoundaryWall":
|
||||
GameState.boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
GameState.visible_positions[pos] = true
|
||||
|
||||
|
||||
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
|
||||
static func _extract_bool_setting(key: String, val: Variant) -> bool:
|
||||
if val is bool:
|
||||
return val
|
||||
if val is Dictionary and val.has("Bool"):
|
||||
return bool(val["Bool"])
|
||||
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
|
||||
return false
|
||||
@@ -10,7 +10,7 @@
|
||||
## run before the project's class_name registry is fully populated.
|
||||
extends SceneTree
|
||||
|
||||
var _Msgpack: GDScript
|
||||
var _msgpack: GDScript
|
||||
var _count := 0
|
||||
var _errors := 0
|
||||
var _output_dir: String
|
||||
@@ -21,7 +21,7 @@ func _init():
|
||||
|
||||
|
||||
func _run():
|
||||
_Msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||
_msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
# Resolve repo root from Godot project root (client/).
|
||||
# Assumes client/ is one level below repo root — validated below.
|
||||
@@ -81,7 +81,7 @@ func _encode_input(tick: int, action_name: String, action_data: Variant = null)
|
||||
else:
|
||||
action = action_name
|
||||
|
||||
var result = _Msgpack.encode({"tick": tick, "action": action})
|
||||
var result = _msgpack.encode({"tick": tick, "action": action})
|
||||
if result.status != null:
|
||||
push_error("Encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -104,7 +104,7 @@ func _encode_inputs(inputs: Array) -> PackedByteArray:
|
||||
action = action_name
|
||||
wire_inputs.append({"tick": input["tick"], "action": action})
|
||||
|
||||
var result = _Msgpack.encode(wire_inputs)
|
||||
var result = _msgpack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Batch encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
class_name TestAiDialogueSprint26
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const SETTINGS_DIALOG_SCENE = preload("res://ui/settings_dialog.tscn")
|
||||
|
||||
var _original_ai_enabled: bool = true
|
||||
|
||||
@@ -345,7 +346,7 @@ func test_hardware_classify_degradation_ok_at_exact_40_percent() -> void:
|
||||
|
||||
func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
|
||||
# settings_dialog needs a testable API — hardcoded UI strings are easy to drift.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -359,7 +360,7 @@ func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
|
||||
|
||||
func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
|
||||
# D-138: label must be exactly "AI-Enhanced Dialogue" (Jeroen's wording).
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -377,7 +378,7 @@ func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
|
||||
func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
|
||||
# D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out.
|
||||
# Player receives message but cannot enable the feature.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -396,7 +397,7 @@ func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
|
||||
|
||||
func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
|
||||
# "pass" → toggle available to interact with.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -415,7 +416,7 @@ func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
|
||||
|
||||
func test_settings_dialog_toggle_enabled_when_hardware_marginal() -> void:
|
||||
# D-138 §8: "marginal" → warn but let player proceed. Never force-disable.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -439,8 +440,8 @@ func test_hardware_detector_benchmark_cache_path_is_correct() -> void:
|
||||
var det := _get_detector()
|
||||
if det == null:
|
||||
return
|
||||
assert_str(det.BENCHMARK_CACHE_PATH).override_failure_message(
|
||||
"BENCHMARK_CACHE_PATH must be 'user://ai-dialogue-config.json' (D-138 §8)"
|
||||
assert_str(det.benchmark_cache_path).override_failure_message(
|
||||
"benchmark_cache_path must be 'user://ai-dialogue-config.json' (D-138 §8)"
|
||||
).is_equal("user://ai-dialogue-config.json")
|
||||
|
||||
|
||||
@@ -766,7 +767,7 @@ func test_hardware_detector_battery_suspend_preserves_player_pref_false() -> voi
|
||||
|
||||
|
||||
func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -779,7 +780,7 @@ func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
|
||||
|
||||
|
||||
func test_settings_dialog_set_inference_suspended_true() -> void:
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -794,7 +795,7 @@ func test_settings_dialog_set_inference_suspended_true() -> void:
|
||||
|
||||
func test_settings_dialog_resume_clears_suspended_state() -> void:
|
||||
# D-138 §8: resume when plugged in — suspended state clears.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -813,7 +814,7 @@ func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> voi
|
||||
# Battery suspend auto-pauses inference but must NOT grey the toggle —
|
||||
# the player can click it to override the suspension.
|
||||
# Only hardware "fail" (RAM < 1.6 GB) may disable the toggle.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -829,7 +830,7 @@ func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> voi
|
||||
|
||||
func test_settings_dialog_toggle_enabled_after_resume() -> void:
|
||||
# After resume (plug-in), toggle must be enabled again.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -847,7 +848,7 @@ func test_settings_dialog_toggle_enabled_after_resume() -> void:
|
||||
func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended() -> void:
|
||||
# Hardware "fail" disables the toggle regardless of battery state.
|
||||
# RAM < 1.6 GB is the only hard disable — battery suspend is not.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -864,7 +865,7 @@ func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended()
|
||||
func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
|
||||
# When inference is battery-suspended, a warning label must be visible
|
||||
# so the player knows why inference isn't running (even though toggle is enabled).
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -882,7 +883,7 @@ func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
|
||||
|
||||
func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void:
|
||||
# Warning label must not show when plugged in — no battery message needed.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
class_name TestAntiTedium
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
@@ -106,7 +107,7 @@ func _make_bug_report_dialog() -> Control:
|
||||
|
||||
func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
# Verify the BugReportDialog node is present and hidden by default.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -125,7 +126,7 @@ func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
func test_bug_report_activates_on_action() -> void:
|
||||
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
||||
# triggers the dialog. This tests the full main._process() handling path.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -170,7 +171,7 @@ func test_bug_report_activates_on_action() -> void:
|
||||
|
||||
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
|
||||
# Load main scene — represents non-Gauntlet (default) play mode
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -224,7 +225,7 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
|
||||
|
||||
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
# Simulate several ticks of normal play — gauntlet UI must never appear.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -18,13 +18,13 @@ func before_test() -> void:
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -480,7 +480,7 @@ func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
@@ -497,7 +497,7 @@ func test_d067_completion_is_when_entity_absent_from_pending() -> void:
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
class_name TestBugReportRingBuffer
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
# Expected ring buffer capacity per spec.
|
||||
const EXPECTED_CAPACITY := 60
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
# Reset GameState fields mutated by tests to prevent cross-test leakage.
|
||||
|
||||
@@ -9,6 +9,7 @@ extends GdUnitTestSuite
|
||||
|
||||
const EXPECTED_PLAYER_POS := Vector2(10, 10)
|
||||
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
@@ -67,7 +68,7 @@ func test_apply_snapshot_sets_player_position() -> void:
|
||||
# --- Camera anchor after _ready() ---
|
||||
|
||||
func test_camera_position_after_ready() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -77,7 +78,7 @@ func test_camera_position_after_ready() -> void:
|
||||
|
||||
|
||||
func test_camera_anchored_flag_after_ready() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -88,7 +89,7 @@ func test_camera_anchored_flag_after_ready() -> void:
|
||||
func test_camera_smoothing_off_after_ready() -> void:
|
||||
# Camera smoothing must be disabled during init to prevent lerp from (0,0).
|
||||
# If this test fails, the camera will visibly drift from origin to player.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -100,7 +101,7 @@ func test_camera_smoothing_off_after_ready() -> void:
|
||||
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
|
||||
# CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -114,7 +115,7 @@ func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||
# --- Camera behavior across frames ---
|
||||
|
||||
func test_camera_tracks_player_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -130,7 +131,7 @@ func test_camera_tracks_player_after_process() -> void:
|
||||
func test_camera_lerps_toward_player_movement() -> void:
|
||||
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
|
||||
# After one 16ms frame the camera should be partway between old and new position.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -161,7 +161,8 @@ func test_reload_descriptor_clears_previous_nodes() -> void:
|
||||
# Second load must produce exactly the same child count — no more, no less.
|
||||
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
|
||||
assert_int(count_after_second).override_failure_message(
|
||||
"load_descriptor must produce identical child count on reload — stale nodes detected if higher, missing cleanup if lower"
|
||||
"load_descriptor must produce identical child count on reload" +
|
||||
" — stale nodes detected if higher, missing cleanup if lower"
|
||||
).is_equal(count_after_first)
|
||||
|
||||
|
||||
@@ -305,7 +306,8 @@ func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
|
||||
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||||
node.load_descriptor(desc)
|
||||
|
||||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") if node.has_method("get_active_coverage") else {}
|
||||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") \
|
||||
if node.has_method("get_active_coverage") else {}
|
||||
if coverage.is_empty():
|
||||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||||
return
|
||||
|
||||
@@ -57,7 +57,8 @@ func test_parse_top_level_quoted_string() -> void:
|
||||
|
||||
|
||||
func test_parse_single_condition() -> void:
|
||||
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var yaml := ("conditions:\n - id: test-1\n description: \"Test condition\"\n" +
|
||||
" condition_type: player_near\n x: 10\n y: 20\n radius: 3.0")
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.has("conditions")).is_true()
|
||||
var conditions: Array = result["conditions"]
|
||||
@@ -70,7 +71,9 @@ func test_parse_single_condition() -> void:
|
||||
|
||||
|
||||
func test_parse_multiple_conditions() -> void:
|
||||
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
|
||||
var yaml := ("conditions:\n - id: cond-a\n condition_type: player_near\n" +
|
||||
" x: 1\n y: 2\n radius: 1.0\n\n" +
|
||||
" - id: cond-b\n condition_type: player_facing\n direction: East")
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(2)
|
||||
@@ -80,7 +83,8 @@ func test_parse_multiple_conditions() -> void:
|
||||
|
||||
|
||||
func test_parse_comments_ignored() -> void:
|
||||
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
|
||||
var yaml := ("# This is a comment\nroom_id: test\n# Another comment\n" +
|
||||
"conditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5")
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("test")
|
||||
var conditions: Array = result["conditions"]
|
||||
|
||||
@@ -352,7 +352,6 @@ func test_fog_overlay_z_layer() -> void:
|
||||
return
|
||||
# This test needs the scene tree to be set up
|
||||
# Verify via scene file inspection rather than runtime
|
||||
pass
|
||||
|
||||
|
||||
# -- Noise animation cycles (D-059) -------------------------------------------
|
||||
|
||||
@@ -19,7 +19,7 @@ func before_each() -> void:
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.insert_active = true
|
||||
|
||||
@@ -67,7 +67,8 @@ func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||
|
||||
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||
var interactions := [
|
||||
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2, "verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||
{"entity_id": 5, "entity_type": "Npc", "distance": 1.2,
|
||||
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}]},
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
||||
@@ -83,7 +84,10 @@ func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||
# -- v5: current_monologue (#414) -----------------------------------------
|
||||
|
||||
func test_apply_snapshot_sets_monologue() -> void:
|
||||
var monologue := {"id": "m1", "text": "Something is off here.", "duration_seconds": 4.0, "priority": 1, "is_urgent": false}
|
||||
var monologue := {
|
||||
"id": "m1", "text": "Something is off here.",
|
||||
"duration_seconds": 4.0, "priority": 1, "is_urgent": false,
|
||||
}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||
|
||||
@@ -14,7 +14,7 @@ extends GdUnitTestSuite
|
||||
|
||||
func before_each() -> void:
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_tiles = []
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
class_name TestHubTeleport
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# -- Fixtures ------------------------------------------------------------------
|
||||
|
||||
@@ -178,8 +180,6 @@ func test_test_mode_teleport_clears_dialogue() -> void:
|
||||
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
|
||||
# These tests verify the distance math against that threshold.
|
||||
|
||||
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
func test_detect_teleport_large_jump() -> void:
|
||||
# Position jump > threshold should be detected as teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
|
||||
@@ -44,7 +44,7 @@ func _make_cursor_or_skip() -> Node:
|
||||
|
||||
func _make_interaction_list() -> Node:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
var scene = load(path)
|
||||
var node = scene.instantiate()
|
||||
|
||||
@@ -12,7 +12,7 @@ extends GdUnitTestSuite
|
||||
|
||||
func _interaction_list_exists() -> bool:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
return true
|
||||
return false
|
||||
@@ -20,7 +20,7 @@ func _interaction_list_exists() -> bool:
|
||||
|
||||
func _make_interaction_list() -> Node:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
var scene = load(path)
|
||||
var node = scene.instantiate()
|
||||
|
||||
@@ -14,7 +14,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
],
|
||||
"nearby_interactions": [{
|
||||
"entity_id": 2,
|
||||
@@ -93,7 +93,7 @@ func test_protocol_decode_v4_entity_relationship() -> void:
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
|
||||
],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
## Smoke tests for main scene initialization
|
||||
## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd
|
||||
## --ignoreHeadlessMode -a res://tests/
|
||||
class_name TestMainScene
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ extends GdUnitTestSuite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
@@ -199,7 +200,7 @@ func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
@@ -226,7 +227,7 @@ func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ class_name TestP0Regressions
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -174,7 +176,7 @@ func test_monologue_carry_forward_preserves_newest() -> void:
|
||||
|
||||
func test_camera_static_during_pause() -> void:
|
||||
# 1. Instantiate main scene — camera anchors at test mode player position
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -204,7 +206,7 @@ func test_camera_static_during_pause() -> void:
|
||||
|
||||
func test_camera_anchored_after_pause_unpause() -> void:
|
||||
# Verify camera stays properly anchored through a pause → unpause cycle.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -346,7 +346,7 @@ func test_full_v6_snapshot_decode() -> void:
|
||||
],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "tile_kind": "Floor"},
|
||||
|
||||
@@ -339,7 +339,7 @@ func test_full_v7_snapshot_decode() -> void:
|
||||
"player_inventory": [{"item_id": 100, "name": "Access Token", "slot": 0}],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
],
|
||||
"visible_tiles": [],
|
||||
"nearby_interactions": [],
|
||||
|
||||
@@ -281,7 +281,8 @@ func test_entity_renderer_npc_uses_unknown_teal() -> void:
|
||||
|
||||
func test_entity_renderer_object_uses_grey() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
|
||||
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0,
|
||||
"kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(obj)
|
||||
var node = renderer.entity_nodes[3] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
@@ -166,7 +168,7 @@ func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
@@ -175,7 +177,7 @@ func test_main_menu_instantiates_without_crash() -> void:
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
@@ -187,7 +189,7 @@ func test_main_menu_has_new_game_button() -> void:
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
class_name TestSignalSprint24
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
|
||||
|
||||
|
||||
# -- #588: Character archetype field ------------------------------------------
|
||||
|
||||
@@ -204,7 +206,7 @@ func test_protocol_decode_current_ticker_null_when_absent() -> void:
|
||||
|
||||
func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
|
||||
# update_from_state() must hide ticker when current_ticker is null.
|
||||
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
|
||||
var ticker_scene := NEWS_TICKER_SCENE
|
||||
assert_that(ticker_scene).is_not_null()
|
||||
var ticker := ticker_scene.instantiate()
|
||||
auto_free(ticker)
|
||||
@@ -224,7 +226,7 @@ func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
|
||||
|
||||
func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
|
||||
# update_from_state() must show ticker when current_ticker has text.
|
||||
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
|
||||
var ticker_scene := NEWS_TICKER_SCENE
|
||||
assert_that(ticker_scene).is_not_null()
|
||||
var ticker := ticker_scene.instantiate()
|
||||
auto_free(ticker)
|
||||
@@ -244,7 +246,7 @@ func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
|
||||
|
||||
func test_news_ticker_hides_when_ticker_becomes_null() -> void:
|
||||
# Ticker shown then hidden: update_from_state() with null current_ticker hides it.
|
||||
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
|
||||
var ticker_scene := NEWS_TICKER_SCENE
|
||||
assert_that(ticker_scene).is_not_null()
|
||||
var ticker := ticker_scene.instantiate()
|
||||
auto_free(ticker)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
class_name TestSmoothCameraSprint15
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ func test_camera_smoothing_speed_constant_reasonable() -> void:
|
||||
|
||||
func test_godot_smoothing_disabled_at_ready() -> void:
|
||||
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -53,7 +55,7 @@ func test_godot_smoothing_disabled_at_ready() -> void:
|
||||
|
||||
func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -70,7 +72,7 @@ func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||
func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||
# #117: When player moves, camera should lerp (not snap) to new position.
|
||||
# After 1 frame at ~60fps, camera should be partway there — not at target.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -90,7 +92,7 @@ func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||
func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||
# #117: After enough frames the camera should be within 1px of target.
|
||||
# At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -111,7 +113,7 @@ func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||
|
||||
func test_camera_stationary_player_no_drift() -> void:
|
||||
# #117: When player is stationary, camera should not drift (lerp to same point).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -133,7 +135,7 @@ func test_camera_stationary_player_no_drift() -> void:
|
||||
func test_teleport_snaps_camera_immediately() -> void:
|
||||
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
|
||||
# Manually displace camera, set the flag, call _process — camera should snap to target.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -155,7 +157,7 @@ func test_teleport_snaps_camera_immediately() -> void:
|
||||
|
||||
func test_teleport_flag_cleared_after_snap() -> void:
|
||||
# #117: _teleport_in_progress must be false after the snap frame.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -170,7 +172,7 @@ func test_teleport_flag_cleared_after_snap() -> void:
|
||||
func test_camera_resumes_lerp_after_teleport() -> void:
|
||||
# #117: Frame after teleport snap must resume lerp (not continue snapping).
|
||||
# After teleport flag clears, any position delta produces lerp movement.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -200,7 +202,7 @@ func test_camera_resumes_lerp_after_teleport() -> void:
|
||||
|
||||
func test_camera_no_rotation() -> void:
|
||||
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
class_name TestTimeDisplaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _clock: Control = null
|
||||
|
||||
|
||||
@@ -275,7 +277,7 @@ func test_sim_bridge_day_phase_is_valid() -> void:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
@@ -283,7 +285,7 @@ func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||
|
||||
func test_insert_clock_time_str_updates_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
class_name TestUIFrameworkSprint15
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -41,7 +43,7 @@ func test_dialogue_max_width_set() -> void:
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
||||
# Constants.CANVAS_INSERT must match.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -53,7 +55,7 @@ func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
|
||||
func test_ui_layer_is_canvas_layer_20() -> void:
|
||||
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -65,7 +67,7 @@ func test_ui_layer_is_canvas_layer_20() -> void:
|
||||
|
||||
func test_modal_layer_is_canvas_layer_30() -> void:
|
||||
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -91,7 +93,7 @@ func test_modal_layer_above_ui_layer() -> void:
|
||||
|
||||
func test_monologue_display_exists_in_ui_layer() -> void:
|
||||
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -101,7 +103,7 @@ func test_monologue_display_exists_in_ui_layer() -> void:
|
||||
|
||||
func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -112,7 +114,7 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
||||
# Sprint 18 #151 (Stig): moved from UILayer to InsertOverlay per D-049 spec.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -122,7 +124,7 @@ func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
|
||||
func test_hud_exists_in_ui_layer() -> void:
|
||||
# D-049: HUD must be in UILayer.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -132,7 +134,7 @@ func test_hud_exists_in_ui_layer() -> void:
|
||||
|
||||
func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -142,7 +144,7 @@ func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||
|
||||
func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -152,7 +154,7 @@ func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||
|
||||
func test_world_radial_exists_in_insert_overlay() -> void:
|
||||
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -162,7 +164,7 @@ func test_world_radial_exists_in_insert_overlay() -> void:
|
||||
|
||||
func test_cursor_renderer_exists_in_ui_layer() -> void:
|
||||
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -182,7 +184,7 @@ func test_gamestate_insert_active_defaults_true() -> void:
|
||||
func test_insert_active_propagates_on_process() -> void:
|
||||
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
||||
# the next _process() call must propagate the state to z-layer-6 nodes.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -235,7 +237,7 @@ func test_follow_target_id_is_negative_one_by_default() -> void:
|
||||
func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
||||
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -254,7 +256,7 @@ func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||
|
||||
func test_fog_group_exists_in_world() -> void:
|
||||
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -264,7 +266,7 @@ func test_fog_group_exists_in_world() -> void:
|
||||
|
||||
func test_floor_tiles_in_fog_group() -> void:
|
||||
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -274,7 +276,7 @@ func test_floor_tiles_in_fog_group() -> void:
|
||||
|
||||
func test_entities_in_ysort_group() -> void:
|
||||
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -288,7 +290,7 @@ func test_entities_in_ysort_group() -> void:
|
||||
|
||||
func test_tile_renderer_skips_nonzero_z() -> void:
|
||||
# #71: Tiles with z != 0 must be filtered out by update_tiles().
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -250,7 +250,8 @@ func test_dialogue_theme_format() -> void:
|
||||
|
||||
func test_checklist_format() -> void:
|
||||
## checklist.yaml: top-level kv + conditions array with typed values.
|
||||
var yaml := "room_id: inventory_warehouse\nconditions:\n - id: test-1\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var yaml := ("room_id: inventory_warehouse\nconditions:\n - id: test-1\n" +
|
||||
" condition_type: player_near\n x: 10\n y: 20\n radius: 3.0")
|
||||
var result := YamlParser.parse(yaml)
|
||||
assert_that(result["room_id"]).is_equal("inventory_warehouse")
|
||||
var cond: Dictionary = result["conditions"][0]
|
||||
|
||||
@@ -31,7 +31,7 @@ func _init():
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run():
|
||||
func _run(): # gdlint:disable=max-returns
|
||||
# Parse CLI args (after -- separator)
|
||||
_parse_args()
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||
# Zone tint is written from visible_tiles with zone_id field.
|
||||
# We patch GameState.visible_tiles after the first snapshot in post_setup().
|
||||
pass
|
||||
|
||||
"fog_debug":
|
||||
# Raw exploration overlay (green/blue/red).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# gdlint:disable=max-file-lines
|
||||
class_name CharacterCreation
|
||||
extends Control
|
||||
## #705: Character creation screen.
|
||||
@@ -105,18 +106,14 @@ const PALETTE_COLORS: Array[Array] = [
|
||||
const PALETTE_COLS := 9
|
||||
const RECENT_SLOTS := 9
|
||||
|
||||
# --- @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 _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
||||
var _tab_container: TabContainer = null # resolved in _ready() — @onready path fails when instantiated as child
|
||||
@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
|
||||
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"
|
||||
|
||||
const SCREENSHOT_DIR := "user://screenshots/"
|
||||
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||
|
||||
# --- Descriptor and preview state ---
|
||||
var _descriptor: CharacterVisualDescriptor
|
||||
@@ -125,9 +122,6 @@ var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face
|
||||
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
|
||||
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
|
||||
|
||||
# --- Tab active slot state ---
|
||||
var _active_clothing_slot: String = "torso"
|
||||
@@ -197,7 +191,31 @@ var _tab_grids: Array[GridContainer] = [null, null, null, null, null]
|
||||
# --- Asset manifest (loaded once, replaces filesystem scanning) ---
|
||||
var _manifest: Dictionary = {}
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
# --- Debug tab state ---
|
||||
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
|
||||
|
||||
# --- Screenshot / automated testing state ---
|
||||
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
|
||||
var _screenshot_pending: bool = false
|
||||
var _screenshot_frame_count: int = 0
|
||||
var _quit_after_screenshot: bool = false
|
||||
var _screenshot_cardinals: bool = false
|
||||
var _screenshot_cardinal_idx: int = 0
|
||||
|
||||
# resolved in _ready() — @onready path fails when instantiated as child
|
||||
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 _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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -341,7 +359,8 @@ 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
|
||||
@@ -1084,15 +1103,6 @@ func _update_accessory_item_btns() -> void:
|
||||
# Screenshot & automated testing
|
||||
# =============================================================================
|
||||
|
||||
const SCREENSHOT_DIR := "user://screenshots/"
|
||||
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
|
||||
var _screenshot_pending: bool = false
|
||||
var _screenshot_frame_count: int = 0
|
||||
var _quit_after_screenshot: bool = false
|
||||
var _screenshot_cardinals: bool = false
|
||||
var _screenshot_cardinal_idx: int = 0
|
||||
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||
|
||||
func _schedule_screenshot() -> void:
|
||||
_screenshot_pending = true
|
||||
_screenshot_frame_count = 0
|
||||
@@ -1131,8 +1141,7 @@ func _take_screenshot(suffix: String = "") -> void:
|
||||
# More directions to capture
|
||||
_schedule_screenshot()
|
||||
return
|
||||
else:
|
||||
_screenshot_cardinals = false
|
||||
_screenshot_cardinals = false
|
||||
|
||||
if _quit_after_screenshot:
|
||||
get_tree().quit()
|
||||
@@ -1203,8 +1212,6 @@ func _load_test_config() -> void:
|
||||
# Debug tab — segment visibility toggles
|
||||
# =============================================================================
|
||||
|
||||
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
|
||||
|
||||
func _build_debug_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -1501,7 +1508,7 @@ func _input(event: InputEvent) -> void:
|
||||
_cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_cam_zoom_toward_cursor(mb.position, CAM_ZOOM_STEP)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
@@ -1873,7 +1880,7 @@ func _get_accessory_ids_for_slot(slot: String) -> Array:
|
||||
"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
|
||||
_: return all_ids # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -6,7 +6,7 @@ extends Control
|
||||
##
|
||||
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
|
||||
|
||||
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
@@ -26,7 +26,7 @@ var _cached_font: Font = null # Cached to avoid per-frame theme lookup
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
_evaluator = _ChecklistEvaluator.new()
|
||||
_evaluator = ChecklistEvaluator.new()
|
||||
_cached_font = get_theme_default_font()
|
||||
|
||||
|
||||
|
||||
@@ -232,11 +232,15 @@ func _draw_stats_panel() -> void:
|
||||
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
|
||||
|
||||
|
||||
@@ -335,7 +339,8 @@ 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)
|
||||
|
||||
|
||||
|
||||
+29
-29
@@ -21,9 +21,26 @@ signal dialogue_state_changed(active: bool)
|
||||
signal audio_dip_requested(profile: String)
|
||||
signal audio_dip_cleared
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
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_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
|
||||
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
||||
const PLAYER_NAME: String = "You"
|
||||
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
|
||||
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
|
||||
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
|
||||
|
||||
# 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",
|
||||
]
|
||||
|
||||
# -- Log state --
|
||||
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
|
||||
@@ -67,26 +84,9 @@ var _passive_opacity: float = 0.9
|
||||
var _entry_lifetime: float = 45.0
|
||||
var _entry_fade: float = 5.0
|
||||
|
||||
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_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
|
||||
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
||||
const PLAYER_NAME: String = "You"
|
||||
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
|
||||
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
|
||||
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
|
||||
|
||||
# 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",
|
||||
]
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -197,7 +197,8 @@ func _update_layout() -> void:
|
||||
## 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,
|
||||
@@ -572,11 +573,10 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, txc, text
|
||||
]
|
||||
else:
|
||||
# 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
|
||||
]
|
||||
# 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
|
||||
]
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
|
||||
@@ -21,12 +21,12 @@ const CONFIDENCE_ALPHA: Dictionary = {
|
||||
"Suspects": 0.6,
|
||||
}
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
var _dismiss_tween: Tween = null
|
||||
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
|
||||
|
||||
@@ -114,7 +114,8 @@ func _draw() -> void:
|
||||
# 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:
|
||||
|
||||
@@ -15,7 +15,7 @@ const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing not
|
||||
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
|
||||
|
||||
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
|
||||
var BENCHMARK_CACHE_PATH: String:
|
||||
var benchmark_cache_path: String:
|
||||
get:
|
||||
return PlatformInfo.benchmark_cache_path
|
||||
|
||||
@@ -45,10 +45,9 @@ func _ready() -> void:
|
||||
func classify_ram(free_mb: float) -> String:
|
||||
if free_mb >= RAM_PASS_THRESHOLD_MB:
|
||||
return "pass"
|
||||
elif free_mb >= RAM_MARGINAL_THRESHOLD_MB:
|
||||
if free_mb >= RAM_MARGINAL_THRESHOLD_MB:
|
||||
return "marginal"
|
||||
else:
|
||||
return "fail"
|
||||
return "fail"
|
||||
|
||||
|
||||
## Query RAM via PlatformInfo and return classification + raw MB value.
|
||||
@@ -65,10 +64,9 @@ func check_ram() -> Dictionary:
|
||||
func classify_tpt(tps: float) -> String:
|
||||
if tps >= TPT_GREEN_THRESHOLD:
|
||||
return "green"
|
||||
elif tps >= TPT_YELLOW_THRESHOLD:
|
||||
if tps >= TPT_YELLOW_THRESHOLD:
|
||||
return "yellow"
|
||||
else:
|
||||
return "red"
|
||||
return "red"
|
||||
|
||||
|
||||
## Read the cached TPT benchmark result written by the server on first model load.
|
||||
@@ -192,12 +190,20 @@ func _send_settings_change(enabled: bool) -> void:
|
||||
## Returns empty string when no message is needed.
|
||||
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. Close other applications and try again, or leave the setting off — the game is complete either way." % free_mb
|
||||
return (
|
||||
"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
|
||||
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. Background NPCs may show base text until the queue catches up." % tps
|
||||
return (
|
||||
"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
|
||||
"red":
|
||||
return "Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours." % tps
|
||||
return (
|
||||
"Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours."
|
||||
) % tps
|
||||
return ""
|
||||
|
||||
@@ -32,6 +32,8 @@ var _verb_labels: Array[Label] = []
|
||||
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
||||
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBox
|
||||
|
||||
|
||||
@@ -40,9 +42,6 @@ func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
|
||||
@@ -8,7 +8,8 @@ extends PanelContainer
|
||||
# v0.2: Will be replaced/extended with radial verb menu.
|
||||
# Public interface: get_interaction_target(), get_selected_verb()
|
||||
|
||||
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||
const FADE_IN: float = 0.15
|
||||
const FADE_OUT: float = 0.15
|
||||
|
||||
var _is_showing: bool = false
|
||||
var _active_tween: Tween = null
|
||||
@@ -16,8 +17,7 @@ var _current_target_id: int = -1
|
||||
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
|
||||
var _insert_active: bool = true
|
||||
|
||||
const FADE_IN: float = 0.15
|
||||
const FADE_OUT: float = 0.15
|
||||
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||
|
||||
func _ready() -> void:
|
||||
modulate.a = 0.0
|
||||
|
||||
@@ -94,7 +94,8 @@ func _draw() -> void:
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -23,13 +23,14 @@ const FADE_OUT: float = 0.25
|
||||
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
||||
|
||||
var _visible_state: bool = false
|
||||
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
|
||||
|
||||
var _visible_state: bool = false
|
||||
var _last_rendered_tick: int = -1
|
||||
@onready var entries_container: VBoxContainer = \
|
||||
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
@@ -40,5 +40,5 @@ func show_loading() -> void:
|
||||
|
||||
|
||||
## Hide the loading overlay. success=false is reserved for future failure-state UI.
|
||||
func hide_loading(success: bool = true) -> void:
|
||||
func hide_loading(_success: bool = true) -> void:
|
||||
visible = false
|
||||
|
||||
@@ -16,6 +16,9 @@ const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
var _char_creation: Control = null
|
||||
var _list_built: bool = false
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _load_game_btn: Button = $VBox/LoadGameBtn
|
||||
@@ -24,8 +27,6 @@ const FONT_SIZE_BTN := 15
|
||||
@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList
|
||||
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
|
||||
|
||||
var _char_creation: Control = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
@@ -93,9 +94,6 @@ func _on_continue() -> void:
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
var _list_built: bool = false
|
||||
|
||||
|
||||
func _on_load_game_browse() -> void:
|
||||
if not _list_built:
|
||||
_build_saves_list()
|
||||
|
||||
@@ -38,8 +38,6 @@ const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
||||
@@ -47,6 +45,8 @@ var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
pass
|
||||
@@ -85,7 +85,10 @@ func show_notification(text: String) -> void:
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_notification_line(text)
|
||||
else:
|
||||
var entry := {text = text, duration = _NOTIFICATION_DURATION, priority = 1, is_urgent = false, lattice_profile = "", is_notification = true}
|
||||
var entry := {
|
||||
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)
|
||||
@@ -173,13 +176,19 @@ func _retire_slot(slot: Dictionary) -> 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.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}
|
||||
_queue[lowest] = {
|
||||
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: incoming line is strictly lower priority — silently drop; no sort needed
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ const FONT_SIZE := 13
|
||||
const SCROLL_SPEED := 60.0 # pixels per second
|
||||
const BAR_HEIGHT := 28
|
||||
|
||||
@onready var _label: Label = $TickerLabel
|
||||
|
||||
var _text: String = ""
|
||||
var _scroll_x: float = 0.0
|
||||
var _content_width: float = 0.0
|
||||
|
||||
@onready var _label: Label = $TickerLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
@@ -6,6 +6,10 @@ extends Control
|
||||
## Volumes persist via AudioManager._save_prefs() on each slider change.
|
||||
## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite).
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
@@ -42,10 +46,6 @@ var _ai_check_node: CheckButton = null
|
||||
var _ai_inference_suspended: bool = false # D-138 §8 Layer 3 battery auto-suspend state
|
||||
var _ai_battery_warning_label: Label = null # shown when on battery; toggle stays enabled
|
||||
|
||||
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
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
|
||||
+137
-63
@@ -5,11 +5,12 @@ extends Control
|
||||
## Renders 301 systems as dots on concentric rings (hop distance from Gateway).
|
||||
## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan).
|
||||
##
|
||||
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db).
|
||||
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db + wiki).
|
||||
## Regenerate with: tooling/generate-star-map-data.py
|
||||
##
|
||||
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
|
||||
## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674.
|
||||
## Ticket #780: Click-through popup — wiki/GTTR content on system select.
|
||||
|
||||
const DATA_PATH := "res://data/star_map_data.json"
|
||||
|
||||
@@ -30,10 +31,17 @@ const GATEWAY_RADIUS: float = 6.0
|
||||
const SELECTION_RING_RADIUS: float = 8.0
|
||||
const HIT_RADIUS: float = 10.0 # click tolerance
|
||||
|
||||
# Edge rendering
|
||||
const EDGE_WIDTH: float = 0.4
|
||||
const EDGE_ALPHA: float = 0.12
|
||||
const EDGE_SELECTED_ALPHA: float = 0.5
|
||||
# Edge rendering — only shown for selected system (ticket #780 UX rule)
|
||||
const EDGE_WIDTH: float = 0.8
|
||||
const EDGE_SELECTED_ALPHA: float = 0.55
|
||||
|
||||
# Info popup — expanded with wiki/GTTR content (#780)
|
||||
const POPUP_WIDTH: float = 300.0
|
||||
const POPUP_MARGIN: float = 16.0
|
||||
const POPUP_PADDING: float = 12.0
|
||||
const POPUP_LINE_H: float = 17.0
|
||||
const POPUP_GTTR_FONT_SIZE: int = 10
|
||||
const POPUP_GTTR_MAX_LINES: int = 7
|
||||
|
||||
# Colors — sector palette from wireframe
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
@@ -95,7 +103,6 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
var _data_loaded: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _show_edges: bool = false # toggle edge display
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
|
||||
|
||||
@@ -260,7 +267,7 @@ func _sector_sort_key(node: Dictionary) -> float:
|
||||
"south_reach": return 3.0
|
||||
"west_reach": return 4.0
|
||||
"deep_frontier": return 5.0
|
||||
_: return 6.0
|
||||
_: return 6.0 # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
## Deterministic float in [-1, 1] from a string key.
|
||||
@@ -289,8 +296,8 @@ func _draw() -> void:
|
||||
# Sector labels
|
||||
_draw_sector_labels(center)
|
||||
|
||||
# Edges (gate connections)
|
||||
if _show_edges or _selected_system != "":
|
||||
# Edges — only draw from selected system (UX rule: full edge web is too dense)
|
||||
if _selected_system != "":
|
||||
_draw_edges(center)
|
||||
|
||||
# System dots
|
||||
@@ -358,23 +365,18 @@ func _draw_systems(center: Vector2) -> void:
|
||||
|
||||
|
||||
func _draw_edges(center: Vector2) -> void:
|
||||
for edge: Array in _edges:
|
||||
if edge.size() < 2:
|
||||
# Only draw edges connected to the selected system (full web is unreadable at 301 systems)
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
var adj: Array = node.get("adjacent_systems", [])
|
||||
if adj.is_empty():
|
||||
return
|
||||
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, EDGE_SELECTED_ALPHA)
|
||||
var sel_pos: Vector2 = center + _node_positions.get(_selected_system, Vector2.ZERO) * _zoom
|
||||
for neighbor_id: String in adj:
|
||||
if not _node_positions.has(neighbor_id):
|
||||
continue
|
||||
var sid_a: String = edge[0]
|
||||
var sid_b: String = edge[1]
|
||||
if not _node_positions.has(sid_a) or not _node_positions.has(sid_b):
|
||||
continue
|
||||
var pos_a: Vector2 = center + _node_positions[sid_a] * _zoom
|
||||
var pos_b: Vector2 = center + _node_positions[sid_b] * _zoom
|
||||
|
||||
var alpha: float = EDGE_ALPHA
|
||||
# Highlight edges connected to selected system
|
||||
if _selected_system == sid_a or _selected_system == sid_b:
|
||||
alpha = EDGE_SELECTED_ALPHA
|
||||
|
||||
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, alpha)
|
||||
draw_line(pos_a, pos_b, color, EDGE_WIDTH, true)
|
||||
var neighbor_pos: Vector2 = center + _node_positions[neighbor_id] * _zoom
|
||||
draw_line(sel_pos, neighbor_pos, color, EDGE_WIDTH, true)
|
||||
|
||||
|
||||
func _draw_selection(center: Vector2) -> void:
|
||||
@@ -389,7 +391,8 @@ 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)
|
||||
|
||||
|
||||
func _draw_info_panel(sz: Vector2) -> void:
|
||||
@@ -397,49 +400,125 @@ func _draw_info_panel(sz: Vector2) -> void:
|
||||
if node.is_empty():
|
||||
return
|
||||
|
||||
var panel_w: float = 220.0
|
||||
var panel_h: float = 130.0
|
||||
var margin: float = 16.0
|
||||
var panel_pos := Vector2(sz.x - panel_w - margin, margin)
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
|
||||
|
||||
var font := get_theme_default_font()
|
||||
var y: float = panel_pos.y + 20.0
|
||||
var x: float = panel_pos.x + 12.0
|
||||
var line_h: float = 18.0
|
||||
var panel_w: float = POPUP_WIDTH
|
||||
var pad: float = POPUP_PADDING
|
||||
var inner_w: float = panel_w - pad * 2.0
|
||||
|
||||
# System name
|
||||
var name: String = node.get("proper_name", "")
|
||||
if name.is_empty():
|
||||
name = node.get("system_id", "Unknown")
|
||||
draw_string(font, Vector2(x, y), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
|
||||
y += line_h
|
||||
# Measure GTTR text height first so we can size the panel
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
# Strip markdown bold markers for display
|
||||
gttr = gttr.replace("**", "")
|
||||
var gttr_block_h: float = 0.0
|
||||
if not gttr.is_empty():
|
||||
var gttr_size: Vector2 = font.get_multiline_string_size(
|
||||
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
|
||||
POPUP_GTTR_MAX_LINES)
|
||||
gttr_block_h = gttr_size.y + 6.0
|
||||
|
||||
# System ID
|
||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
|
||||
var fixed_h: float = (
|
||||
POPUP_LINE_H * 2.0 # name + id
|
||||
+ 4.0 # separator
|
||||
+ POPUP_LINE_H * 3.0 # star+hop, corridor, bodies+pop
|
||||
+ 4.0 # separator
|
||||
+ gttr_block_h
|
||||
+ 4.0 # separator (before adjacent)
|
||||
+ POPUP_LINE_H # adjacent systems label
|
||||
+ pad * 2.0
|
||||
)
|
||||
var panel_h: float = fixed_h
|
||||
|
||||
# Sector
|
||||
var sector: String = node.get("geographic_sector", "").replace("_", " ").capitalize()
|
||||
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
|
||||
var x: float = panel_pos.x + pad
|
||||
var y: float = panel_pos.y + pad + POPUP_LINE_H # baseline offset
|
||||
|
||||
# Background + border
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
|
||||
|
||||
# ── System name ──────────────────────────────────────────────────────────
|
||||
var sys_name: String = node.get("proper_name", "")
|
||||
if sys_name.is_empty():
|
||||
sys_name = node.get("system_id", "Unknown")
|
||||
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
# Separator
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
|
||||
# ── Stats block ──────────────────────────────────────────────────────────
|
||||
var star_type: String = node.get("star_type", "")
|
||||
var hop: int = int(node.get("hop_distance", 0))
|
||||
var stat_line_1: String
|
||||
if star_type.is_empty():
|
||||
stat_line_1 = "Hop %d from Gateway" % hop
|
||||
else:
|
||||
stat_line_1 = "%s star · hop %d" % [star_type, hop]
|
||||
draw_string(font, Vector2(x, y), stat_line_1, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
|
||||
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
||||
draw_string(font, Vector2(x, y), "Sector: " + sector, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, sector_color)
|
||||
y += line_h
|
||||
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
# Hop distance
|
||||
draw_string(font, Vector2(x, y), "Hop distance: " + str(node.get("hop_distance", "?")), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
y += line_h
|
||||
var bodies: String = node.get("bodies", "")
|
||||
var population: String = node.get("population", "")
|
||||
var stat_line_3: String
|
||||
if not bodies.is_empty() and not population.is_empty():
|
||||
stat_line_3 = "%s · pop %s" % [bodies, population]
|
||||
elif not bodies.is_empty():
|
||||
stat_line_3 = bodies
|
||||
elif not population.is_empty():
|
||||
stat_line_3 = "Population: " + population
|
||||
else:
|
||||
stat_line_3 = "%d aperture%s · %s" % [
|
||||
int(node.get("aperture_count", 0)),
|
||||
"s" if int(node.get("aperture_count", 0)) != 1 else "",
|
||||
node.get("gate_topology", "")]
|
||||
draw_string(font, Vector2(x, y), stat_line_3, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
# Gate connections
|
||||
draw_string(font, Vector2(x, y), "Gates: " + str(int(node.get("aperture_count", 0))) + " apertures", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
if not gttr.is_empty():
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
draw_multiline_string(font, Vector2(x, y), gttr, HORIZONTAL_ALIGNMENT_LEFT,
|
||||
inner_w, POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES,
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
|
||||
y += gttr_block_h
|
||||
|
||||
# ── Adjacent systems ──────────────────────────────────────────────────────
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
var adj: Array = node.get("adjacent_systems", [])
|
||||
if adj.is_empty():
|
||||
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
||||
else:
|
||||
var adj_names: Array = []
|
||||
for neighbor_id: String in adj:
|
||||
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
|
||||
var n_name: String = neighbor.get("proper_name", "")
|
||||
adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
|
||||
var adj_line: String = " · ".join(adj_names)
|
||||
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
|
||||
inner_w, 10, 2, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
func _draw_title() -> void:
|
||||
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, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
func _dot_radius(topology: String) -> float:
|
||||
@@ -508,12 +587,7 @@ func _find_nearest_system(pos: Vector2) -> String:
|
||||
|
||||
func _handle_click(pos: Vector2) -> void:
|
||||
var nearest := _find_nearest_system(pos)
|
||||
if nearest != "":
|
||||
_selected_system = nearest
|
||||
_show_edges = true
|
||||
else:
|
||||
_selected_system = ""
|
||||
_show_edges = false
|
||||
_selected_system = nearest
|
||||
_dirty = true
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate client/data/star_map_data.json from star-map.json + systems.db.
|
||||
"""Generate client/data/star_map_data.json from star-map.json + systems.db + wiki.
|
||||
|
||||
Run from any directory — paths are resolved relative to this script's location:
|
||||
python3 tooling/generate-star-map-data.py
|
||||
@@ -8,6 +8,7 @@ Run from any directory — paths are resolved relative to this script's location
|
||||
Sources:
|
||||
docs/design/star-map.json — graph topology (nodes + edges)
|
||||
server/server/data/systems.db — proper names, geographic sectors
|
||||
wiki/star-systems/ — star type, bodies, population, GTTR excerpt
|
||||
|
||||
Output:
|
||||
client/data/star_map_data.json — self-contained client data for the star map UI
|
||||
@@ -15,6 +16,7 @@ Output:
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -31,9 +33,95 @@ _WORKTREE_PARENT = os.path.dirname(_PROJECT_ROOT)
|
||||
|
||||
STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json")
|
||||
SYSTEMS_DB_PATH = os.path.join(_WORKTREE_PARENT, "server", "server", "data", "systems.db")
|
||||
WIKI_PATH = os.path.join(_PROJECT_ROOT, "wiki", "star-systems")
|
||||
OUTPUT_PATH = os.path.join(_PROJECT_ROOT, "client", "data", "star_map_data.json")
|
||||
|
||||
|
||||
def system_id_to_wiki_slug(system_id: str) -> str:
|
||||
"""Convert system_id to wiki folder slug. 'GJ 71' → 'GJ-71'."""
|
||||
return system_id.replace(" ", "-")
|
||||
|
||||
|
||||
def parse_wiki_index(system_id: str) -> dict:
|
||||
"""Extract star type, bodies summary, and population from index.md.
|
||||
|
||||
Returns dict with keys: star_type, bodies, population (all strings, may be empty).
|
||||
"""
|
||||
slug = system_id_to_wiki_slug(system_id)
|
||||
path = os.path.join(WIKI_PATH, slug, "index.md")
|
||||
result = {"star_type": "", "bodies": "", "population": ""}
|
||||
if not os.path.exists(path):
|
||||
return result
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Star row: | **Star** | G8V · 11.9 ly |
|
||||
m = re.search(r"\|\s*\*\*Star\*\*\s*\|\s*([^|]+?)\s*\|", content)
|
||||
if m:
|
||||
raw = m.group(1).strip()
|
||||
# Extract spectral class — everything before " ·" or end of string
|
||||
star_type = raw.split("·")[0].strip()
|
||||
result["star_type"] = star_type
|
||||
|
||||
# Bodies row: | **Bodies** | 2 habitable · 3 inhabited |
|
||||
m = re.search(r"\|\s*\*\*Bodies\*\*\s*\|\s*([^|]+?)\s*\|", content)
|
||||
if m:
|
||||
result["bodies"] = m.group(1).strip()
|
||||
|
||||
# Population row: | **Population** | 1,200,000,000 |
|
||||
m = re.search(r"\|\s*\*\*Population\*\*\s*\|\s*([^|]+?)\s*\|", content)
|
||||
if m:
|
||||
result["population"] = m.group(1).strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_gttr_excerpt(system_id: str) -> str:
|
||||
"""Extract the first body paragraph from gttr.md as the GTTR excerpt."""
|
||||
slug = system_id_to_wiki_slug(system_id)
|
||||
path = os.path.join(WIKI_PATH, slug, "gttr.md")
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Skip the H1 heading line, then find the first non-empty paragraph
|
||||
in_content = False
|
||||
paragraph_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not in_content:
|
||||
# Skip heading and blank lines at start
|
||||
if stripped.startswith("# "):
|
||||
in_content = True
|
||||
continue
|
||||
if not stripped:
|
||||
# Blank line ends the paragraph if we've collected lines
|
||||
if paragraph_lines:
|
||||
break
|
||||
else:
|
||||
if not stripped.startswith("#"):
|
||||
paragraph_lines.append(stripped)
|
||||
|
||||
return " ".join(paragraph_lines)
|
||||
|
||||
|
||||
def build_adjacency(edges: list) -> dict:
|
||||
"""Build a map from system_id to list of adjacent system_ids from edges."""
|
||||
adj: dict = {}
|
||||
for edge in edges:
|
||||
if len(edge) < 2:
|
||||
continue
|
||||
a, b = edge[0], edge[1]
|
||||
adj.setdefault(a, [])
|
||||
adj.setdefault(b, [])
|
||||
if b not in adj[a]:
|
||||
adj[a].append(b)
|
||||
if a not in adj[b]:
|
||||
adj[b].append(a)
|
||||
return adj
|
||||
|
||||
|
||||
def generate() -> dict:
|
||||
"""Generate the enriched star map data dict."""
|
||||
if not os.path.exists(STAR_MAP_PATH):
|
||||
@@ -56,10 +144,21 @@ def generate() -> dict:
|
||||
db_lookup = {row["system_id"]: dict(row) for row in cur.fetchall()}
|
||||
conn.close()
|
||||
|
||||
adjacency = build_adjacency(star_map["edges"])
|
||||
|
||||
nodes = []
|
||||
wiki_hits = 0
|
||||
gttr_hits = 0
|
||||
for n in star_map["nodes"]:
|
||||
sid = n["system_id"]
|
||||
db = db_lookup.get(sid, {})
|
||||
wiki = parse_wiki_index(sid)
|
||||
gttr = parse_gttr_excerpt(sid)
|
||||
if wiki["star_type"]:
|
||||
wiki_hits += 1
|
||||
if gttr:
|
||||
gttr_hits += 1
|
||||
|
||||
entry = {
|
||||
"system_id": sid,
|
||||
"proper_name": db.get("proper_name", ""),
|
||||
@@ -69,16 +168,28 @@ def generate() -> dict:
|
||||
"aperture_count": n["aperture_count"],
|
||||
"gate_connections": n["gate_connections"],
|
||||
"hop_distance": n["hop_distance_from_gateway"],
|
||||
"adjacent_systems": adjacency.get(sid, []),
|
||||
}
|
||||
if wiki["star_type"]:
|
||||
entry["star_type"] = wiki["star_type"]
|
||||
if wiki["bodies"]:
|
||||
entry["bodies"] = wiki["bodies"]
|
||||
if wiki["population"]:
|
||||
entry["population"] = wiki["population"]
|
||||
if gttr:
|
||||
entry["gttr_excerpt"] = gttr
|
||||
if n.get("is_gateway"):
|
||||
entry["is_gateway"] = True
|
||||
nodes.append(entry)
|
||||
|
||||
nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"]))
|
||||
|
||||
print(f" Wiki index data: {wiki_hits}/{len(nodes)} systems")
|
||||
print(f" GTTR excerpts: {gttr_hits}/{len(nodes)} systems")
|
||||
|
||||
return {
|
||||
"_meta": {
|
||||
"generated_from": "star-map.json + systems.db",
|
||||
"generated_from": "star-map.json + systems.db + wiki/star-systems",
|
||||
"system_count": len(nodes),
|
||||
"edge_count": len(star_map["edges"]),
|
||||
"note": "Client-side star map data. Regenerate with: tooling/generate-star-map-data.py",
|
||||
|
||||
Reference in New Issue
Block a user