class_name PawnDetailPanel extends CanvasLayer ## Phase 17 — Right-side bottom-sheet pawn inspector. ## ## Layer 18: above banner (15) and world, below modal (20) and load_menu (25). ## Opens when EventBus.pawn_selected fires; closes on pawn_deselected or pawn_died. ## Repopulates every 5 sim ticks while open so vitals stay live. ## ## Touch targets: all interactive controls are at least 48×48 px. ## Background elements use MOUSE_FILTER_IGNORE so world taps pass through, ## allowing the player to tap a different pawn to swap panels without closing first. const PANEL_WIDTH: int = 360 const REFRESH_TICKS: int = 5 # repopulate every N sim ticks # ── internal state ──────────────────────────────────────────────────────────── var _pawn = null # currently displayed Pawn (may become null on death) var _tick_counter: int = 0 # counts sim ticks since last repopulate # ── node refs (built in _build_ui) ─────────────────────────────────────────── var _panel: PanelContainer = null var _pawn_name_label: Label = null var _portrait_rect: ColorRect = null var _close_btn: Button = null var _hp_bar: ProgressBar = null var _hunger_bar: ProgressBar = null var _sleep_bar: ProgressBar = null var _sulk_badge: Label = null var _job_label: Label = null var _mood_label: Label = null var _thoughts_vbox: VBoxContainer = null var _statuses_vbox: VBoxContainer = null var _skills_vbox: VBoxContainer = null var _priorities_label: Label = null func _ready() -> void: layer = 18 _build_ui() _set_visible(false) EventBus.pawn_selected.connect(_on_pawn_selected) EventBus.pawn_deselected.connect(_on_pawn_deselected) if EventBus.has_signal("pawn_died"): EventBus.pawn_died.connect(_on_pawn_died) EventBus.sim_tick.connect(_on_sim_tick) Audit.log("pawn_detail_panel", "PawnDetailPanel ready (layer %d)" % layer) func _exit_tree() -> void: if EventBus.pawn_selected.is_connected(_on_pawn_selected): EventBus.pawn_selected.disconnect(_on_pawn_selected) if EventBus.pawn_deselected.is_connected(_on_pawn_deselected): EventBus.pawn_deselected.disconnect(_on_pawn_deselected) if EventBus.has_signal("pawn_died") and EventBus.pawn_died.is_connected(_on_pawn_died): EventBus.pawn_died.disconnect(_on_pawn_died) if EventBus.sim_tick.is_connected(_on_sim_tick): EventBus.sim_tick.disconnect(_on_sim_tick) # ── UI construction ─────────────────────────────────────────────────────────── func _build_ui() -> void: # Right-side sheet — anchored to the right edge, full height. _panel = PanelContainer.new() _panel.name = "PawnDetailSheet" # Anchor: right strip. _panel.anchor_left = 1.0 _panel.anchor_right = 1.0 _panel.anchor_top = 0.0 _panel.anchor_bottom = 1.0 _panel.offset_left = -PANEL_WIDTH _panel.offset_right = 0.0 _panel.offset_top = 0.0 _panel.offset_bottom = 0.0 # Pass-through for the background so world taps reach the Selection handler. _panel.mouse_filter = Control.MOUSE_FILTER_PASS add_child(_panel) # Scrollable inner container so content survives small screens. var scroll := ScrollContainer.new() scroll.name = "Scroll" scroll.set_anchors_preset(Control.PRESET_FULL_RECT) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED _panel.add_child(scroll) var vbox := VBoxContainer.new() vbox.name = "Content" vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_theme_constant_override("separation", 6) scroll.add_child(vbox) # ── Header ──────────────────────────────────────────────────────────────── var header := HBoxContainer.new() header.name = "Header" header.add_theme_constant_override("separation", 8) vbox.add_child(header) _portrait_rect = ColorRect.new() _portrait_rect.name = "Portrait" _portrait_rect.custom_minimum_size = Vector2(32, 32) _portrait_rect.color = Color.WHITE _portrait_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(_portrait_rect) _pawn_name_label = Label.new() _pawn_name_label.name = "PawnName" _pawn_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _pawn_name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _pawn_name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(_pawn_name_label) _close_btn = Button.new() _close_btn.name = "CloseBtn" _close_btn.text = Strings.t(&"ui.detail.close") _close_btn.custom_minimum_size = Vector2(48, 48) _close_btn.focus_mode = Control.FOCUS_NONE _close_btn.pressed.connect(_on_close_pressed) header.add_child(_close_btn) _add_separator(vbox) # ── Vitals ──────────────────────────────────────────────────────────────── var vitals_vbox := VBoxContainer.new() vitals_vbox.name = "Vitals" vitals_vbox.add_theme_constant_override("separation", 4) vitals_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(vitals_vbox) _hp_bar = _make_stat_bar(Strings.t(&"ui.detail.hp"), vitals_vbox) _hunger_bar = _make_stat_bar(Strings.t(&"ui.detail.hunger"), vitals_vbox) _sleep_bar = _make_stat_bar(Strings.t(&"ui.detail.sleep"), vitals_vbox) _add_separator(vbox) # ── Status row (sulk badge + job) ───────────────────────────────────────── var status_row := HBoxContainer.new() status_row.name = "StatusRow" status_row.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(status_row) _sulk_badge = Label.new() _sulk_badge.name = "SulkBadge" _sulk_badge.text = Strings.t(&"ui.detail.sulking") _sulk_badge.modulate = Color(1.0, 0.25, 0.25) _sulk_badge.visible = false _sulk_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE status_row.add_child(_sulk_badge) _job_label = Label.new() _job_label.name = "JobLabel" _job_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _job_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _job_label.mouse_filter = Control.MOUSE_FILTER_IGNORE status_row.add_child(_job_label) _add_separator(vbox) # ── Mood + thoughts ─────────────────────────────────────────────────────── _mood_label = Label.new() _mood_label.name = "MoodLabel" _mood_label.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_mood_label) _thoughts_vbox = VBoxContainer.new() _thoughts_vbox.name = "Thoughts" _thoughts_vbox.add_theme_constant_override("separation", 2) _thoughts_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_thoughts_vbox) _add_separator(vbox) # ── Statuses ────────────────────────────────────────────────────────────── var stat_header := Label.new() stat_header.text = Strings.t(&"ui.detail.statuses") stat_header.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(stat_header) _statuses_vbox = VBoxContainer.new() _statuses_vbox.name = "Statuses" _statuses_vbox.add_theme_constant_override("separation", 2) _statuses_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_statuses_vbox) _add_separator(vbox) # ── Skills ──────────────────────────────────────────────────────────────── var skills_header := Label.new() skills_header.text = Strings.t(&"ui.detail.skills") skills_header.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(skills_header) _skills_vbox = VBoxContainer.new() _skills_vbox.name = "Skills" _skills_vbox.add_theme_constant_override("separation", 2) _skills_vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_skills_vbox) _add_separator(vbox) # ── Work priorities (read-only; Agent C adds the tap-to-cycle matrix) ───── var prio_header := Label.new() prio_header.text = Strings.t(&"ui.detail.priorities") prio_header.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(prio_header) _priorities_label = Label.new() _priorities_label.name = "Priorities" _priorities_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _priorities_label.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_priorities_label) ## Build a labelled ProgressBar row inside parent_vbox. Returns the ProgressBar. func _make_stat_bar(label_text: String, parent: VBoxContainer) -> ProgressBar: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) row.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(row) var lbl := Label.new() lbl.text = label_text lbl.custom_minimum_size = Vector2(56, 0) lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(lbl) var bar := ProgressBar.new() bar.min_value = 0.0 bar.max_value = 100.0 bar.value = 100.0 bar.show_percentage = false bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL bar.custom_minimum_size = Vector2(0, 20) bar.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(bar) return bar func _add_separator(parent: VBoxContainer) -> void: var sep := HSeparator.new() sep.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(sep) # ── event handlers ──────────────────────────────────────────────────────────── func _on_pawn_selected(pawn) -> void: _pawn = pawn _tick_counter = 0 _populate() _set_visible(true) Audit.log("pawn_detail_panel", "opened for %s" % pawn.pawn_name) func _on_pawn_deselected() -> void: _pawn = null _set_visible(false) Audit.log("pawn_detail_panel", "closed (deselected)") func _on_pawn_died(pawn, _cause: StringName) -> void: if _pawn == pawn or _pawn == null: _pawn = null _set_visible(false) Audit.log("pawn_detail_panel", "closed (pawn died)") func _on_close_pressed() -> void: # Mirror a deselect — clear selection state so the world is consistent. _pawn = null _set_visible(false) EventBus.pawn_deselected.emit() Audit.log("pawn_detail_panel", "closed (X button)") func _on_sim_tick(_tick_number: int) -> void: if _pawn == null or not _panel.visible: return # Guard against a pawn that was freed without emitting pawn_died. if not is_instance_valid(_pawn): _pawn = null _set_visible(false) return _tick_counter += 1 if _tick_counter >= REFRESH_TICKS: _tick_counter = 0 _populate() # ── population ──────────────────────────────────────────────────────────────── func _populate() -> void: if _pawn == null or not is_instance_valid(_pawn): return # Header. _pawn_name_label.text = _pawn.pawn_name _portrait_rect.color = _pawn.portrait_color # Vitals. _hp_bar.value = _pawn.hp _color_threshold_bar(_hp_bar, _pawn.hp, 30.0, 60.0) _hunger_bar.value = _pawn.hunger _color_threshold_bar(_hunger_bar, _pawn.hunger, 30.0, 60.0) _sleep_bar.value = _pawn.sleep _color_threshold_bar(_sleep_bar, _pawn.sleep, 30.0, 60.0) # Status row. _sulk_badge.visible = _pawn.sulking var job_text: String = Strings.t(&"ui.detail.idle") if _pawn.job_runner != null and _pawn.job_runner.has_method("current_job_label"): var lbl: String = _pawn.job_runner.current_job_label() if lbl != "": job_text = lbl elif _pawn.job_runner != null and _pawn.job_runner.get("current_job") != null: var cj = _pawn.job_runner.get("current_job") if cj != null and cj.get("label") != null and cj.label != "": job_text = cj.label _job_label.text = job_text # Mood. var mood_val: float = _pawn.mood _mood_label.text = "%s: %.0f" % [Strings.t(&"ui.detail.mood"), mood_val] if mood_val < 30.0: _mood_label.modulate = Color(1.0, 0.3, 0.3) elif mood_val < 60.0: _mood_label.modulate = Color(1.0, 0.85, 0.3) else: _mood_label.modulate = Color(0.4, 1.0, 0.4) # Thoughts — top 5 by |modifier × stacks| desc. _clear_children(_thoughts_vbox) var sorted_thoughts: Array = _pawn.thoughts.duplicate() sorted_thoughts.sort_custom(func(a, b) -> bool: return abs(a.modifier * a.stacks) > abs(b.modifier * b.stacks) ) var count: int = mini(5, sorted_thoughts.size()) for i in range(count): var t = sorted_thoughts[i] var delta: int = t.modifier * mini(t.stacks, t.max_stacks) var sign_str: String = "+" if delta >= 0 else "" var row := Label.new() row.text = " %s %s%d" % [t.label, sign_str, delta] row.mouse_filter = Control.MOUSE_FILTER_IGNORE _thoughts_vbox.add_child(row) # Statuses. _clear_children(_statuses_vbox) for s in _pawn.statuses: var row := Label.new() row.text = " %s %s" % [s.label, Strings.t(&"ui.detail.sev").format({"s": s.severity, "m": s.max_severity})] row.mouse_filter = Control.MOUSE_FILTER_IGNORE _statuses_vbox.add_child(row) # Skills. _clear_children(_skills_vbox) var skill_key_map: Array = [ [&"manual_labor", &"ui.detail.skill.manual_labor"], [&"crafting", &"ui.detail.skill.crafting"], [&"cooking", &"ui.detail.skill.cooking"], [&"medicine", &"ui.detail.skill.medicine"], [&"combat", &"ui.detail.skill.combat"], ] for pair in skill_key_map: var skill_id: StringName = pair[0] var skill_lbl: StringName = pair[1] if not _pawn.skills.has(skill_id): continue var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) row.mouse_filter = Control.MOUSE_FILTER_IGNORE _skills_vbox.add_child(row) var name_lbl := Label.new() name_lbl.text = Strings.t(skill_lbl) name_lbl.custom_minimum_size = Vector2(72, 0) name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(name_lbl) var val_lbl := Label.new() val_lbl.text = str(_pawn.skills[skill_id]) val_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(val_lbl) # Work priorities — comma-separated single line (Agent C adds interactive matrix). var prio_parts: Array[String] = [] for cat in _pawn.work_priorities: prio_parts.append("%s: %d" % [String(cat), _pawn.work_priorities[cat]]) _priorities_label.text = ", ".join(prio_parts) ## Color a progress bar by value thresholds: red below low, yellow below high, green above. func _color_threshold_bar(bar: ProgressBar, value: float, low: float, high: float) -> void: if value < low: bar.modulate = Color(1.0, 0.25, 0.25) elif value < high: bar.modulate = Color(1.0, 0.85, 0.25) else: bar.modulate = Color(0.3, 1.0, 0.3) func _clear_children(node: Node) -> void: for child in node.get_children(): child.queue_free() # ── visibility ──────────────────────────────────────────────────────────────── func _set_visible(v: bool) -> void: if _panel != null: _panel.visible = v