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