commit
7325c46f96
@ -0,0 +1,8 @@ |
||||
# Default ignored files |
||||
/shelf/ |
||||
/workspace.xml |
||||
# Editor-based HTTP Client requests |
||||
/httpRequests/ |
||||
# Datasource local storage ignored files |
||||
/dataSources/ |
||||
/dataSources.local.xml |
@ -0,0 +1,10 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<module type="PYTHON_MODULE" version="4"> |
||||
<component name="NewModuleRootManager"> |
||||
<content url="file://$MODULE_DIR$"> |
||||
<excludeFolder url="file://$MODULE_DIR$/venv" /> |
||||
</content> |
||||
<orderEntry type="jdk" jdkName="Python 3.11 (fabula)" jdkType="Python SDK" /> |
||||
<orderEntry type="sourceFolder" forTests="false" /> |
||||
</component> |
||||
</module> |
@ -0,0 +1,6 @@ |
||||
<component name="InspectionProjectProfileManager"> |
||||
<settings> |
||||
<option name="USE_PROJECT_PROFILE" value="false" /> |
||||
<version value="1.0" /> |
||||
</settings> |
||||
</component> |
@ -0,0 +1,4 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project version="4"> |
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (fabula)" project-jdk-type="Python SDK" /> |
||||
</project> |
@ -0,0 +1,8 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project version="4"> |
||||
<component name="ProjectModuleManager"> |
||||
<modules> |
||||
<module fileurl="file://$PROJECT_DIR$/.idea/fabula.iml" filepath="$PROJECT_DIR$/.idea/fabula.iml" /> |
||||
</modules> |
||||
</component> |
||||
</project> |
@ -0,0 +1,6 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project version="4"> |
||||
<component name="VcsDirectoryMappings"> |
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" /> |
||||
</component> |
||||
</project> |
@ -0,0 +1,71 @@ |
||||
from dataclasses import dataclass |
||||
|
||||
from effects import DamageEffect |
||||
from enums import CombatSide |
||||
from jsonable import JsonableDataclassArgs |
||||
from model import Action, Target, Effect, Character, Clock |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class AbilityAction(Action): |
||||
name: str |
||||
user: Target |
||||
costs: tuple[DamageEffect, ...] |
||||
effects: tuple[Effect, ...] |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class ModifyCharacterAction(Action): |
||||
index: int |
||||
old_character_data: Character | None |
||||
new_character_data: Character | None |
||||
|
||||
def __post_init__(self): |
||||
if self.old_character_data is None and self.new_character_data is None: |
||||
raise ValueError("At least one of old_character_data or new_character_data must be non-None") |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class ModifyClockAction(Action): |
||||
index: int |
||||
old_clock_data: Clock | None |
||||
new_clock_data: Clock | None |
||||
|
||||
def __post_init__(self): |
||||
if self.old_clock_data is None and self.new_clock_data is None: |
||||
raise ValueError("At least one of old_clock_data or new_clock_data must be non-None") |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class EndTurnAction(Action): |
||||
turn_ending_index: int |
||||
activating_side: CombatSide |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class StartTurnAction(Action): |
||||
turn_starting_index: int |
||||
old_active_side: CombatSide |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class StartRoundAction(Action): |
||||
last_round: int |
||||
old_active_side: CombatSide |
||||
old_turns_remaining: tuple[int, ...] |
||||
next_round: int |
||||
activating_side: CombatSide |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class StartBattleAction(Action): |
||||
starting_side: CombatSide |
||||
starting_round: int |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class EndBattleAction(Action): |
||||
old_round_number: int |
||||
old_active_side: CombatSide |
||||
old_starting_side: CombatSide |
||||
old_turns_remaining: tuple[int, ...] |
@ -0,0 +1,157 @@ |
||||
import asyncio |
||||
|
||||
from discord_webhook import DiscordEmbed |
||||
from urwid import MainLoop, AsyncioEventLoop, set_encoding |
||||
|
||||
from embedgen import WebhookExecutor, simple_character_field |
||||
from model import Character |
||||
from enums import CharacterType, Visibility, CombatSide |
||||
from ui import LogUI, CharactersUI, ClocksUI, FabulaUI, MainUI |
||||
|
||||
|
||||
class FabulaApp(object): |
||||
def __init__(self, wrapper: FabulaUI, clocks: ClocksUI, characters: CharactersUI, |
||||
log: LogUI, webhook: WebhookExecutor): |
||||
self.wrapper = wrapper |
||||
self.clocks = clocks |
||||
self.characters = characters |
||||
self.log = log |
||||
self.log.add_message("this is a log message") |
||||
self.log.add_message("this is a second log message with some **bolded** text") |
||||
self.log.move_divider() |
||||
self.log.add_message("this is the newest message, since the last log was sent") |
||||
prandia = Character(name="Prandia", hp=53, max_hp=65, mp=55, max_mp=55, ip=6, max_ip=6, max_turns=1, |
||||
turns_left=0, sp=3, statuses=frozenset(("Delicious", "Dazed")), affinities=tuple(), |
||||
access_key='1', visibility=Visibility.ShowEvenIfKO, mdf=7, df=11, dr=0, |
||||
character_type=CharacterType.PlayerCharacter, side=CombatSide.Heroes, |
||||
color="34eb89", player_discord_id="579932273558945792", player_name="Fak", |
||||
thumbnail="https://media.discordapp.net/attachments/989315969920929862/1058455278221271120/" |
||||
+ "342px-Bluhen_Epic_Quest_Square.png") |
||||
self.characters.add_character(prandia) |
||||
selia = Character(name="Sélia", hp=12, max_hp=50, mp=55, max_mp=55, ip=0, max_ip=0, max_turns=1, |
||||
turns_left=1, sp=None, statuses=frozenset(["Enraged"]), affinities=tuple(), |
||||
access_key='2', visibility=Visibility.ShowAll, mdf=7, df=11, dr=0, |
||||
character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Heroes, |
||||
color=None, thumbnail=None, player_discord_id=None, player_name=None) |
||||
self.characters.add_character(selia) |
||||
prandia_voraphilia = Character(name="Prandia's Voraphilia Resistance", hp=0, max_hp=1, mp=1, max_mp=1, ip=0, |
||||
max_ip=0, |
||||
max_turns=1, turns_left=1, sp=None, statuses=frozenset(), affinities=tuple(), |
||||
access_key='', |
||||
character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Heroes, |
||||
visibility=Visibility.Hide, mdf=7, df=11, dr=0, color="34eb89", |
||||
thumbnail=None, player_discord_id=None, player_name=None) |
||||
self.characters.add_character(prandia_voraphilia) |
||||
vacana = Character(name="Hungry Vacana", hp=53, max_hp=53, mp=55, max_mp=55, ip=6, max_ip=6, max_turns=2, |
||||
turns_left=1, sp=3, statuses=frozenset(["Transformed"]), affinities=tuple(), access_key='A', |
||||
visibility=Visibility.MaskStats, mdf=7, df=11, dr=0, |
||||
character_type=CharacterType.PlayerCharacter, side=CombatSide.Villains, |
||||
player_name="Nan", player_discord_id="158766486725328897", color="a50ecf", |
||||
thumbnail="https://images-ext-1.discordapp.net/external/" |
||||
+ "bMwLWSrrCyGiqgJ14icQjXXllStzsKbQ-htrANQdSJA/" |
||||
+ "https/cdn.picrew.me/shareImg/org/202211/1561797_pzHTS4JJ.png") |
||||
self.characters.add_character(vacana) |
||||
flow = Character(name="Hungry Flow", hp=69, max_hp=120, mp=55, max_mp=55, ip=0, max_ip=0, max_turns=2, |
||||
turns_left=2, sp=3, statuses=frozenset(), affinities=tuple(), access_key='B', |
||||
visibility=Visibility.MaskName, mdf=7, df=11, dr=0, |
||||
character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Villains, player_name=None, |
||||
player_discord_id=None, color="0073ff", thumbnail=None) |
||||
self.characters.add_character(flow) |
||||
vacana_hunger = Character(name="Vacana's Hunger", hp=27, max_hp=60, mp=1, max_mp=1, ip=0, max_ip=0, max_turns=2, |
||||
turns_left=0, sp=None, statuses=frozenset(), affinities=tuple(), access_key='', |
||||
visibility=Visibility.Hide, mdf=7, df=11, dr=0, |
||||
character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Villains, |
||||
color="a50ecf", |
||||
thumbnail=None, player_name=None, player_discord_id=None, ) |
||||
self.characters.add_character(vacana_hunger) |
||||
self.webhook = webhook |
||||
self.character_list = (prandia, selia, prandia_voraphilia, vacana, flow, vacana_hunger) |
||||
self.event_loop = None |
||||
self.loop = None |
||||
|
||||
@classmethod |
||||
def new(cls, webhook_url: str): |
||||
clocks = ClocksUI() |
||||
characters = CharactersUI() |
||||
log = LogUI() |
||||
main = MainUI(clocks, characters, log) |
||||
wrapper = FabulaUI(main) |
||||
webhook = WebhookExecutor(url=webhook_url) |
||||
return cls(wrapper=wrapper, clocks=clocks, characters=characters, |
||||
log=log, webhook=webhook) |
||||
|
||||
def keypress(self, key): |
||||
if key == "l": |
||||
self.log.add_message("Another log message, eh? Sure, I can hook you up with **one of those.** Here I go!") |
||||
|
||||
def run(self): |
||||
set_encoding("UTF-8") |
||||
self.event_loop = asyncio.new_event_loop() |
||||
loop = MainLoop( |
||||
widget=self.wrapper, |
||||
unhandled_input=self.keypress, |
||||
event_loop=AsyncioEventLoop(loop=self.event_loop), |
||||
palette=[ |
||||
(None, "light gray", "default", "default"), |
||||
("FrameTitle", "white,bold", "default", "bold"), |
||||
("LogBold", "white,bold", "default", "bold"), |
||||
("LogFocused", "light cyan,bold", "default", "bold"), |
||||
("LogUnfocused", "dark blue", "default", "default"), |
||||
("ClockName", "white,bold", "default", "bold"), |
||||
("ClockFocused", "light green,bold", "default", "bold"), |
||||
("ClockUnfocused", "dark green", "default", "default"), |
||||
("ClockBarEdge", 'yellow,bold', 'default', 'bold'), |
||||
("ClockBarFilled", 'yellow', 'default', 'default'), |
||||
("ClockBarEmpty", 'light gray', 'default', 'default'), |
||||
("ClockCurrent", 'light green,bold', 'default', 'bold'), |
||||
("ClockDivider", 'white', 'default', 'default'), |
||||
("ClockMax", 'dark green', 'default', 'default'), |
||||
("RoundLabel", 'light gray', 'default', 'default'), |
||||
("RoundNumber", 'white,bold', 'default', 'bold'), |
||||
("FabulaLabel", 'light gray', 'default', 'default'), |
||||
("FabulaNumber", 'white,bold', 'default', 'bold'), |
||||
("UltimaLabel", 'light gray', 'default', 'default'), |
||||
("UltimaNumber", 'white,bold', 'default', 'bold'), |
||||
("CharacterUnfocused", 'dark red', 'default', 'default'), |
||||
("CharacterFocused", 'light red,bold', 'default', 'bold'), |
||||
("CharacterPlayer", 'light green,bold', 'default', 'bold'), |
||||
("CharacterAlly", 'dark green,bold', 'default', 'bold'), |
||||
("CharacterEnemy", 'dark red,bold', 'default', 'bold'), |
||||
("CharacterEnemyPlayer", 'light red,bold', 'default', 'bold'), |
||||
("CharacterUnknown", 'light gray,bold', 'default', 'bold'), |
||||
("TurnDisabled", 'dark gray', 'default', 'default'), |
||||
("TurnAvailable", 'light green,bold', 'default', 'bold'), |
||||
("TurnActed", 'light gray', 'default', 'default'), |
||||
("HPFull", 'light green,bold', 'default', 'bold'), |
||||
("HPScratched", 'light green', 'default', 'default'), |
||||
("HPWounded", 'white', 'default', 'default'), |
||||
("HPCrisis", 'yellow', 'default', 'default'), |
||||
("HPPeril", 'light red', 'default', 'default'), |
||||
("HPDown", 'dark red,bold', 'default', 'bold'), |
||||
("HPLabel", 'light gray', 'default', 'default'), |
||||
("HPMax", 'light gray', 'default', 'default'), |
||||
("MPLabel", 'light gray', 'default', 'default'), |
||||
("MPValue", 'light blue', 'default', 'default'), |
||||
("MPMax", 'light gray', 'default', 'default'), |
||||
("FPLabel", 'light gray', 'default', 'default'), |
||||
("UPLabel", 'light gray', 'default', 'default'), |
||||
("FPValue", 'yellow', 'default', 'default'), |
||||
("UPValue", 'yellow', 'default', 'default'), |
||||
("IPLabel", 'light gray', 'default', 'default'), |
||||
("IPValue", 'light cyan', 'default', 'default'), |
||||
("IPMax", 'light gray', 'default', 'default'), |
||||
("StatusKO", 'light red,bold', 'default', 'bold,standout'), |
||||
("StatusCrisis", 'yellow,bold', 'default', 'bold'), |
||||
("Statuses", 'white', 'default', 'default'), |
||||
]) |
||||
loop.screen.set_terminal_properties( |
||||
colors=2 ** 24, |
||||
has_underline=True, |
||||
bright_is_bold=False, |
||||
) |
||||
embed = DiscordEmbed() |
||||
for header, field in [simple_character_field(character) for character in self.character_list]: |
||||
embed.add_embed_field(name=header, value=field, inline=False) |
||||
self.loop.run() |
||||
self.loop = None |
||||
self.event_loop = None |
@ -0,0 +1,59 @@ |
||||
from dataclasses import dataclass |
||||
|
||||
from enums import Counter, Element, Affinity, MissType |
||||
from jsonable import JsonableDataclassArgs |
||||
from model import Effect, Target, Action |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class OpportunityEffect(Effect): |
||||
target: Target |
||||
opportunity_text: str |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class SubActionEffect(Effect): |
||||
action: Action |
||||
trigger_text: str |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class DamageEffect(Effect): |
||||
target: Target | None |
||||
attribute: Counter |
||||
damage: int |
||||
old_value: int |
||||
new_value: int |
||||
max_value: int | None |
||||
element: Element |
||||
affinity: Affinity |
||||
piercing: bool |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class StatusEffect(Effect): |
||||
target: Target |
||||
old_status: str | None |
||||
new_status: str | None |
||||
|
||||
def __post_init__(self): |
||||
if self.old_status is None and self.new_status is None: |
||||
raise ValueError("At least one of old_status or new_status must be non-None") |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class FPBonusEffect(Effect): |
||||
rerolls: int |
||||
bond_bonus: int |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class MissEffect(Effect): |
||||
miss_type: MissType |
||||
target: Target |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class GeneralEffect(Effect): |
||||
text: str |
||||
target: Target |
@ -0,0 +1,101 @@ |
||||
from enum import Enum |
||||
|
||||
|
||||
class Affinity(Enum): |
||||
Absorb = "absorb" |
||||
Immune = "immune" |
||||
Resistant = "resistant" |
||||
Normal = "normal" |
||||
Vulnerable = "vulnerable" |
||||
|
||||
|
||||
class Element(Enum): |
||||
NonElemental = "non-elemental" |
||||
Physical = "physical" |
||||
Poison = "poison" |
||||
Fire = "fire" |
||||
Water = "water" |
||||
Lightning = "lightning" |
||||
Earth = "earth" |
||||
Wind = "wind" |
||||
Ice = "ice" |
||||
Light = "light" |
||||
Dark = "dark" |
||||
|
||||
|
||||
class CharacterType(Enum): |
||||
PlayerCharacter = "pc" |
||||
NonPlayerCharacter = "npc" |
||||
|
||||
@property |
||||
def sp_name_abbr(self): |
||||
if self is CharacterType.PlayerCharacter: |
||||
return "FP" |
||||
elif self is CharacterType.NonPlayerCharacter: |
||||
return "UP" |
||||
else: |
||||
return "SP" |
||||
|
||||
@property |
||||
def sp_name(self): |
||||
if self is CharacterType.PlayerCharacter: |
||||
return "Fabula Points" |
||||
elif self is CharacterType.NonPlayerCharacter: |
||||
return "Ultima Points" |
||||
else: |
||||
return "Special Points" |
||||
|
||||
|
||||
class Visibility(Enum): |
||||
ShowEvenIfKO = "show_even_if_ko" |
||||
ShowAll = "show" |
||||
MaskStats = "mask_stats" |
||||
MaskName = "mask_name" |
||||
Hide = "hide" |
||||
|
||||
@property |
||||
def show_name(self): |
||||
return self not in (Visibility.MaskName, Visibility.Hide) |
||||
|
||||
@property |
||||
def show_stats(self): |
||||
return self not in (Visibility.MaskStats, Visibility.MaskName, Visibility.Hide) |
||||
|
||||
def show_in_party_list(self, down=False): |
||||
if down: |
||||
return self == Visibility.ShowEvenIfKO |
||||
else: |
||||
return self != Visibility.Hide |
||||
|
||||
|
||||
class Counter(Enum): |
||||
HP = "HP" |
||||
MP = "MP" |
||||
IP = "IP" |
||||
SP = "SP" |
||||
|
||||
|
||||
class MissType(Enum): |
||||
Missed = "missed" |
||||
Blocked = "blocked" |
||||
Avoided = "avoided" |
||||
Repelled = "repelled" |
||||
Resisted = "resisted" |
||||
|
||||
|
||||
class CombatSide(Enum): |
||||
Heroes = "heroes" |
||||
Villains = "villains" |
||||
|
||||
@property |
||||
def opposite(self): |
||||
if self == CombatSide.Heroes: |
||||
return CombatSide.Villains |
||||
else: |
||||
return CombatSide.Heroes |
||||
|
||||
|
||||
class StatusChange(Enum): |
||||
Added = "added" |
||||
Removed = "removed" |
||||
Changed = "changed" |
@ -0,0 +1,279 @@ |
||||
from dataclasses import dataclass, fields, MISSING |
||||
from enum import Enum |
||||
from functools import reduce |
||||
from itertools import chain |
||||
from typing import ClassVar, get_origin, Union, get_args, Mapping, Any, Sequence |
||||
from types import UnionType |
||||
from warnings import warn |
||||
|
||||
JsonableDataclassArgs = {"init": True, "repr": True, "eq": True, "order": False, "frozen": True, "match_args": True, |
||||
"kw_only": True, "slots": True, "weakref_slot": True} |
||||
|
||||
JsonableParentArgs = {"init": False, "repr": True, "eq": True, "order": False, "frozen": True, "match_args": True, |
||||
"kw_only": True, "slots": True, "weakref_slot": True} |
||||
|
||||
|
||||
@dataclass(**JsonableParentArgs) |
||||
class Jsonable(object): |
||||
subclass_unique_attr_dict: ClassVar[dict[str, type["Jsonable"]]] = {} |
||||
subclass_attr_set: ClassVar[set[str]] = set() |
||||
|
||||
def __init__(self, **kwargs): |
||||
raise TypeError(f"{friendly_type(type(self))} is not instantiable") |
||||
|
||||
@classmethod |
||||
def instantiable(cls): |
||||
return "__init__" in cls.__dict__ and cls.__init__ is not Jsonable.__init__ |
||||
|
||||
def __init_subclass__(cls): |
||||
cls.subclass_attr_set = set(field.name for field in fields(cls)) |
||||
cls.subclass_unique_attr_dict = dict.fromkeys(cls.subclass_attr_set, cls) if cls.instantiable() else {} |
||||
bases = list(cls.__bases__) |
||||
while len(bases) > 0: |
||||
base = bases.pop(0) |
||||
if (hasattr(base, "subclass_attr_set") and isinstance(base.subclass_attr_set, set) |
||||
and hasattr(base, "subclass_unique_attr_dict") and isinstance(base.subclass_unique_attr_dict, |
||||
dict)): |
||||
if hasattr(base, "instantiable") and callable(base.instantiable) and base.instantiable(): |
||||
warn(f"Created subclass {cls} of instantiable class {base}") |
||||
bases.extend(base.__bases__) |
||||
deleted_classes = {cls} if cls.instantiable() else set() |
||||
for key in cls.subclass_attr_set.intersection(base.subclass_unique_attr_dict.keys()): |
||||
deleted_classes.update((base.subclass_unique_attr_dict.pop(key),)) |
||||
if cls.instantiable(): |
||||
unique_attrs = cls.subclass_attr_set.difference(base.subclass_attr_set) |
||||
base.subclass_unique_attr_dict.update(((key, cls) for key in unique_attrs)) |
||||
lost_classes = deleted_classes.difference(base.subclass_unique_attr_dict.values()) |
||||
if len(lost_classes) > 0: |
||||
lost_class_string = "/".join(friendly_type(subclass) for subclass in lost_classes) |
||||
warn(f'No unique attributes for {lost_class_string} in {friendly_type(base)}', |
||||
category=RuntimeWarning, stacklevel=5) |
||||
base.subclass_attr_set.update(cls.subclass_attr_set) |
||||
|
||||
@classmethod |
||||
def from_json_object_self_only(cls, value: Mapping[str, Any]): |
||||
result = {} |
||||
raised = [] |
||||
missing = set() |
||||
unused = set(value.keys()) |
||||
for field in fields(cls): |
||||
if field.name not in value: |
||||
if field.default is MISSING and field.default_factory is MISSING: |
||||
missing.add(field.name) |
||||
else: |
||||
unused.remove(field.name) |
||||
try: |
||||
result[field.name] = convert_json_to_object(value[field.name], field.type) |
||||
except Exception as ex: |
||||
ex.add_note( |
||||
f"when deserializing value {repr(value[field.name])} ({friendly_type(type(field.name))}) " |
||||
f"for field {field.name} ({friendly_type(field.type)}) " |
||||
f"of {friendly_type(cls)}") |
||||
raised.append(ex) |
||||
if len(unused) > 0: |
||||
raised.append(ValueError(f'Unused fields {", ".join(sorted(repr(n) for n in unused))}')) |
||||
if len(missing) > 0: |
||||
raised.append(ValueError(f'Missing fields {", ".join(sorted(repr(n) for n in missing))}')) |
||||
raise_exceptions(raised, for_type=cls, value=value) |
||||
return cls(**result) |
||||
|
||||
@classmethod |
||||
def from_json_object(cls, value: Mapping[str, Any]): |
||||
if cls.instantiable(): |
||||
return cls.from_json_object_self_only(value) |
||||
else: |
||||
for key, subclass in cls.subclass_unique_attr_dict.items(): |
||||
if key in value: |
||||
return subclass.from_json_object_self_only(value) |
||||
raise TypeError( |
||||
f"No obvious subclass of {friendly_type(cls)} " |
||||
f"(didn't find any of {', '.join(sorted(repr(key) for key in cls.subclass_unique_attr_dict.keys()))}) " |
||||
f"for {repr(value)}") |
||||
|
||||
def to_json_object(self) -> Mapping[str, Any]: |
||||
return dict(chain(((field.name, convert_object_to_json(getattr(self, field.name))) |
||||
for field in fields(self)), |
||||
((key, convert_object_to_json(value)) |
||||
for key, value in self.__dict__.items()) |
||||
if hasattr(self, "__dict__") else tuple())) |
||||
|
||||
|
||||
def raise_exceptions(raised: Sequence[Exception], for_type, value) -> None: |
||||
if len(raised) == 0: |
||||
return |
||||
elif len(raised) == 1: |
||||
raised = raised[0] |
||||
raised.add_note(f'for {friendly_type(for_type)}') |
||||
raise raised |
||||
else: |
||||
raise ExceptionGroup( |
||||
f'When deserializing {repr(value)} ({friendly_type(type(value))}) ' |
||||
f'to {friendly_type(for_type)}', raised) |
||||
|
||||
|
||||
class WrongTypeError(TypeError): |
||||
def __init__(self, actual, *, for_type, expected_type): |
||||
super().__init__(f'Wrong type {friendly_type(type(actual))} for value {repr(actual)} ' |
||||
f'when deserializing {friendly_type(for_type)} ' |
||||
f'- expected {friendly_type(expected_type)}') |
||||
|
||||
|
||||
def expected_json_type(value_type): |
||||
if get_origin(value_type) in [Union, UnionType]: |
||||
return reduce(lambda x, y: x | y, (expected_json_type(t) for t in get_args(value_type))) |
||||
elif value_type in (str, int, float, bool, type(None)): |
||||
return value_type |
||||
elif (is_heterogeneous_tuple_type(value_type) or is_homogeneous_tuple_type(value_type) |
||||
or get_origin(value_type) in (list, set, frozenset)): |
||||
return list |
||||
elif issubclass(value_type, Jsonable) or value_type is dict: |
||||
return dict |
||||
elif issubclass(value_type, Enum): |
||||
return get_enum_type(value_type) |
||||
else: |
||||
raise TypeError(f'Cannot deserialize objects of type {friendly_type(value_type)}') |
||||
|
||||
|
||||
def convert_json_to_object(value, value_type): |
||||
expect_type = expected_json_type(value_type) |
||||
if not isinstance(value, expect_type): |
||||
raise WrongTypeError(value, for_type=value_type, expected_type=expect_type) |
||||
if get_origin(value_type) in [Union, UnionType]: |
||||
raised = [] |
||||
for subtype in get_args(value_type): |
||||
if not isinstance(value, expected_json_type(subtype)): |
||||
continue |
||||
try: |
||||
return convert_json_to_object(value, subtype) |
||||
except Exception as ex: |
||||
raised.append(ex) |
||||
raise_exceptions(raised, for_type=value_type, value=value) |
||||
raise AssertionError( |
||||
f'Unexpectedly failed to throw or return when deserializing ' |
||||
f'value {repr(value)} to {friendly_type(value_type)} - expected {friendly_type(expect_type)} ' |
||||
f'and was {friendly_type(type(value))} but still no type matched') |
||||
elif value_type in (str, int, float, bool, type(None)): |
||||
return value |
||||
elif is_heterogeneous_tuple_type(value_type): |
||||
if not isinstance(value, list): |
||||
raise WrongTypeError(value, for_type=value_type, expected_type=list) |
||||
item_types = get_args(value_type) |
||||
if len(value) != len(item_types): |
||||
raise ValueError( |
||||
f'Wrong number of elements {len(value)} (should be {len(item_types)}) ' |
||||
f'for {repr(value)} ({friendly_type(type(value))}) ' |
||||
f'to deserialize it to {friendly_type(value_type)}') |
||||
result = [] |
||||
raised = [] |
||||
for index, pair in enumerate(zip(value, item_types)): |
||||
item, item_type = pair |
||||
try: |
||||
result.append(convert_json_to_object(item, item_type)) |
||||
except Exception as ex: |
||||
ex.add_note(f'when deserializing element #{index}' |
||||
f' - {repr(item)} ({friendly_type(type(item))}) - ' |
||||
f'to {friendly_type(item_type)}') |
||||
raised.append(ex) |
||||
raise_exceptions(raised, for_type=value_type, value=value) |
||||
return value_type(result) |
||||
elif is_homogeneous_tuple_type(value_type) or get_origin(value_type) in [set, frozenset, list]: |
||||
if not isinstance(value, list): |
||||
raise WrongTypeError(value, for_type=value_type, expected_type=list) |
||||
item_type = get_args(value_type)[0] |
||||
result = [] |
||||
raised = [] |
||||
for index, item in enumerate(value): |
||||
try: |
||||
result.append(convert_json_to_object(item, item_type)) |
||||
except Exception as ex: |
||||
ex.add_note(f'when deserializing element #{index}' |
||||
f' - {repr(item)} ({friendly_type(type(item))}) - ' |
||||
f'to {friendly_type(item_type)}') |
||||
raised.append(ex) |
||||
raise_exceptions(raised, for_type=value_type, value=value) |
||||
return value_type(result) |
||||
elif get_origin(value_type) is dict: |
||||
if not isinstance(value, dict): |
||||
raise WrongTypeError(value, for_type=value_type, expected_type=dict) |
||||
k_type, v_type = get_args(value_type) |
||||
result = {} |
||||
raised = [] |
||||
for k, v in value.items(): |
||||
success = True |
||||
converted_key, converted_value = None, None |
||||
try: |
||||
converted_key = convert_json_to_object(k, k_type) |
||||
except Exception as ex: |
||||
success = False |
||||
ex.add_note( |
||||
f'while deserializing key {repr(k)} ({friendly_type(type(k))}) ' |
||||
f'to {friendly_type(k_type)}') |
||||
raised.append(ex) |
||||
try: |
||||
converted_value = convert_json_to_object(v, v_type) |
||||
except Exception as ex: |
||||
success = False |
||||
ex.add_note(f'while deserializing value {repr(v)} ({friendly_type(type(v))}) ' |
||||
f'corresponding to key {repr(k)} ' |
||||
f'to {friendly_type(v_type)}') |
||||
raised.append(ex) |
||||
if success: |
||||
result[converted_key] = converted_value |
||||
raise_exceptions(raised, for_type=value_type, value=value) |
||||
return dict(result) |
||||
elif issubclass(value_type, Jsonable): |
||||
if not isinstance(value, dict): |
||||
raise WrongTypeError(value, for_type=value_type, expected_type=dict) |
||||
return value_type.from_json_object(value) |
||||
elif issubclass(value_type, Enum): |
||||
return value_type(value) |
||||
raise TypeError(f'Cannot deserialize objects of type {friendly_type(value_type)}') |
||||
|
||||
|
||||
def get_enum_type(enum_type): |
||||
if all(isinstance(v.value, str) for v in enum_type): |
||||
return str |
||||
elif all(isinstance(v.value, int) for v in enum_type): |
||||
return int |
||||
else: |
||||
raise TypeError(f"Enum type {friendly_type(enum_type)} is not all str or all int") |
||||
|
||||
|
||||
def convert_object_to_json(source): |
||||
if (isinstance(source, int) or isinstance(source, float) or isinstance(source, str) |
||||
or isinstance(source, bool) or isinstance(source, type(None))): |
||||
return source |
||||
if isinstance(source, list) or isinstance(source, tuple) or isinstance(source, set): |
||||
return [convert_object_to_json(item) for item in source] |
||||
if isinstance(source, dict): |
||||
return dict((str(key), convert_object_to_json(value)) for key, value in source.items()) |
||||
if isinstance(source, Jsonable): |
||||
return source.to_json_object() |
||||
if isinstance(source, Enum): |
||||
return convert_object_to_json(source.value) |
||||
raise TypeError(f"No adapter for {friendly_type(type(source))} to deserialize it to JSON") |
||||
|
||||
|
||||
def is_homogeneous_tuple_type(t) -> bool: |
||||
if get_origin(t) is not tuple: |
||||
return False |
||||
a = get_args(t) |
||||
if len(a) == 2 and a[1] is ...: |
||||
return True |
||||
return False |
||||
|
||||
|
||||
def is_heterogeneous_tuple_type(t) -> bool: |
||||
if get_origin(t) is not tuple: |
||||
return False |
||||
a = get_args(t) |
||||
if len(a) != 2 or a[1] is not ...: |
||||
return True |
||||
return False |
||||
|
||||
|
||||
def friendly_type(t) -> str: |
||||
if isinstance(t, type): |
||||
return t.__name__ |
||||
else: |
||||
return str(t) |
@ -0,0 +1,10 @@ |
||||
import asyncio |
||||
|
||||
from app import FabulaApp |
||||
|
||||
if __name__ == '__main__': |
||||
FabulaApp.new( |
||||
webhook_url="https://discord.com/api/webhooks/1084019766559260714/" + |
||||
"s6CPtH48h6_y5QIE0Cbq4MJMXoEH7K63jcMYUNTVu3msTneGNb09tCEt4tUV7WAC-ACZ").run() |
||||
|
||||
# See PyCharm help at https://www.jetbrains.com/help/pycharm/ |
@ -0,0 +1,70 @@ |
||||
from dataclasses import dataclass |
||||
|
||||
from enums import Affinity, Element, CharacterType, Visibility, CombatSide |
||||
|
||||
from jsonable import JsonableDataclassArgs, Jsonable, JsonableParentArgs |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class Target(Jsonable): |
||||
index: int |
||||
key: str |
||||
name: str |
||||
|
||||
|
||||
@dataclass(**JsonableParentArgs) |
||||
class Effect(Jsonable): |
||||
pass |
||||
|
||||
|
||||
@dataclass(**JsonableParentArgs) |
||||
class Action(Jsonable): |
||||
pass |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class Clock(Jsonable): |
||||
name: str |
||||
current: int |
||||
maximum: int |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class Character(Jsonable): |
||||
name: str |
||||
hp: int |
||||
max_hp: int |
||||
mp: int |
||||
max_mp: int |
||||
ip: int |
||||
max_ip: int |
||||
sp: int | None |
||||
df: int |
||||
mdf: int |
||||
dr: int |
||||
statuses: frozenset[str] |
||||
affinities: tuple[tuple[Element, Affinity], ...] |
||||
turns_left: int |
||||
max_turns: int |
||||
visibility: Visibility |
||||
character_type: CharacterType |
||||
side: CombatSide |
||||
|
||||
color: str | None |
||||
thumbnail: str | None |
||||
access_key: str |
||||
player_discord_id: str | None |
||||
player_name: str | None |
||||
|
||||
|
||||
@dataclass(**JsonableDataclassArgs) |
||||
class CombatStatus(Jsonable): |
||||
round_number: int |
||||
fp_spent: int |
||||
up_spent: int |
||||
clocks: tuple[Clock, ...] |
||||
characters: tuple[Character, ...] |
||||
log: tuple[Action, ...] |
||||
starting_side: CombatSide | None |
||||
active_side: CombatSide | None |
||||
active_combatant: int | None |
@ -0,0 +1,2 @@ |
||||
discord-webhook[async] |
||||
urwid |
@ -0,0 +1,312 @@ |
||||
from typing import Callable |
||||
|
||||
from urwid import Pile, Columns, ListBox, SimpleFocusListWalker, Text, WidgetWrap, SelectableIcon, AttrMap, Padding, \ |
||||
Divider, LineBox, WidgetPlaceholder, Overlay, Widget, Edit, IntEdit, \ |
||||
Button |
||||
|
||||
from model import Character, Clock |
||||
from enums import CharacterType, Counter |
||||
|
||||
|
||||
class SessionStateUI(WidgetWrap): |
||||
@classmethod |
||||
def render_round_timer(cls, rd: int, fp_spent: int, up_spent: int): |
||||
return ([('RoundLabel', 'Round'), ': ', ('RoundNumber', str(rd)), ' / '] if rd > 0 else []) + [ |
||||
('FabulaLabel', 'FP Used'), |
||||
': ', |
||||
('FabulaNumber', str(fp_spent)), |
||||
] + ([' / ', ('UltimaLabel', 'UP Used'), ': ', ('UltimaNumber', str(up_spent))] if up_spent > 0 else []) |
||||
|
||||
def __init__(self, rd: int, fp_spent: int, up_spent: int): |
||||
self.text = Text(self.render_round_timer(rd=rd, fp_spent=fp_spent, up_spent=up_spent)) |
||||
super().__init__(Columns([ |
||||
(4, Padding( |
||||
w=AttrMap( |
||||
SelectableIcon(text='<◕>', cursor_position=1), |
||||
'ClockUnfocused', 'ClockFocused'), |
||||
align='left', |
||||
width=3, |
||||
right=1)), |
||||
('weight', 1, self.text)])) |
||||
|
||||
def update_session(self, rd: int, fp_spent: int, up_spent: int): |
||||
self.text.set_text(self.render_round_timer(rd=rd, fp_spent=fp_spent, up_spent=up_spent)) |
||||
|
||||
|
||||
class ClockUI(WidgetWrap): |
||||
@classmethod |
||||
def render_clock_text(cls, clock: Clock): |
||||
return [ |
||||
('ClockName', clock.name), |
||||
' ', |
||||
('ClockBarEdge', '❰'), |
||||
('ClockBarFilled', '❚' * clock.current), |
||||
('ClockBarEmpty', '·' * (clock.maximum - clock.current)), |
||||
('ClockBarEdge', '❱'), |
||||
' ', |
||||
('ClockCurrent', str(clock.current)), |
||||
'/', |
||||
('ClockMax', str(clock.maximum)) |
||||
] |
||||
|
||||
def __init__(self, clock: Clock): |
||||
self.text = Text(self.render_clock_text(clock=clock)) |
||||
super().__init__(Columns([ |
||||
(4, Padding( |
||||
w=AttrMap( |
||||
SelectableIcon(text='<◕>', cursor_position=1), |
||||
'ClockUnfocused', 'ClockFocused'), |
||||
align='left', |
||||
width=3, |
||||
right=1)), |
||||
('weight', 1, self.text)])) |
||||
|
||||
def update(self, clock): |
||||
self.text.set_text( |
||||
self.render_clock_text(clock=clock)) |
||||
|
||||
|
||||
class ClocksUI(WidgetWrap): |
||||
def __init__(self): |
||||
self.state = SessionStateUI(rd=0, fp_spent=0, up_spent=0) |
||||
self.items = SimpleFocusListWalker([]) |
||||
self.list = ListBox(self.items) |
||||
super().__init__(Pile([('pack', self.state), ('weight', 1, self.list)])) |
||||
|
||||
def update_session(self, rd: int, fp_spent: int, up_spent: int): |
||||
self.state.update_session(rd=rd, fp_spent=fp_spent, up_spent=up_spent) |
||||
|
||||
def add_clock(self, clock: Clock, index: int = -1) -> int: |
||||
if index == -1: |
||||
index = len(self.items) |
||||
self.items.insert(index, ClockUI(clock=clock)) |
||||
return index |
||||
|
||||
def update_clock(self, index: int, clock: Clock): |
||||
self.items[index].update(clock) |
||||
|
||||
def reorder_clock(self, old: int, new: int = -1): |
||||
focused = self.list.focus_position == old |
||||
item = self.items.pop(old) |
||||
new = new if new != -1 else len(self.items) |
||||
self.items.insert(new, self.items.pop(item)) |
||||
if focused: |
||||
self.items.set_focus(position=new) |
||||
|
||||
def remove_clock(self, index: int): |
||||
self.items.pop(index + 1) |
||||
|
||||
def clear(self): |
||||
self.items.clear() |
||||
|
||||
|
||||
class CharacterUI(WidgetWrap): |
||||
@classmethod |
||||
def render_character_text(cls, character: Character): |
||||
if character.role == CharacterType.Player: |
||||
role_icon = '♥' |
||||
role_style = 'CharacterPlayer' |
||||
elif character.role == CharacterType.Ally: |
||||
role_icon = '♡' |
||||
role_style = 'CharacterAlly' |
||||
elif character.role == CharacterType.Enemy: |
||||
role_icon = '♤' |
||||
role_style = 'CharacterEnemy' |
||||
elif character.role == CharacterType.EnemyPlayer: |
||||
role_icon = '♠' |
||||
role_style = 'CharacterEnemyPlayer' |
||||
else: |
||||
role_icon = '*' |
||||
role_style = 'CharacterUnknown' |
||||
if character.max_turns == 0 or character.hp == 0: |
||||
turn_style = 'TurnDisabled' |
||||
turn_text = '[✗]' |
||||
elif character.turns_left > 1: |
||||
turn_style = 'TurnAvailable' |
||||
turn_text = f'[{character.turns_left}]' |
||||
elif character.turns_left == 0: |
||||
turn_style = 'TurnActed' |
||||
turn_text = '[✓]' |
||||
elif character.max_turns > 1: |
||||
turn_style = 'TurnAvailable' |
||||
turn_text = '[1]' |
||||
else: |
||||
turn_style = 'TurnAvailable' |
||||
turn_text = '[ ]' |
||||
if character.hp == character.max_hp: |
||||
hp_style = 'HPFull' |
||||
hp_suffix = '★' |
||||
elif character.hp * 4 > character.max_hp * 3: |
||||
hp_style = 'HPScratched' |
||||
hp_suffix = '☆' |
||||
elif character.hp * 2 > character.max_hp: |
||||
hp_style = 'HPWounded' |
||||
hp_suffix = '\u00A0' |
||||
elif character.hp * 4 > character.max_hp: |
||||
hp_style = 'HPCrisis' |
||||
hp_suffix = '!' |
||||
elif character.hp > 0: |
||||
hp_style = 'HPPeril' |
||||
hp_suffix = '‼' |
||||
else: |
||||
hp_style = 'HPDown' |
||||
hp_suffix = '✖' |
||||
return ([ |
||||
(turn_style, turn_text), |
||||
' ', |
||||
(role_style, role_icon + character.name), |
||||
], |
||||
[('HPLabel', hp_suffix + 'HP'), |
||||
'\u00A0', |
||||
(hp_style, str(character.hp).rjust(3, '\u00A0')), |
||||
'/', |
||||
('HPMax', str(character.max_hp).rjust(3, '\u00A0')), |
||||
'\u00A0 ', |
||||
('MPLabel', '\u00A0MP'), |
||||
'\u00A0', |
||||
('MPValue', str(character.mp).rjust(3, '\u00A0')), |
||||
'/', |
||||
('MPMax', str(character.max_mp).rjust(3, '\u00A0'))] + ( |
||||
['\u00A0 ', |
||||
('FPLabel' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else 'UPLabel', '\u00A0FP' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else '\u00A0UP'), |
||||
'\u00A0', |
||||
('FPValue' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else 'UPValue', str(character.sp).rjust(3, '\u00A0')), |
||||
'\u00A0' * 4] if character.sp is not None else []) + ( |
||||
['\u00A0 ', ('IPLabel', '\u00A0IP'), '\u00A0', ('IPValue', str(character.ip).rjust(3, '\u00A0')), '/', |
||||
('IPMax', str(character.max_ip).rjust(3, '\u00A0'))] if character.max_ip > 0 else []) + |
||||
(['\n ', |
||||
(('StatusKO', "KO") if character.hp == 0 else |
||||
('StatusCrisis', "Crisis") if character.hp * 2 < character.max_hp else ""), |
||||
('Statuses', |
||||
", ".join(([""] if character.hp * 2 < character.max_hp else []) + |
||||
sorted(list(str(s) for s in character.statuses)))), |
||||
] if len(character.statuses) > 0 or character.hp * 2 < character.max_hp else [])) |
||||
|
||||
def __init__(self, character: Character): |
||||
name_text, condition_text = self.render_character_text(character=character) |
||||
self.nameText = Text(name_text) |
||||
self.conditionText = Text(condition_text) |
||||
self.icon = SelectableIcon( |
||||
text=f'({character.access_key if character.access_key != "" else "*"})', cursor_position=1) |
||||
super().__init__(Columns([ |
||||
(4, Padding( |
||||
w=AttrMap( |
||||
self.icon, |
||||
'CharacterUnfocused', 'CharacterFocused'), |
||||
align='left', |
||||
width=3, |
||||
right=1)), |
||||
('weight', 1, Pile([self.nameText, self.conditionText]))])) |
||||
|
||||
|
||||
class CharactersUI(WidgetWrap): |
||||
def __init__(self): |
||||
self.items = SimpleFocusListWalker([]) |
||||
self.list = ListBox(self.items) |
||||
super().__init__(ListBox(self.items)) |
||||
|
||||
def add_character(self, character: Character): |
||||
self.items.append(CharacterUI(character=character)) |
||||
|
||||
|
||||
class LogMessageUI(WidgetWrap): |
||||
def __init__(self, text): |
||||
super().__init__(Columns([ |
||||
(4, Padding( |
||||
w=AttrMap( |
||||
SelectableIcon(text='[▶]', cursor_position=1), |
||||
'LogUnfocused', 'LogFocused'), |
||||
align='left', |
||||
width=3, |
||||
right=1)), |
||||
('weight', 1, Text([ |
||||
('LogBold', v) if index % 2 != 0 else ('LogText', v) |
||||
for index, v |
||||
in enumerate(text.split("**")) if v != ""]))])) |
||||
|
||||
|
||||
class LogUI(WidgetWrap): |
||||
def __init__(self): |
||||
self.items = SimpleFocusListWalker([]) |
||||
self.divider = Divider('=') |
||||
self.divider_by_zero = True |
||||
self.list = ListBox(self.items) |
||||
super().__init__(self.list) |
||||
|
||||
def add_message(self, text: str): |
||||
if self.divider not in self.items and not self.divider_by_zero: |
||||
if len(self.items) == 0: |
||||
self.divider_by_zero = True |
||||
else: |
||||
self.items.insert(0, self.divider) |
||||
self.divider_by_zero = False |
||||
self.items.insert(0, LogMessageUI(text)) |
||||
self.items.set_focus(0) |
||||
|
||||
def move_divider(self, location: int = -1): |
||||
if self.divider in self.items: |
||||
self.items.remove(self.divider) |
||||
location = location if location > -1 else len(self.items) |
||||
if 0 < location <= len(self.items): |
||||
self.items.insert(len(self.items) - location, self.divider) |
||||
self.divider_by_zero = False |
||||
else: |
||||
self.divider_by_zero = True |
||||
|
||||
def clear(self): |
||||
self.items.clear() |
||||
|
||||
|
||||
class MainUI(WidgetWrap): |
||||
def __init__(self, clocks: ClocksUI, characters: CharactersUI, log: LogUI): |
||||
super().__init__(Pile([ |
||||
('weight', 5, Columns([ |
||||
('weight', 5, LineBox(original_widget=characters, title="Character Status", |
||||
title_attr="FrameTitle", title_align='left')), |
||||
('weight', 3, LineBox(original_widget=clocks, title="Clocks", |
||||
title_attr="FrameTitle", title_align='left')), |
||||
])), |
||||
('weight', 3, LineBox(original_widget=log, title="Combat Log", |
||||
title_attr="FrameTitle", title_align='left')), |
||||
])) |
||||
|
||||
|
||||
class AbilityEntryPopup(WidgetWrap): |
||||
def __init__(self, user: Character, callback: Callable[[str, list[tuple[Counter]]], None]): |
||||
self.user = user |
||||
self.index = index |
||||
self.complete_callback = callback |
||||
self.ability_name_editor = Edit(f'{user.name} uses ') |
||||
self.mp_cost_editor = IntEdit(f'Mind Points cost (of {user.mp}) ', 0) |
||||
self.ip_cost_editor = IntEdit(f'Inventory Points cost (of {user.ip}) ', 0) |
||||
self.hp_cost_editor = IntEdit(f'Health Points cost (of {user.hp}) ', 0) |
||||
editors = [self.ability_name_editor, self.mp_cost_editor, self.ip_cost_editor, self.hp_cost_editor] |
||||
if user.sp is not None: |
||||
self.sp_cost_editor = IntEdit(f'{user.role.sp_name_abbr} cost (of {user.sp}) ', 0) |
||||
editors.append(self.sp_cost_editor) |
||||
else: |
||||
self.sp_cost_editor = None |
||||
ok_button = Button('Initiate') |
||||
cancel_button = Button('Abort') |
||||
contents = Pile([ |
||||
*editors, |
||||
Padding(Columns((ok_button, cancel_button)), 'right'), |
||||
]) |
||||
super().__init__(LineBox( |
||||
original_widget=contents, |
||||
title='Use Ability', |
||||
)) |
||||
pass |
||||
|
||||
|
||||
class FabulaUI(WidgetPlaceholder): |
||||
def __init__(self, main: MainUI): |
||||
self.main = main |
||||
super().__init__(main) |
||||
|
||||
def open_popup(self, popup: Widget): |
||||
self.original_widget = Overlay(popup, self.original_widget, 'center', ('relative', 50), 'middle', 'min_height') |
||||
|
||||
def close_popup(self): |
||||
if isinstance(self.original_widget, Overlay): |
||||
self.original_widget = self.original_widget.bottom_w |
Loading…
Reference in new issue