Files
settled-reach/client/ui/journal_panel.gd
T
2026-04-05 11:09:10 +02:00

223 lines
7.3 KiB
GDScript

extends Control
## Journal panel — knowledge graph review. #264, D-041.
##
## Toggle with J key. Displays accumulated facts from GameState.player_knowledge
## grouped by entity. Read-only — no player interaction beyond scrolling.
##
## KnowledgeState rendering:
## Active → normal color (INSERT_COLOR_TEXT)
## Stale → dimmed (IMPLANT_TEXT_DIM)
## Contradicted → amber tint + strikethrough (ENTITY_COLOR_POI) — THE FRIEND arc surface
##
## KnowledgeConfidence labels (D-041):
## Direct → KnowsDetails → KnowsOf → Suspects
##
## Cannot be open simultaneously with dialogue (sprint briefing constraint).
## Closes when dialogue opens.
const FADE_IN: float = 0.18
const FADE_OUT: float = 0.25
# D-041/D-042: confidence and source labels loaded from UIStrings (data/ui-strings.yaml).
# 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
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
modulate.a = 0.0
title_label.text = UIStrings.get_text("knowledge_panel.tab_contacts")
## Toggle open/close. Called from main.gd on J key press.
func toggle() -> void:
if _visible_state:
_hide_panel()
else:
_show_panel()
## Force-close. Called when dialogue opens.
func close() -> void:
if _visible_state:
_hide_panel()
func is_open() -> bool:
return _visible_state
func _show_panel() -> void:
_rebuild_entries()
_visible_state = true
visible = true
var t := create_tween()
t.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide_panel() -> void:
_visible_state = false
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): visible = false)
## Rebuild the entry list from GameState.player_knowledge.
func _rebuild_entries() -> void:
# Clear previous entries
for child in entries_container.get_children():
child.queue_free()
var knowledge: Variant = GameState.player_knowledge
if knowledge == null:
_add_empty_state()
return
var entities: Array = knowledge.get("entities", [])
if entities.is_empty():
_add_empty_state()
return
for entity in entities:
if not entity is Dictionary:
continue
_add_entity_entry(entity)
func _add_empty_state() -> void:
var label := Label.new()
label.text = UIStrings.get_text("knowledge_panel.empty_state")
label.add_theme_color_override("font_color", Constants.IMPLANT_TEXT_DIM)
label.add_theme_font_size_override("font_size", 13)
entries_container.add_child(label)
func _add_entity_entry(entity: Dictionary) -> void:
var name_str: String = entity.get("name", "Unknown")
var confidence: String = entity.get("confidence", "Suspects")
var source: String = entity.get("source", "")
var state: String = entity.get("state", "Active")
var relationship: String = entity.get("relationship", "Unknown")
# Entity header — name + relationship status
var header_rtl := RichTextLabel.new()
header_rtl.bbcode_enabled = true
header_rtl.fit_content = true
header_rtl.scroll_active = false
header_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
header_rtl.add_theme_font_size_override("normal_font_size", 14)
var rel_color: Color = Constants.color_for_relationship(relationship)
var rel_label: String = UIStrings.get_text(
"relationship_states.%s.label" % relationship.to_lower()
)
if rel_label == "relationship_states.%s.label" % relationship.to_lower():
rel_label = relationship # fallback if key missing
var state_color: Color = _state_color(state)
var conf_label: String = UIStrings.get_text(
"knowledge_panel.confidence_%s" % confidence.to_lower()
)
var src_label: String = _resolve_source_label(source)
# Build BBCode:
# [color=#hex][b]Name[/b][/color] [color=#rel_hex]Status[/color]
var name_hex: String = state_color.to_html(false)
var rel_hex: String = rel_color.to_html(false)
var header_bbcode: String
if state == "Contradicted":
header_bbcode = (
"[color=#%s][b][s]%s[/s][/b][/color] [color=#%s]%s[/color]"
% [name_hex, name_str, rel_hex, rel_label]
)
else:
header_bbcode = (
"[color=#%s][b]%s[/b][/color] [color=#%s]%s[/color]"
% [name_hex, name_str, rel_hex, rel_label]
)
header_rtl.text = header_bbcode
entries_container.add_child(header_rtl)
# Detail line — confidence + source
var detail_rtl := RichTextLabel.new()
detail_rtl.bbcode_enabled = true
detail_rtl.fit_content = true
detail_rtl.scroll_active = false
detail_rtl.mouse_filter = Control.MOUSE_FILTER_IGNORE
detail_rtl.add_theme_font_size_override("normal_font_size", 12)
var detail_hex: String = Constants.IMPLANT_TEXT_DIM.to_html(false)
var detail_text: String = "%s · %s" % [conf_label, src_label] if src_label else conf_label
detail_rtl.text = "[color=#%s]%s[/color]" % [detail_hex, detail_text]
entries_container.add_child(detail_rtl)
# Spacer between entries
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
entries_container.add_child(spacer)
## Resolve a source string from the server into a human-readable label.
## Handles plain keys ("DirectObservation", "Heard") and ToldBy(N) format.
## ToldBy(N) looks up entity_id N in player_knowledge.entities for the name.
func _resolve_source_label(source: String) -> String:
if source.is_empty():
return ""
# ToldBy(entity_id) format — e.g. "ToldBy(12)"
if source.begins_with("ToldBy(") and source.ends_with(")"):
var id_str: String = source.substr(7, source.length() - 8)
var entity_id: int = id_str.to_int()
var name := _entity_name_for_id(entity_id)
var told_prefix: String = UIStrings.get_text("knowledge_panel.source_toldby")
if told_prefix == "knowledge_panel.source_toldby":
told_prefix = "Told"
return "%s: %s" % [told_prefix, name]
# Plain source key — look up UIStrings
var key: String = "knowledge_panel.source_%s" % source.to_lower()
return UIStrings.get_text(key)
## Look up an entity name by stable ID from GameState.player_knowledge.entities.
func _entity_name_for_id(entity_id: int) -> String:
var knowledge: Variant = GameState.player_knowledge
if knowledge == null:
return "#%d" % entity_id
for entity in knowledge.get("entities", []):
if entity is Dictionary and int(entity.get("entity_id", -1)) == entity_id:
return entity.get("name", "#%d" % entity_id)
return "#%d" % entity_id
func _state_color(state: String) -> Color:
match state:
"Contradicted":
return Constants.ENTITY_COLOR_POI # amber — THE FRIEND arc
"Stale":
return Constants.IMPLANT_TEXT_DIM # dimmed
_:
return Constants.INSERT_COLOR_TEXT # active — normal
## Called from main.gd on each snapshot to close the panel when dialogue opens.
func update_from_state() -> void:
if _visible_state and GameState.dialogue_active:
close()
# Rebuild if open and knowledge data is newer than last render
if _visible_state and GameState.current_tick != _last_rendered_tick:
_last_rendered_tick = GameState.current_tick
_rebuild_entries()