From 86ff83d92742b27a85d979913a9e8784ee0c3650 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 02:30:04 +0100 Subject: [PATCH] feat(ui): knowledge/journal display panel (#264) Right-side insert panel toggled with J key. Facts grouped by entity with confidence, source, and state metadata. Contradicted entries in amber with strikethrough (THE FRIEND arc surface). Stale entries dimmed. Mutual exclusion with dialogue box. All labels via UIStrings. Co-Authored-By: Claude Opus 4.6 --- client/project.godot | 5 + client/scripts/autoloads/input_mapper.gd | 3 + client/ui/journal_panel.gd | 194 +++++++++++++++++++++++ client/ui/journal_panel.tscn | 63 ++++++++ 4 files changed, 265 insertions(+) create mode 100644 client/ui/journal_panel.gd create mode 100644 client/ui/journal_panel.tscn diff --git a/client/project.godot b/client/project.godot index 8aa99cf2f..479146b4c 100644 --- a/client/project.godot +++ b/client/project.godot @@ -125,6 +125,11 @@ debug_overlay={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +open_journal={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) +] +} teleport_hub={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 9a51eee07..37957efd0 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -19,6 +19,7 @@ enum Action { INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server + OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only SET_FACING, # D-054: facing octant update (no movement) TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel) } @@ -106,6 +107,8 @@ func _unhandled_input(event: InputEvent) -> void: action = Action.TOGGLE_STANCE_DOWN elif event.is_action_pressed("bug_report"): action = Action.BUG_REPORT + elif event.is_action_pressed("open_journal"): + action = Action.OPEN_JOURNAL elif event.is_action_pressed("teleport_hub"): if GameState.gauntlet_mode: action = Action.TELEPORT_HUB diff --git a/client/ui/journal_panel.gd b/client/ui/journal_panel.gd new file mode 100644 index 000000000..2f39ee02e --- /dev/null +++ b/client/ui/journal_panel.gd @@ -0,0 +1,194 @@ +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 confidence → display label +const CONFIDENCE_LABELS: Dictionary = { + "Direct": "Confirmed", + "KnowsDetails": "Detailed", + "KnowsOf": "Known", + "Suspects": "Unconfirmed", +} + +# D-041 source → display label +const SOURCE_LABELS: Dictionary = { + "DirectObservation": "Observed", + "ToldBy": "Told", + "Heard": "Overheard", + "Inferred": "Inferred", + "Background": "Prior", +} + +@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 + +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 = CONFIDENCE_LABELS.get(confidence, confidence) + var src_label: String = SOURCE_LABELS.get(source, 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) + + +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() diff --git a/client/ui/journal_panel.tscn b/client/ui/journal_panel.tscn new file mode 100644 index 000000000..9eedb4da9 --- /dev/null +++ b/client/ui/journal_panel.tscn @@ -0,0 +1,63 @@ +[gd_scene load_steps=2 format=3 uid="uid://journal_panel_sr"] + +[ext_resource type="Script" path="res://ui/journal_panel.gd" id="1_journal"] + +; JournalPanel — knowledge graph review. #264, D-041. +; Toggle J key. Right side, 380px wide, full height minus margins. +; InsertOverlay (CanvasLayer 10, z-layer 6). Read-only, closes with dialogue. + +[node name="JournalPanel" type="Control"] +layout_mode = 3 +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 0.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -400.0 +offset_top = 16.0 +offset_right = -16.0 +offset_bottom = -16.0 +grow_horizontal = 0 +grow_vertical = 2 +mouse_filter = 2 +modulate = Color(1, 1, 1, 0) +script = ExtResource("1_journal") + +[node name="PanelContainer" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 14 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 14 +theme_override_constants/margin_bottom = 12 + +[node name="VBoxContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="TitleLabel" type="Label" parent="PanelContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Contacts" +theme_override_font_sizes/font_size = 15 +theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 0.9) + +[node name="Separator" type="HSeparator" parent="PanelContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_colors/separator_color = Color(0.784, 0.816, 0.878, 0.2) + +[node name="ScrollContainer" type="ScrollContainer" parent="PanelContainer/MarginContainer/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="EntriesContainer" type="VBoxContainer" parent="PanelContainer/MarginContainer/VBoxContainer/ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 2