392 lines
19 KiB
Python
392 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
from engine.controls_menu import ControlsMenu
|
|
from engine.dev_tools import DevFactionMenu
|
|
from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu
|
|
from engine.render.ui_draw import LINE_HEIGHT, draw_panel, draw_text, text_width
|
|
from engine.ui import AbilityBar, CharacterMenu
|
|
from engine.world_creation import WorldCreationMenu
|
|
|
|
MENU_OPTION_LABELS: dict[str, str] = {
|
|
"continue": "Continue",
|
|
"new_game": "New Game",
|
|
"controls": "Configure Controls",
|
|
"resume": "Resume",
|
|
"save_and_quit_to_menu": "Save & Quit to Menu",
|
|
}
|
|
|
|
ROW_H = LINE_HEIGHT
|
|
PADDING = 16.0
|
|
|
|
|
|
def _draw_option_list(buckets, options: list[str], selected_index: int, x: float, y: float) -> None:
|
|
for i, option in enumerate(options):
|
|
label = MENU_OPTION_LABELS.get(option, option)
|
|
row_y = y + i * ROW_H
|
|
if i == selected_index:
|
|
draw_panel(buckets, "highlight", x - 6, row_y - 2, text_width("> " + label) + 12, ROW_H)
|
|
draw_text(buckets, ("> " if i == selected_index else " ") + label, x, row_y)
|
|
|
|
|
|
def draw_main_menu(buckets, menu: MainMenu, viewport_w: float, viewport_h: float) -> None:
|
|
options = menu.options()
|
|
panel_w, panel_h = 320.0, PADDING * 2 + ROW_H * (len(options) + 1)
|
|
x = (viewport_w - panel_w) / 2
|
|
y = (viewport_h - panel_h) / 2
|
|
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
|
draw_text(buckets, "Mi2d RPG Engine", x + PADDING, y + PADDING)
|
|
_draw_option_list(buckets, options, menu.selected_index, x + PADDING, y + PADDING + ROW_H * 1.5)
|
|
|
|
|
|
def draw_pause_menu(buckets, menu: PauseMenu, viewport_w: float, viewport_h: float) -> None:
|
|
options = list(PauseMenu.OPTIONS)
|
|
panel_w, panel_h = 320.0, PADDING * 2 + ROW_H * (len(options) + 1)
|
|
x = (viewport_w - panel_w) / 2
|
|
y = (viewport_h - panel_h) / 2
|
|
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
|
draw_text(buckets, "Paused", x + PADDING, y + PADDING)
|
|
_draw_option_list(buckets, options, menu.selected_index, x + PADDING, y + PADDING + ROW_H * 1.5)
|
|
|
|
|
|
def draw_character_creation(
|
|
buckets, flow: CharacterCreationFlow, viewport_w: float, viewport_h: float, title: str = "New Character"
|
|
) -> None:
|
|
"""Species/name creation flow, shared by the launch-time "new game" screen and the dev-mode
|
|
NPC spawner (see main.py's handle_npc_spawn_key) - `title` is the only thing that ever
|
|
differs between the two, since both drive the exact same CharacterCreationFlow.
|
|
"""
|
|
panel_w, panel_h = 420.0, 220.0
|
|
x = (viewport_w - panel_w) / 2
|
|
y = (viewport_h - panel_h) / 2
|
|
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
|
draw_text(buckets, title, x + PADDING, y + PADDING)
|
|
|
|
if flow.step == "species":
|
|
species = flow.species_list()
|
|
current = flow.creator.species_id or "-"
|
|
draw_text(buckets, f"Species: < {current} >", x + PADDING, y + PADDING + ROW_H * 2)
|
|
draw_text(buckets, f"({flow.species_index + 1}/{max(len(species), 1)})", x + PADDING, y + PADDING + ROW_H * 3)
|
|
draw_text(buckets, "Left/Right to choose, Enter to continue", x + PADDING, y + panel_h - PADDING - ROW_H)
|
|
elif flow.step == "name":
|
|
draw_text(buckets, f"Species: {flow.creator.species_id}", x + PADDING, y + PADDING + ROW_H * 2)
|
|
draw_text(buckets, f"Name: {flow.creator.name}_", x + PADDING, y + PADDING + ROW_H * 3)
|
|
draw_text(buckets, "Type a name, Enter to confirm, Esc to go back", x + PADDING, y + panel_h - PADDING - ROW_H)
|
|
else: # password
|
|
masked = "*" * len(flow.creator.secret_password)
|
|
draw_text(buckets, f"Name: {flow.creator.name}", x + PADDING, y + PADDING + ROW_H * 2)
|
|
draw_text(buckets, f"Secret Items Password: {masked}_", x + PADDING, y + PADDING + ROW_H * 3)
|
|
draw_text(
|
|
buckets, "(optional) Type a password, Enter to confirm, Esc to go back", x + PADDING, y + panel_h - PADDING - ROW_H
|
|
)
|
|
|
|
|
|
def draw_world_creation(buckets, menu: WorldCreationMenu, viewport_w: float, viewport_h: float) -> None:
|
|
panel_w, panel_h = 480.0, PADDING * 2 + ROW_H * (len(menu.templates) * 2 + 2)
|
|
x = (viewport_w - panel_w) / 2
|
|
y = (viewport_h - panel_h) / 2
|
|
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
|
draw_text(buckets, "Choose a World", x + PADDING, y + PADDING)
|
|
|
|
selected = menu.selected_index if menu.selected_index is not None else 0
|
|
for i, template in enumerate(menu.templates):
|
|
row_y = y + PADDING + ROW_H * (1.5 + i * 2)
|
|
if i == selected:
|
|
draw_panel(buckets, "highlight", x + PADDING - 6, row_y - 2, text_width(template.name) + 12, ROW_H)
|
|
draw_text(buckets, template.name, x + PADDING, row_y)
|
|
draw_text(buckets, template.description[:60], x + PADDING + 10, row_y + ROW_H)
|
|
|
|
draw_text(buckets, "Up/Down to choose, Enter to confirm", x + PADDING, y + panel_h - PADDING - ROW_H)
|
|
|
|
|
|
def draw_controls_menu(buckets, menu: ControlsMenu, viewport_w: float, viewport_h: float) -> None:
|
|
panel_w = 480.0
|
|
panel_h = PADDING * 2 + ROW_H * (len(menu.actions) + 2)
|
|
x = (viewport_w - panel_w) / 2
|
|
y = max(20.0, (viewport_h - panel_h) / 2)
|
|
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
|
draw_text(buckets, "Configure Controls", x + PADDING, y + PADDING)
|
|
|
|
for i, action in enumerate(menu.actions):
|
|
row_y = y + PADDING + ROW_H * (i + 1.5)
|
|
keys = ", ".join(menu.keybindings.keys_for_action(action)) or "-"
|
|
capturing = menu.awaiting_key and i == menu.selected_index
|
|
label = f"{action}: {'press a key...' if capturing else keys}"
|
|
if i == menu.selected_index:
|
|
draw_panel(buckets, "highlight", x + PADDING - 6, row_y - 2, text_width(label) + 12, ROW_H)
|
|
draw_text(buckets, label, x + PADDING, row_y)
|
|
|
|
footer_y = y + panel_h - PADDING - ROW_H
|
|
draw_text(buckets, "Enter to rebind, Esc to go back", x + PADDING, footer_y)
|
|
|
|
|
|
def draw_faction_editor(buckets, menu: DevFactionMenu, character, viewport_w: float, viewport_h: float) -> None:
|
|
"""Dev-mode-only: lists `character`'s current faction tags (see Character.factions) with
|
|
the selected one highlighted, plus the tag currently being typed - see main.py's
|
|
dev_edit_player_factions/dev_edit_target_factions and DevFactionMenu itself for the
|
|
add/remove/select logic this only renders.
|
|
"""
|
|
tag_count = len(character.factions)
|
|
panel_w = 380.0
|
|
panel_h = PADDING * 2 + ROW_H * (tag_count + 4.5)
|
|
x = (viewport_w - panel_w) / 2
|
|
y = max(20.0, (viewport_h - panel_h) / 2)
|
|
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
|
draw_text(buckets, f"Factions: {character.name or character.entity_id}", x + PADDING, y + PADDING)
|
|
|
|
if not character.factions:
|
|
draw_text(buckets, "(no tags yet)", x + PADDING, y + PADDING + ROW_H * 1.5)
|
|
for i, tag in enumerate(character.factions):
|
|
row_y = y + PADDING + ROW_H * (1.5 + i)
|
|
label = f"> {tag}" if i == menu.selected_index else f" {tag}"
|
|
if i == menu.selected_index:
|
|
draw_panel(buckets, "highlight", x + PADDING - 6, row_y - 2, text_width(label) + 12, ROW_H)
|
|
draw_text(buckets, label, x + PADDING, row_y)
|
|
|
|
input_row_y = y + PADDING + ROW_H * (tag_count + 2.0)
|
|
draw_text(buckets, f"New tag: {menu.input_text}_", x + PADDING, input_row_y)
|
|
footer_y = y + panel_h - PADDING - ROW_H
|
|
draw_text(
|
|
buckets, "Type + Enter to add, Up/Down to select, Delete to remove, Esc to close", x + PADDING, footer_y
|
|
)
|
|
|
|
|
|
CHARACTER_CARD_W = 260.0
|
|
CHARACTER_CARD_H = 200.0
|
|
|
|
INVENTORY_CELL_SIZE = 14.0
|
|
INVENTORY_CELL_GAP = 1.0
|
|
|
|
|
|
def character_card_rect(viewport_h: float) -> tuple[float, float, float, float]:
|
|
"""(x, y, w, h) of the character card panel in screen pixels - shared by drawing and
|
|
main.py's click hit-testing (see point_in_rect), so the two can never drift out of sync.
|
|
"""
|
|
x = PADDING + ABILITY_BAR_W + PADDING
|
|
y = viewport_h - CHARACTER_CARD_H - PADDING
|
|
return x, y, CHARACTER_CARD_W, CHARACTER_CARD_H
|
|
|
|
|
|
def point_in_rect(px: float, py: float, rect: tuple[float, float, float, float]) -> bool:
|
|
x, y, w, h = rect
|
|
return x <= px <= x + w and y <= py <= y + h
|
|
|
|
|
|
def inventory_cell_at(inventory, origin: tuple[float, float], px: float, py: float) -> tuple[int, int] | None:
|
|
"""Which cell (gx, gy) of `inventory`'s grid - drawn with its top-left at `origin` - a
|
|
screen point falls on, or None if it's outside the grid. `inventory` is whichever
|
|
container's contents are currently on screen (see draw_backpack_popup); callers are
|
|
expected to already know it's not None (e.g. via CharacterMenu.resolve_open_inventory).
|
|
"""
|
|
grid_x, grid_y = origin
|
|
cell = INVENTORY_CELL_SIZE + INVENTORY_CELL_GAP
|
|
if px < grid_x or py < grid_y:
|
|
return None
|
|
gx = int((px - grid_x) // cell)
|
|
gy = int((py - grid_y) // cell)
|
|
if 0 <= gx < inventory.width and 0 <= gy < inventory.height:
|
|
return gx, gy
|
|
return None
|
|
|
|
|
|
def draw_inventory_grid(buckets, inventory, origin: tuple[float, float], dragging_item_id: str | None = None) -> None:
|
|
"""`inventory`'s cells, top-left at `origin` - each occupied cell highlighted, with a
|
|
2-letter abbreviation of the item def id at its top-left corner. The cell currently being
|
|
dragged (if any) is left blank - see draw_drag_ghost, which renders it following the cursor
|
|
instead (main.py wires the actual drag/drop via pointer_down/pointer_up - see
|
|
resources/logic/pickup.py::drop_item).
|
|
"""
|
|
grid_x, grid_y = origin
|
|
cell = INVENTORY_CELL_SIZE + INVENTORY_CELL_GAP
|
|
for gy in range(inventory.height):
|
|
for gx in range(inventory.width):
|
|
cx, cy = grid_x + gx * cell, grid_y + gy * cell
|
|
placed = inventory.item_at(gx, gy)
|
|
if placed is not None and placed.item.item_id == dragging_item_id:
|
|
draw_panel(buckets, "border", cx, cy, INVENTORY_CELL_SIZE, INVENTORY_CELL_SIZE)
|
|
continue
|
|
panel = "highlight" if placed is not None else "border"
|
|
draw_panel(buckets, panel, cx, cy, INVENTORY_CELL_SIZE, INVENTORY_CELL_SIZE)
|
|
if placed is not None and (placed.x, placed.y) == (gx, gy):
|
|
draw_text(buckets, placed.item.def_id[:2], cx + 1, cy, size=8.0, char_advance=6.0)
|
|
|
|
|
|
def draw_drag_ghost(buckets, def_id: str, px: float, py: float) -> None:
|
|
"""A small highlighted box following the cursor while an inventory item is being dragged -
|
|
see draw_inventory_grid (the origin cell is left blank while this is shown) and main.py's
|
|
pointer_down/pointer_up handling for the actual drag-state tracking.
|
|
"""
|
|
half = INVENTORY_CELL_SIZE / 2
|
|
draw_panel(buckets, "highlight", px - half, py - half, INVENTORY_CELL_SIZE, INVENTORY_CELL_SIZE)
|
|
draw_text(buckets, def_id[:2], px - half + 1, py - half, size=8.0, char_advance=6.0)
|
|
|
|
|
|
def _equipment_rows(character) -> list[str]:
|
|
"""Ordered row identifiers for the equipment tab: each worn clothing slot, then
|
|
CharacterMenu.POCKETS_SLOT if the character has a base inventory at all - shared by
|
|
draw_character_card's equipment tab and equipment_row_at so the two can't drift apart.
|
|
"""
|
|
worn = character.clothing or character.default_clothing
|
|
rows = [piece.slot for piece in worn]
|
|
if character.inventory is not None:
|
|
rows.append(CharacterMenu.POCKETS_SLOT)
|
|
return rows
|
|
|
|
|
|
def _equipment_row_label(character, slot: str) -> str:
|
|
if slot == CharacterMenu.POCKETS_SLOT:
|
|
return "Pockets"
|
|
piece = character.get_clothing(slot)
|
|
return f"{slot}: {piece.item_def_id}" if piece is not None else slot
|
|
|
|
|
|
def equipment_row_at(character, viewport_h: float, px: float, py: float) -> str | None:
|
|
"""Which equipment-tab row (a clothing slot, or CharacterMenu.POCKETS_SLOT) a screen point
|
|
falls on, or None. Callers should already know `px, py` landed inside the character card's
|
|
equipment tab (see point_in_rect/character_card_rect) - this only handles the row math.
|
|
"""
|
|
x, y, w, _h = character_card_rect(viewport_h)
|
|
if not (x <= px <= x + w):
|
|
return None
|
|
tab_y = y + 10 + ROW_H
|
|
body_y = tab_y + ROW_H * 1.5
|
|
if py < body_y:
|
|
return None
|
|
rows = _equipment_rows(character)
|
|
row = int((py - body_y) // ROW_H)
|
|
if 0 <= row < len(rows):
|
|
return rows[row]
|
|
return None
|
|
|
|
|
|
def backpack_popup_rect(inventory, viewport_h: float) -> tuple[float, float, float, float]:
|
|
"""(x, y, w, h) of the floating popup card showing a single container's contents - sized to
|
|
fit `inventory`'s own grid and anchored just above the character card (see
|
|
character_card_rect). Shared by drawing and main.py's click hit-testing.
|
|
"""
|
|
card_x, card_y, _w, _h = character_card_rect(viewport_h)
|
|
cell = INVENTORY_CELL_SIZE + INVENTORY_CELL_GAP
|
|
w = inventory.width * cell + 20.0
|
|
h = inventory.height * cell + 20.0 + ROW_H
|
|
y = card_y - h - 8.0
|
|
return card_x, y, w, h
|
|
|
|
|
|
def backpack_grid_origin(rect: tuple[float, float, float, float]) -> tuple[float, float]:
|
|
x, y, _w, _h = rect
|
|
return x + 10.0, y + 10.0 + ROW_H
|
|
|
|
|
|
def draw_backpack_popup(
|
|
buckets, character, menu: CharacterMenu, registry, viewport_h: float, dragging_item_id: str | None = None
|
|
) -> None:
|
|
"""The separate card for whichever container `menu.open_backpack_slot` names (a worn
|
|
backpack, or the character's own base inventory via POCKETS_SLOT) - opened by clicking that
|
|
row in the equipment tab (see equipment_row_at/CharacterMenu.toggle_backpack). Does nothing
|
|
if nothing's open, or the referenced container has no inventory (shouldn't normally happen -
|
|
toggle_backpack only opens containers that do).
|
|
"""
|
|
inventory = menu.resolve_open_inventory(character, registry)
|
|
if inventory is None:
|
|
return
|
|
rect = backpack_popup_rect(inventory, viewport_h)
|
|
x, y, w, h = rect
|
|
draw_panel(buckets, "background", x, y, w, h)
|
|
title = "Pockets" if menu.open_backpack_slot == CharacterMenu.POCKETS_SLOT else menu.open_backpack_slot
|
|
draw_text(buckets, title, x + 10, y + 10)
|
|
draw_inventory_grid(buckets, inventory, backpack_grid_origin(rect), dragging_item_id)
|
|
|
|
ABILITY_BAR_SLOT_W = 30.0
|
|
ABILITY_BAR_SLOT_H = 30.0
|
|
ABILITY_BAR_GAP = 3.0
|
|
ABILITY_BAR_KEY_LABELS = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
|
|
ABILITY_BAR_W = len(ABILITY_BAR_KEY_LABELS) * ABILITY_BAR_SLOT_W + (len(ABILITY_BAR_KEY_LABELS) - 1) * ABILITY_BAR_GAP
|
|
|
|
|
|
def draw_ability_bar(buckets, bar: AbilityBar, viewport_h: float) -> None:
|
|
"""Bottom-left action hotbar (numkeys 1-9/0 select a slot, "e"/gp_b activates whichever's
|
|
selected - see main.py). engine/ui.py::AbilityBar had never been wired to any rendering,
|
|
populated from the player's actual loadout, or hooked up to input consumption before, which
|
|
is why it "didn't show" at all.
|
|
"""
|
|
y = viewport_h - ABILITY_BAR_SLOT_H - PADDING
|
|
for i, key_label in enumerate(ABILITY_BAR_KEY_LABELS):
|
|
x = PADDING + i * (ABILITY_BAR_SLOT_W + ABILITY_BAR_GAP)
|
|
panel_name = "highlight" if i == bar.selected_index else "border"
|
|
draw_panel(buckets, panel_name, x, y, ABILITY_BAR_SLOT_W, ABILITY_BAR_SLOT_H)
|
|
draw_text(buckets, key_label, x + 2, y + 2, size=10.0, char_advance=7.0)
|
|
slot = bar.slots[i]
|
|
if slot is not None:
|
|
draw_text(buckets, slot.ability_id[:5], x + 1, y + 16, size=8.0, char_advance=6.0)
|
|
|
|
|
|
MEDICAL_LOG_DISPLAY_COUNT = 5
|
|
|
|
|
|
def _medical_lines(character, registry) -> list[str]:
|
|
"""One line per body part (integrity, or REMOVED) plus an indented sub-line for each of its
|
|
ailments and organs - the data's all on Character.resolved_body_parts() (engine/character.py)
|
|
already, this just flattens it for the medical tab (see draw_character_card). Finishes with
|
|
the most recent entries from Character.medical_log (newest first), if there are any.
|
|
"""
|
|
lines = [f"{character.blood_label(registry)}: {character.blood_percentage:.0f}%"]
|
|
for slot, part in sorted(character.resolved_body_parts().items()):
|
|
if part is None:
|
|
continue
|
|
if part.removed:
|
|
lines.append(f"{slot}: REMOVED")
|
|
continue
|
|
lines.append(f"{slot}: {part.integrity:.0f}%")
|
|
for ailment in part.ailments:
|
|
lines.append(f" {ailment.name} (sev {ailment.severity:.1f})")
|
|
for organ in part.organs:
|
|
organ_def = registry.organ_defs.get(organ.organ_def_id)
|
|
organ_name = organ_def.name if organ_def is not None else organ.organ_def_id
|
|
status = "removed" if organ.removed else f"{organ.integrity:.0f}%"
|
|
lines.append(f" {organ_name}: {status}")
|
|
|
|
if character.medical_log:
|
|
lines.append("Log:")
|
|
recent = list(reversed(character.medical_log[-MEDICAL_LOG_DISPLAY_COUNT:]))
|
|
lines.extend(f" {entry}" for entry in recent)
|
|
return lines
|
|
|
|
|
|
def draw_character_card(buckets, character, menu: CharacterMenu, registry, viewport_h: float) -> None:
|
|
"""Bio/equipment/medical card, next to the ability bar along the bottom edge (see
|
|
draw_ability_bar) - engine/ui.py::CharacterMenu had never been wired to any rendering
|
|
before, which is why it "didn't show" - see main.py's toggle_character_card. The equipment
|
|
tab lists worn items plus a "Pockets" row for the character's own base inventory; clicking
|
|
a row that grants an inventory opens its own separate popup card (see draw_backpack_popup,
|
|
equipment_row_at, and main.py's pointer handling) rather than showing a grid inline here.
|
|
"""
|
|
x, y, w, h = character_card_rect(viewport_h)
|
|
draw_panel(buckets, "background", x, y, w, h)
|
|
|
|
name = character.name or "(unnamed)"
|
|
draw_text(buckets, name, x + 10, y + 10)
|
|
|
|
tab_y = y + 10 + ROW_H
|
|
for i, tab in enumerate(CharacterMenu.TABS):
|
|
label = f"[{tab}]" if tab == menu.active_tab else f" {tab} "
|
|
draw_text(buckets, label, x + 10 + i * 70, tab_y)
|
|
|
|
body_y = tab_y + ROW_H * 1.5
|
|
if menu.active_tab == "bio":
|
|
draw_text(buckets, f"Species: {character.species_id or '-'}", x + 10, body_y)
|
|
draw_text(buckets, f"{character.blood_label(registry)}: {character.blood_percentage:.0f}%", x + 10, body_y + ROW_H)
|
|
for i, (skill_id, value) in enumerate(sorted(character.bio.items())):
|
|
draw_text(buckets, f"{skill_id}: {value}", x + 10, body_y + ROW_H * (2 + i))
|
|
elif menu.active_tab == "equipment":
|
|
rows = _equipment_rows(character)
|
|
if not rows:
|
|
draw_text(buckets, "(nothing worn)", x + 10, body_y)
|
|
for i, slot in enumerate(rows):
|
|
row_y = body_y + ROW_H * i
|
|
label = _equipment_row_label(character, slot)
|
|
if slot == menu.open_backpack_slot:
|
|
draw_panel(buckets, "highlight", x + 10 - 4, row_y - 2, text_width(label) + 8, ROW_H)
|
|
draw_text(buckets, label, x + 10, row_y)
|
|
else:
|
|
for i, line in enumerate(_medical_lines(character, registry)):
|
|
draw_text(buckets, line, x + 10, body_y + ROW_H * i)
|