class_name StockpilePanel extends CanvasLayer ## Phase 17 — Right-side stockpile filter + priority editor. ## ## Layer 18: shares the slot with WorkbenchPanel and PawnDetailPanel — only one ## is visible at a time (mutual exclusion via Selection). ## Opens when EventBus.stockpile_selected fires; closes on stockpile_deselected. ## ## Filter model: ## accepted_types.is_empty() → wildcard "accept all" mode (default). ## As soon as the player checks any chip, the zone switches to explicit-list ## mode. "Select all" returns to wildcard by clearing the array. ## ## Rebuild policy: ## _populate_chips / _update_priority_row are called only on open or on an ## explicit player action. No per-tick polling needed. ## ## Touch targets: all interactive controls are at least 48×48 px. const PANEL_WIDTH: int = 360 const LAYER: int = 18 # ── internal state ──────────────────────────────────────────────────────────── var current_zone = null # StockpileZone (StorageDestination subclass) # ── node refs (built in _build_ui) ─────────────────────────────────────────── var _panel: PanelContainer = null var _header_label: Label = null var _close_btn: Button = null var _priority_btns: Array[Button] = [] var _chips_grid: GridContainer = null var _all_label: Label = null func _ready() -> void: layer = LAYER _build_ui() _set_visible(false) EventBus.stockpile_selected.connect(_on_stockpile_selected) EventBus.stockpile_deselected.connect(_on_stockpile_deselected) EventBus.pawn_selected.connect(_on_other_selected) EventBus.workbench_selected.connect(_on_other_selected) Audit.log("stockpile_panel", "StockpilePanel ready (layer %d)" % layer) func _exit_tree() -> void: if EventBus.stockpile_selected.is_connected(_on_stockpile_selected): EventBus.stockpile_selected.disconnect(_on_stockpile_selected) if EventBus.stockpile_deselected.is_connected(_on_stockpile_deselected): EventBus.stockpile_deselected.disconnect(_on_stockpile_deselected) if EventBus.pawn_selected.is_connected(_on_other_selected): EventBus.pawn_selected.disconnect(_on_other_selected) if EventBus.workbench_selected.is_connected(_on_other_selected): EventBus.workbench_selected.disconnect(_on_other_selected) # ── UI construction ─────────────────────────────────────────────────────────── func _build_ui() -> void: # Right-side sheet anchored to the right edge, full height. _panel = PanelContainer.new() _panel.name = "StockpileSheet" _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 _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) _header_label = Label.new() _header_label.name = "HeaderLabel" _header_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _header_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _header_label.mouse_filter = Control.MOUSE_FILTER_IGNORE header.add_child(_header_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) # ── Priority row ────────────────────────────────────────────────────────── var prio_lbl := Label.new() prio_lbl.text = Strings.t(&"ui.stockpile.priority") prio_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(prio_lbl) var prio_row := HBoxContainer.new() prio_row.name = "PriorityRow" prio_row.add_theme_constant_override("separation", 4) vbox.add_child(prio_row) # Buttons ordered CRITICAL → HIGH → NORMAL → LOW → OFF for display. # StorageDestination.Priority has CRITICAL=0, HIGH=1, NORMAL=2, LOW=3, OFF=4. var prio_labels: Array[StringName] = [ &"ui.stockpile.prio.critical", &"ui.stockpile.prio.high", &"ui.stockpile.prio.normal", &"ui.stockpile.prio.low", &"ui.stockpile.prio.off", ] _priority_btns.clear() for i in prio_labels.size(): var btn := Button.new() btn.text = Strings.t(prio_labels[i]) btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL btn.custom_minimum_size = Vector2(0, 40) btn.focus_mode = Control.FOCUS_NONE btn.toggle_mode = false var captured_i: int = i btn.pressed.connect(func() -> void: _on_priority_pressed(captured_i)) prio_row.add_child(btn) _priority_btns.append(btn) _add_separator(vbox) # ── Filter chip section ──────────────────────────────────────────────────── var filter_hdr := HBoxContainer.new() filter_hdr.name = "FilterHeader" filter_hdr.add_theme_constant_override("separation", 8) vbox.add_child(filter_hdr) var accepts_lbl := Label.new() accepts_lbl.text = Strings.t(&"ui.stockpile.accepts") accepts_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL accepts_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE filter_hdr.add_child(accepts_lbl) _all_label = Label.new() _all_label.name = "AllLabel" _all_label.text = Strings.t(&"ui.stockpile.accepts_all_hint") _all_label.modulate = Color(0.7, 0.7, 0.7) _all_label.mouse_filter = Control.MOUSE_FILTER_IGNORE filter_hdr.add_child(_all_label) # "Select all" / "Clear all" quick-action row. var quick_row := HBoxContainer.new() quick_row.name = "QuickRow" quick_row.add_theme_constant_override("separation", 8) vbox.add_child(quick_row) var select_all_btn := Button.new() select_all_btn.name = "SelectAllBtn" select_all_btn.text = Strings.t(&"ui.stockpile.select_all") select_all_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL select_all_btn.custom_minimum_size = Vector2(0, 40) select_all_btn.focus_mode = Control.FOCUS_NONE select_all_btn.pressed.connect(_on_select_all_pressed) quick_row.add_child(select_all_btn) var clear_all_btn := Button.new() clear_all_btn.name = "ClearAllBtn" clear_all_btn.text = Strings.t(&"ui.stockpile.clear_all") clear_all_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL clear_all_btn.custom_minimum_size = Vector2(0, 40) clear_all_btn.focus_mode = Control.FOCUS_NONE clear_all_btn.pressed.connect(_on_clear_all_pressed) quick_row.add_child(clear_all_btn) # 4-column chip grid. _chips_grid = GridContainer.new() _chips_grid.name = "ChipsGrid" _chips_grid.columns = 4 _chips_grid.add_theme_constant_override("h_separation", 4) _chips_grid.add_theme_constant_override("v_separation", 4) _chips_grid.mouse_filter = Control.MOUSE_FILTER_IGNORE vbox.add_child(_chips_grid) func _add_separator(parent: VBoxContainer) -> void: var sep := HSeparator.new() sep.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(sep) func _clear_children(node: Node) -> void: for child in node.get_children(): child.queue_free() # ── event handlers ──────────────────────────────────────────────────────────── func _on_stockpile_selected(zone) -> void: current_zone = zone _refresh_header() _update_priority_row() _populate_chips() _set_visible(true) Audit.log("stockpile_panel", "opened for zone at %s" % str(zone.position)) func _on_stockpile_deselected() -> void: current_zone = null _set_visible(false) Audit.log("stockpile_panel", "closed (deselected)") func _on_other_selected(_ignored = null) -> void: # Mutual exclusion: any other selection type hides this panel. if current_zone == null: return current_zone = null _set_visible(false) Audit.log("stockpile_panel", "closed (other panel selected)") func _on_close_pressed() -> void: current_zone = null _set_visible(false) EventBus.stockpile_deselected.emit() Audit.log("stockpile_panel", "closed (X button)") func _on_priority_pressed(prio_index: int) -> void: if current_zone == null: return current_zone.priority = prio_index as StorageDestination.Priority _update_priority_row() EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "priority → %d" % prio_index) func _on_select_all_pressed() -> void: if current_zone == null: return # Empty array = wildcard — accept all. call_deferred to avoid freeing chips # while this button's pressed signal is still emitting. current_zone.accepted_types.clear() call_deferred("_populate_chips") EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "select_all → wildcard mode") func _on_clear_all_pressed() -> void: if current_zone == null: return # Start explicit-list mode but with nothing checked. # We set accepted_types to all types so *nothing* is excluded yet, then # clear to empty which means… wildcard. To mean "accept nothing" we need # Priority.OFF instead. So: clear all chips means set priority to OFF and # leave accepted_types alone (wildcard doesn't matter when OFF). # The idiomatic result the player expects from "Clear all" on a filter grid # is that the zone accepts nothing — set priority to OFF. current_zone.priority = StorageDestination.Priority.OFF _update_priority_row() call_deferred("_populate_chips") EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "clear_all → priority OFF") # ── refresh helpers ──────────────────────────────────────────────────────────── func _refresh_header() -> void: if current_zone == null: return var tile_count: int = 0 if current_zone.has_method("tile_count"): tile_count = current_zone.tile_count() elif current_zone.get("tiles") != null: tile_count = current_zone.tiles.size() _header_label.text = "%s (%d)" % [Strings.t(&"ui.stockpile.title"), tile_count] func _update_priority_row() -> void: if current_zone == null: return var active: int = int(current_zone.priority) # CRITICAL=0 HIGH=1 NORMAL=2 LOW=3 OFF=4 — matches button array order. var active_tint := Color(0.90, 0.65, 0.20) var inactive_tint := Color(1.0, 1.0, 1.0) for i in _priority_btns.size(): _priority_btns[i].modulate = active_tint if i == active else inactive_tint func _populate_chips() -> void: if current_zone == null: return _clear_children(_chips_grid) var is_wildcard: bool = current_zone.accepted_types.is_empty() _all_label.visible = is_wildcard for type in Item.ALL_TYPES: var chip := _make_chip(type, is_wildcard or (type in current_zone.accepted_types)) _chips_grid.add_child(chip) ## Build a single filter chip Button for one item type. ## checked=true renders the chip as "included" (white/bright), false as dimmed. func _make_chip(type: StringName, checked: bool) -> Button: # Label: try "item." key; Strings.t() returns the key string itself # when missing (and emits a push_warning), so we detect that and fall back # to a capitalized form without the "item." prefix. var label_key: StringName = StringName("item." + String(type)) var looked_up: String = Strings.t(label_key) var label_text: String if looked_up != String(label_key): label_text = looked_up else: label_text = String(type).capitalize() var btn := Button.new() btn.text = label_text btn.custom_minimum_size = Vector2(0, 40) btn.focus_mode = Control.FOCUS_NONE btn.toggle_mode = false btn.modulate = Color(1.0, 1.0, 1.0) if checked else Color(0.5, 0.5, 0.5, 0.8) btn.pressed.connect(func() -> void: _on_chip_pressed(type)) return btn func _on_chip_pressed(type: StringName) -> void: if current_zone == null: return var is_wildcard: bool = current_zone.accepted_types.is_empty() if is_wildcard: # Wildcard → explicit: start with all types checked, then remove this one. var explicit: Array[StringName] = [] for t in Item.ALL_TYPES: if t != type: explicit.append(t) current_zone.accepted_types = explicit else: # Toggle: add if absent, remove if present. if type in current_zone.accepted_types: current_zone.accepted_types.erase(type) # If we just unchecked the last one, go back to wildcard. if current_zone.accepted_types.is_empty(): pass # empty = wildcard already else: current_zone.accepted_types.append(type) # Defer the rebuild — don't free this chip while its pressed signal emits. call_deferred("_populate_chips") EventBus.stockpile_layout_changed.emit() Audit.log("stockpile_panel", "chip toggled: %s → accepted_types size=%d" % [ type, current_zone.accepted_types.size() ]) # ── visibility ──────────────────────────────────────────────────────────────── func _set_visible(v: bool) -> void: if _panel != null: _panel.visible = v