Mi2dRPGamEng/engine/ui.py

155 lines
6.3 KiB
Python

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")
active_tab: str = "bio"
open_backpack_slot: str | None = None # clothing slot whose granted-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 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