from __future__ import annotations from dataclasses import dataclass, field from engine.character import Character from engine.defs import DefRegistry # Fixed scan order populate_ability_bar fills slots in - matches HAND_TO_ARM_SLOT's own order # in engine/character.py, so a two-armed character's slots land in the same left-to-right # sense a player would expect (right hand first, then left, then the second pair if present). ABILITY_BAR_HAND_SLOTS = ("right_hand", "left_hand", "right_hand_2", "left_hand_2") @dataclass class LayerPickerMenu: """Pure interaction state for choosing which of a tile's occupied layers a tool targets. No rendering here - this project has no text-rendering system yet, so drawing an actual on-screen popup box is a follow-up UI-layer task. This is the game-logic side of it: which option is highlighted, and what layer name comes back once the player confirms. """ map_id: str x: int y: int z: int options: list[str] = field(default_factory=list) selected_index: int = 0 def cycle(self, delta: int = 1) -> None: if self.options: self.selected_index = (self.selected_index + delta) % len(self.options) def confirm(self) -> str | None: if not self.options: return None return self.options[self.selected_index] @dataclass class AbilityBarSlot: """One assigned slot on the ability bar: where to find the ability-granting thing (a wielded item in a hand, or an installed implant) when this slot gets triggered - see resources/logic/actions.py::activate_ability_bar_slot for the dispatch on `kind`. """ kind: str # "hand" or "implant" source_id: str # hand_slot name (e.g. "right_hand") or implant item_def_id ability_id: str # the ability granted, for display/bookkeeping @dataclass class AbilityBar: """Pure interaction state for the bottom-left ability bar: up to 10 assignable slots (numkeys 1-9 and 0), selectable by mouse click or hotkey - both just call select(). No rendering here - same boundary as LayerPickerMenu above: this project has no text/icon-rendering system yet, so drawing the actual bar and highlighting the selected slot on-screen is a follow-up UI-layer task. """ slots: list[AbilityBarSlot | None] = field(default_factory=lambda: [None] * 10) selected_index: int | None = None def assign(self, index: int, slot: AbilityBarSlot | None) -> None: self.slots[index] = slot def select(self, index: int) -> None: if 0 <= index < len(self.slots): self.selected_index = index def selected_slot(self) -> AbilityBarSlot | None: if self.selected_index is None: return None return self.slots[self.selected_index] def populate_ability_bar(bar: AbilityBar, character: Character, registry: DefRegistry) -> None: """Auto-fills `bar` from whatever `character` can currently actually cast: each wielded hand holding an ability-granting item (see ABILITY_BAR_HAND_SLOTS), then each installed ability-granting implant - in that fixed order, one per slot. Meant to be re-run whenever the loadout might have changed (main.py calls it once per frame during play; cheap enough not to bother caching), which also means it always leaves stale slots from a dropped/ unwielded item cleared rather than assigning past the end. """ bar.slots = [None] * len(bar.slots) index = 0 for hand_slot in ABILITY_BAR_HAND_SLOTS: if index >= len(bar.slots): return held = character.get_held_item(hand_slot) if held is None: continue item_def = registry.get_item_def(held.item_def_id) if item_def.grants_ability is None: continue bar.assign(index, AbilityBarSlot(kind="hand", source_id=hand_slot, ability_id=item_def.grants_ability)) index += 1 for implant_id in character.implants: if index >= len(bar.slots): return item_def = registry.get_item_def(implant_id) if item_def.grants_ability is None: continue bar.assign(index, AbilityBarSlot(kind="implant", source_id=implant_id, ability_id=item_def.grants_ability)) index += 1 @dataclass class CharacterMenu: """Pure interaction state for the bottom-left tabbed character menu: a "bio" tab (skills/ mental stats/health - all already on the Character model), an "equipment" tab (worn clothing + held items), and a "medical" tab (per-body-part integrity, ailments, and organ health/removal - see engine/character.py's BodyPart/Organ/Ailment). Right-clicking an equipped slot that grants an inventory (e.g. a worn backpack) opens a popup submenu above the menu showing that slot's contents. No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio, equipment, and medical data already lives on the Character/DefRegistry models this engine built over the whole session, so this class only tracks which tab is active and which backpack popup (if any) is open - drawing the tabs, labels, and popup box is a follow-up UI-layer task (see engine/render/menu_draw.py::draw_character_card). """ TABS = ("bio", "equipment", "medical") POCKETS_SLOT = "_pockets" # sentinel open_backpack_slot value for the character's own base # inventory (Character.inventory) - it isn't worn, so it has no real clothing slot name. active_tab: str = "bio" open_backpack_slot: str | None = None # clothing slot (or POCKETS_SLOT) whose inventory popup is open def select_tab(self, tab: str) -> None: if tab in self.TABS: self.active_tab = tab self.open_backpack_slot = None # leaving equipment closes any open popup def cycle_tab(self) -> None: """Advances to the next tab in TABS order, wrapping around - see main.py's cycle_character_tab action (bound to Tab by default), which was the only way to ever actually reach a tab other than the default "bio" one before this existed. """ index = self.TABS.index(self.active_tab) self.select_tab(self.TABS[(index + 1) % len(self.TABS)]) def right_click_equipment_slot(self, slot: str, character, registry) -> bool: """Toggles the backpack popup for a worn item that grants an inventory (e.g. a backpack). No-op (returns False, nothing opens) for an empty slot or a worn item with no granted inventory - e.g. a plain jacket has nothing to pop open. """ piece = character.get_clothing(slot) if piece is None: return False item_def = registry.get_item_def(piece.item_def_id) if item_def.grants_inventory_size is None: return False if self.open_backpack_slot == slot: self.open_backpack_slot = None return False self.open_backpack_slot = slot return True def close_backpack_popup(self) -> None: self.open_backpack_slot = None def resolve_open_inventory(self, character, registry): """The Inventory object `open_backpack_slot` currently refers to, or None if nothing's open - POCKETS_SLOT resolves to the character's own base inventory, any other slot to that worn item's granted inventory (see Character.get_equipped_inventory). """ if self.open_backpack_slot is None: return None if self.open_backpack_slot == self.POCKETS_SLOT: return character.inventory return character.get_equipped_inventory(self.open_backpack_slot, registry) def toggle_backpack(self, slot: str, character, registry) -> bool: """Single entry point for "the player clicked this equipment-tab row" - handles both the POCKETS_SLOT sentinel (toggled directly, since it isn't real clothing) and real worn slots (delegates to right_click_equipment_slot's existing grants-inventory check). """ if slot == self.POCKETS_SLOT: if character.inventory is None: return False if self.open_backpack_slot == slot: self.open_backpack_slot = None return False self.open_backpack_slot = slot return True return self.right_click_equipment_slot(slot, character, registry)