commit 7a1febe16ed6e7eb43f426caf3d2b05e37d7e868 Author: rr- Date: Thu Nov 1 21:43:03 2018 +0100 initial commit diff --git a/pqcli/__init__.py b/pqcli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pqcli/__main__.py b/pqcli/__main__.py new file mode 100644 index 0000000..7880696 --- /dev/null +++ b/pqcli/__main__.py @@ -0,0 +1,27 @@ +import pickle +from pathlib import Path + +import xdg + +from pqcli.game_state import Roster +from pqcli.ui import Ui + + +SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" + + +def main() -> None: + if SAVE_PATH.exists(): + roster = pickle.loads(SAVE_PATH.read_bytes()) + else: + roster = Roster() + + try: + Ui(roster).run() + finally: + SAVE_PATH.parent.mkdir(parents=True, exist_ok=True) + SAVE_PATH.write_bytes(pickle.dumps(roster)) + + +if __name__ == "__main__": + main() diff --git a/pqcli/config.py b/pqcli/config.py new file mode 100644 index 0000000..5ece9c2 --- /dev/null +++ b/pqcli/config.py @@ -0,0 +1,757 @@ +import enum +import typing as T +from dataclasses import dataclass + + +def level_up_time(level) -> int: + # seconds + return 20 * level * 60 + + +class Traits(enum.Enum): + name = "Name" + race = "Race" + class_ = "Class" + level = "Level" + + +class Stat(enum.Enum): + strength = "STR" + condition = "CON" + dexterity = "DEX" + intelligence = "INT" + wisdom = "WIS" + charisma = "CHA" + hp_max = "HP Max" + mp_max = "MP Max" + + +PRIME_STATS = [ + Stat.strength, + Stat.condition, + Stat.dexterity, + Stat.intelligence, + Stat.wisdom, + Stat.charisma, +] + +ALL_STATS = PRIME_STATS + [Stat.hp_max, Stat.mp_max] + + +EQUIPS = [ + "Weapon", + "Shield", + "Helm", + "Hauberk", + "Brassairts", + "Vambraces", + "Gauntlets", + "Gambeson", + "Cuisses", + "Greaves", + "Sollerets", +] + +SPELLS = [ + "Slime Finger", + "Rabbit Punch", + "Hastiness", + "Good Move", + "Sadness", + "Seasick", + "Shoelaces", + "Innoculate", + "Cone of Annoyance", + "Magnetic Orb", + "Invisible Hands", + "Revolting Cloud", + "Aqueous Humor", + "Spectral Miasma", + "Clever Fellow", + "Lockjaw", + "History Lesson", + "Hydrophobia", + "Big Sister", + "Cone of Paste", + "Mulligan", + "Nestor's Bright Idea", + "Holy Batpole", + "Tumor (Benign)", + "Braingate", + "Summon a Bitch", + "Nonplus", + "Animate Nightstand", + "Eye of the Troglodyte", + "Curse Name", + "Dropsy", + "Vitreous Humor", + "Roger's Grand Illusion", + "Covet", + "Black Idaho", + "Astral Miasma", + "Spectral Oyster", + "Acrid Hands", + "Angioplasty", + "Grognor's Big Day Off", + "Tumor (Malignant)", + "Animate Tunic", + "Ursine Armor", + "Holy Roller", + "Tonsilectomy", + "Curse Family", + "Infinite Confusion", +] + +OFFENSE_ATTRIB = [ + "Polished|+1", + "Serrated|+1", + "Heavy|+1", + "Pronged|+2", + "Steely|+2", + "Vicious|+3", + "Venomed|+4", + "Stabbity|+4", + "Dancing|+5", + "Invisible|+6", + "Vorpal|+7", +] + +DEFENSE_ATTRIB = [ + "Studded|+1", + "Banded|+2", + "Gilded|+2", + "Festooned|+3", + "Holy|+4", + "Cambric|+1", + "Fine|+4", + "Impressive|+5", + "Custom|+3", +] + +SHIELDS = [ + "Parasol|0", + "Pie Plate|1", + "Garbage Can Lid|2", + "Buckler|3", + "Plexiglass|4", + "Fender|4", + "Round Shield|5", + "Carapace|5", + "Scutum|6", + "Propugner|6", + "Kite Shield|7", + "Pavise|8", + "Tower Shield|9", + "Baroque Shield|11", + "Aegis|12", + "Magnetic Field|18", +] + +ARMORS = [ + "Lace|1", + "Macrame|2", + "Burlap|3", + "Canvas|4", + "Flannel|5", + "Chamois|6", + "Pleathers|7", + "Leathers|8", + "Bearskin|9", + "Ringmail|10", + "Scale Mail|12", + "Chainmail|14", + "Splint Mail|15", + "Platemail|16", + "ABS|17", + "Kevlar|18", + "Titanium|19", + "Mithril Mail|20", + "Diamond Mail|25", + "Plasma|30", +] + +WEAPONS = [ + "Stick|0", + "Broken Bottle|1", + "Shiv|1", + "Sprig|1", + "Oxgoad|1", + "Eelspear|2", + "Bowie Knife|2", + "Claw Hammer|2", + "Handpeen|2", + "Andiron|3", + "Hatchet|3", + "Tomahawk|3", + "Hackbarm|3", + "Crowbar|4", + "Mace|4", + "Battleadze|4", + "Leafmace|5", + "Shortsword|5", + "Longiron|5", + "Poachard|5", + "Baselard|5", + "Whinyard|6", + "Blunderbuss|6", + "Longsword|6", + "Crankbow|6", + "Blibo|7", + "Broadsword|7", + "Kreen|7", + "Warhammer|7", + "Morning Star|8", + "Pole-adze|8", + "Spontoon|8", + "Bastard Sword|9", + "Peen-arm|9", + "Culverin|10", + "Lance|10", + "Halberd|11", + "Poleax|12", + "Bandyclef|15", +] + +SPECIALS = [ + "Diadem", + "Festoon", + "Gemstone", + "Phial", + "Tiara", + "Scabbard", + "Arrow", + "Lens", + "Lamp", + "Hymnal", + "Fleece", + "Laurel", + "Brooch", + "Gimlet", + "Cobble", + "Albatross", + "Brazier", + "Bandolier", + "Tome", + "Garnet", + "Amethyst", + "Candelabra", + "Corset", + "Sphere", + "Sceptre", + "Ankh", + "Talisman", + "Orb", + "Gammel", + "Ornament", + "Brocade", + "Galoon", + "Bijou", + "Spangle", + "Gimcrack", + "Hood", + "Vulpeculum", +] + +ITEM_ATTRIB = [ + "Golden", + "Gilded", + "Spectral", + "Astral", + "Garlanded", + "Precious", + "Crafted", + "Dual", + "Filigreed", + "Cruciate", + "Arcane", + "Blessed", + "Reverential", + "Lucky", + "Enchanted", + "Gleaming", + "Grandiose", + "Sacred", + "Legendary", + "Mythic", + "Crystalline", + "Austere", + "Ostentatious", + "One True", + "Proverbial", + "Fearsome", + "Deadly", + "Benevolent", + "Unearthly", + "Magnificent", + "Iron", + "Ormolu", + "Puissant", +] + +ITEM_OFS = [ + "Foreboding", + "Foreshadowing", + "Nervousness", + "Happiness", + "Torpor", + "Danger", + "Craft", + "Silence", + "Invisibility", + "Rapidity", + "Pleasure", + "Practicality", + "Hurting", + "Joy", + "Petulance", + "Intrusion", + "Chaos", + "Suffering", + "Extroversion", + "Frenzy", + "Sisu", + "Solitude", + "Punctuality", + "Efficiency", + "Comfort", + "Patience", + "Internment", + "Incarceration", + "Misapprehension", + "Loyalty", + "Envy", + "Acrimony", + "Worry", + "Fear", + "Awe", + "Guile", + "Prurience", + "Fortune", + "Perspicacity", + "Domination", + "Submission", + "Fealty", + "Hunger", + "Despair", + "Cruelty", + "Grob", + "Dignard", + "Ra", + "the Bone", + "Diamonique", + "Electrum", + "Hydragyrum", +] + +BORING_ITEMS = [ + "nail", + "lunchpail", + "sock", + "I.O.U.", + "cookie", + "pint", + "toothpick", + "writ", + "newspaper", + "letter", + "plank", + "hat", + "egg", + "coin", + "needle", + "bucket", + "ladder", + "chicken", + "twig", + "dirtclod", + "counterpane", + "vest", + "teratoma", + "bunny", + "rock", + "pole", + "carrot", + "canoe", + "inkwell", + "hoe", + "bandage", + "trowel", + "towel", + "planter box", + "anvil", + "axle", + "tuppence", + "casket", + "nosegay", + "trinket", + "credenza", + "writ", +] + +MONSTERS = [ + "Anhkheg|6|chitin", + "Ant|0|antenna", + "Ape|4|ass", + "Baluchitherium|14|ear", + "Beholder|10|eyestalk", + "Black Pudding|10|saliva", + "Blink Dog|4|eyelid", + "Cub Scout|1|neckerchief", + "Girl Scout|2|cookie", + "Boy Scout|3|merit badge", + "Eagle Scout|4|merit badge", + "Bugbear|3|skin", + "Bugboar|3|tusk", + "Boogie|3|slime", + "Camel|2|hump", + "Carrion Crawler|3|egg", + "Catoblepas|6|neck", + "Centaur|4|rib", + "Centipede|0|leg", + "Cockatrice|5|wattle", + "Couatl|9|wing", + "Crayfish|0|antenna", + "Demogorgon|53|tentacle", + "Jubilex|17|gel", + "Manes|1|tooth", + "Orcus|27|wand", + "Succubus|6|bra", + "Vrock|8|neck", + "Hezrou|9|leg", + "Glabrezu|10|collar", + "Nalfeshnee|11|tusk", + "Marilith|7|arm", + "Balor|8|whip", + "Yeenoghu|25|flail", + "Asmodeus|52|leathers", + "Baalzebul|43|pants", + "Barbed Devil|8|flame", + "Bone Devil|9|hook", + "Dispater|30|matches", + "Erinyes|6|thong", + "Geryon|30|cornucopia", + "Malebranche|5|fork", + "Ice Devil|11|snow", + "Lemure|3|blob", + "Pit Fiend|13|seed", + "Ankylosaurus|9|tail", + "Brontosaurus|30|brain", + "Diplodocus|24|fin", + "Elasmosaurus|15|neck", + "Gorgosaurus|13|arm", + "Iguanadon|6|thumb", + "Megalosaurus|12|jaw", + "Monoclonius|8|horn", + "Pentasaurus|12|head", + "Stegosaurus|18|plate", + "Triceratops|16|horn", + "Tyranosauraus Rex|18|forearm", + "Djinn|7|lamp", + "Doppleganger|4|face", + "Black Dragon|7|*", + "Plaid Dragon|7|sporrin", + "Blue Dragon|9|*", + "Beige Dragon|9|*", + "Brass Dragon|7|pole", + "Tin Dragon|8|*", + "Bronze Dragon|9|medal", + "Chromatic Dragon|16|scale", + "Copper Dragon|8|loafer", + "Gold Dragon|8|filling", + "Green Dragon|8|*", + "Platinum Dragon|21|*", + "Red Dragon|10|cocktail", + "Silver Dragon|10|*", + "White Dragon|6|tooth", + "Dragon Turtle|13|shell", + "Dryad|2|acorn", + "Dwarf|1|drawers", + "Eel|2|sashimi", + "Efreet|10|cinder", + "Sand Elemental|8|glass", + "Bacon Elemental|10|bit", + "Porn Elemental|12|lube", + "Cheese Elemental|14|curd", + "Hair Elemental|16|follicle", + "Swamp Elf|1|lilypad", + "Brown Elf|1|tusk", + "Sea Elf|1|jerkin", + "Ettin|10|fur", + "Frog|0|leg", + "Violet Fungi|3|spore", + "Gargoyle|4|gravel", + "Gelatinous Cube|4|jam", + "Ghast|4|vomit", + "Ghost|10|*", + "Ghoul|2|muscle", + "Humidity Giant|12|drops", + "Beef Giant|11|steak", + "Quartz Giant|10|crystal", + "Porcelain Giant|9|fixture", + "Rice Giant|8|grain", + "Cloud Giant|12|condensation", + "Fire Giant|11|cigarettes", + "Frost Giant|10|snowman", + "Hill Giant|8|corpse", + "Stone Giant|9|hatchling", + "Storm Giant|15|barometer", + "Mini Giant|4|pompadour", + "Gnoll|2|collar", + "Gnome|1|hat", + "Goblin|1|ear", + "Grid Bug|1|carapace", + "Jellyrock|9|seedling", + "Beer Golem|15|foam", + "Oxygen Golem|17|platelet", + "Cardboard Golem|14|recycling", + "Rubber Golem|16|ball", + "Leather Golem|15|fob", + "Gorgon|8|testicle", + "Gray Ooze|3|gravy", + "Green Slime|2|sample", + "Griffon|7|nest", + "Banshee|7|larynx", + "Harpy|3|mascara", + "Hell Hound|5|tongue", + "Hippocampus|4|mane", + "Hippogriff|3|egg", + "Hobgoblin|1|patella", + "Homonculus|2|fluid", + "Hydra|8|gyrum", + "Imp|2|tail", + "Invisible Stalker|8|*", + "Iron Peasant|3|chaff", + "Jumpskin|3|shin", + "Kobold|1|penis", + "Leprechaun|1|wallet", + "Leucrotta|6|hoof", + "Lich|11|crown", + "Lizard Man|2|tail", + "Lurker|10|sac", + "Manticore|6|spike", + "Mastodon|12|tusk", + "Medusa|6|eye", + "Multicell|2|dendrite", + "Pirate|1|booty", + "Berserker|1|shirt", + "Caveman|2|club", + "Dervish|1|robe", + "Merman|1|trident", + "Mermaid|1|gills", + "Mimic|9|hinge", + "Mind Flayer|8|tentacle", + "Minotaur|6|map", + "Yellow Mold|1|spore", + "Morkoth|7|teeth", + "Mummy|6|gauze", + "Naga|9|rattle", + "Nebbish|1|belly", + "Neo-Otyugh|11|organ ", + "Nixie|1|webbing", + "Nymph|3|hanky", + "Ochre Jelly|6|nucleus", + "Octopus|2|beak", + "Ogre|4|talon", + "Ogre Mage|5|apparel", + "Orc|1|snout", + "Otyugh|7|organ", + "Owlbear|5|feather", + "Pegasus|4|aileron", + "Peryton|4|antler", + "Piercer|3|tip", + "Pixie|1|dust", + "Man-o-war|3|tentacle", + "Purple Worm|15|dung", + "Quasit|3|tail", + "Rakshasa|7|pajamas", + "Rat|0|tail", + "Remorhaz|11|protrusion", + "Roc|18|wing", + "Roper|11|twine", + "Rot Grub|1|eggsac", + "Rust Monster|5|shavings", + "Satyr|5|hoof", + "Sea Hag|3|wart", + "Silkie|3|fur", + "Shadow|3|silhouette", + "Shambling Mound|10|mulch", + "Shedu|9|hoof", + "Shrieker|3|stalk", + "Skeleton|1|clavicle", + "Spectre|7|vestige", + "Sphinx|10|paw", + "Spider|0|web", + "Sprite|1|can", + "Stirge|1|proboscis", + "Stun Bear|5|tooth", + "Stun Worm|2|trode", + "Su-monster|5|tail", + "Sylph|3|thigh", + "Titan|20|sandal", + "Trapper|12|shag", + "Treant|10|acorn", + "Triton|3|scale", + "Troglodyte|2|tail", + "Troll|6|hide", + "Umber Hulk|8|claw", + "Unicorn|4|blood", + "Vampire|8|pancreas", + "Wight|4|lung", + "Will-o'-the-Wisp|9|wisp", + "Wraith|5|finger", + "Wyvern|7|wing", + "Xorn|7|jaw", + "Yeti|4|fur", + "Zombie|2|forehead", + "Wasp|0|stinger", + "Rat|1|tail", + "Bunny|0|ear", + "Moth|0|dust", + "Beagle|0|collar", + "Midge|0|corpse", + "Ostrich|1|beak", + "Billy Goat|1|beard", + "Bat|1|wing", + "Koala|2|heart", + "Wolf|2|paw", + "Whippet|2|collar", + "Uruk|2|boot", + "Poroid|4|node", + "Moakum|8|frenum", + "Fly|0|*", + "Hogbird|3|curl", + "Wolog|4|lemma", +] + +MON_MODS = [ + "-4 fœtal *", + "-4 dying *", + "-3 crippled *", + "-3 baby *", + "-2 adolescent *", + "-2 very sick *", + "-1 lesser *", + "-1 undernourished *", + "+1 greater *", + "+1 * Elder", + "+2 war *", + "+2 Battle-*", + "+3 Were-*", + "+3 undead *", + "+4 giant *", + "+4 * Rex", +] + +OFFENSE_BAD = [ + "Dull|-2", + "Tarnished|-1", + "Rusty|-3", + "Padded|-5", + "Bent|-4", + "Mini|-4", + "Rubber|-6", + "Nerf|-7", + "Unbalanced|-2", +] + +DEFENSE_BAD = [ + "Holey|-1", + "Patched|-1", + "Threadbare|-2", + "Faded|-1", + "Rusty|-3", + "Motheaten|-3", + "Mildewed|-2", + "Torn|-3", + "Dented|-3", + "Cursed|-5", + "Plastic|-4", + "Cracked|-4", + "Warped|-3", + "Corroded|-3", +] + + +@dataclass +class Race: + name: str + attr: T.List[Stat] + + +RACES = [ + Race("Half Orc", [Stat.hp_max]), + Race("Half Man", [Stat.charisma]), + Race("Half Halfling", [Stat.dexterity]), + Race("Double Hobbit", [Stat.strength]), + Race("Hob-Hobbit", [Stat.dexterity, Stat.condition]), + Race("Low Elf", [Stat.condition]), + Race("Dung Elf", [Stat.wisdom]), + Race("Talking Pony", [Stat.mp_max, Stat.intelligence]), + Race("Gyrognome", [Stat.dexterity]), + Race("Lesser Dwarf", [Stat.condition]), + Race("Crested Dwarf", [Stat.charisma]), + Race("Eel Man", [Stat.dexterity]), + Race("Panda Man", [Stat.condition, Stat.strength]), + Race("Trans-Kobold", [Stat.wisdom]), + Race("Enchanted Motorcycle", [Stat.mp_max]), + Race("Will o' the Wisp", [Stat.wisdom]), + Race("Battle-Finch", [Stat.dexterity, Stat.intelligence]), + Race("Double Wookiee", [Stat.strength]), + Race("Skraeling", [Stat.wisdom]), + Race("Demicanadian", [Stat.condition]), + Race("Land Squid", [Stat.strength, Stat.hp_max]), +] + + +@dataclass +class Class: + name: str + attr: T.List[Stat] + + +CLASSES = [ + Class("Ur-Paladin", [Stat.wisdom, Stat.condition]), + Class("Voodoo Princess", [Stat.intelligence, Stat.charisma]), + Class("Robot Monk", [Stat.strength]), + Class("Mu-Fu Monk", [Stat.dexterity]), + Class("Mage Illusioner", [Stat.intelligence, Stat.mp_max]), + Class("Shiv-Knight", [Stat.dexterity]), + Class("Inner Mason", [Stat.condition]), + Class("Fighter/Organist", [Stat.charisma, Stat.strength]), + Class("Puma Burgular", [Stat.dexterity]), + Class("Runeloremaster", [Stat.wisdom]), + Class("Hunter Strangler", [Stat.dexterity, Stat.intelligence]), + Class("Battle-Felon", [Stat.strength]), + Class("Tickle-Mimic", [Stat.wisdom, Stat.intelligence]), + Class("Slow Poisoner", [Stat.condition]), + Class("Bastard Lunatic", [Stat.condition]), + Class("Lowling", [Stat.wisdom]), + Class("Birdrider", [Stat.wisdom]), + Class("Vermineer", [Stat.intelligence]), +] + +TITLES = [ + "Mr.", + "Mrs.", + "Sir", + "Sgt.", + "Ms.", + "Captain", + "Chief", + "Admiral", + "Saint", +] + +IMPRESSIVE_TITLES = [ + "King", + "Queen", + "Lord", + "Lady", + "Viceroy", + "Mayor", + "Prince", + "Princess", + "Chief", + "Boss", + "Archbishop", +] diff --git a/pqcli/game_state.py b/pqcli/game_state.py new file mode 100644 index 0000000..57bb98f --- /dev/null +++ b/pqcli/game_state.py @@ -0,0 +1,84 @@ +import datetime +import random +import typing as T +from dataclasses import dataclass + +from pqcli.config import Stat, PRIME_STATS, Race, Class, RACES, CLASSES + + +def generate_name() -> str: + parts = [ + "br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"), + "a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou".split("|"), + "b|ck|d|g|k|m|n|p|t|v|x|z".split("|"), + ] + result = "" + for i in range(6): + result += random.choice(parts[i % 3]) + return result.title() + + +@dataclass +class Stats: + def __init__(self): + self.values = {stat: 0 for stat in Stat} + + def __iter__(self) -> T.Iterable[T.Tuple[Stat, int]]: + return iter(self.values.items()) + + def __setitem__(self, stat: Stat, value: int) -> None: + self.values[stat] = value + + def __getitem__(self, stat: Stat) -> int: + return self.values[stat] + + +@dataclass +class Player: + name: str + stats: Stats + race: Race + class_: Class + birthday: datetime.datetime + + +class Roster: + def __init__(self) -> None: + self.players: T.List[Player] = [] + + +class StatsBuilder: + def __init__(self): + self.history: T.List[Stats] = [] + + def roll(self): + stats = Stats() + for stat in PRIME_STATS: + stats[stat] = ( + 3 + + random.randint(0, 5) + + random.randint(0, 5) + + random.randint(0, 5) + ) + stats[Stat.hp_max] = random.randint(0, 7) + stats[Stat.condition] // 6 + stats[Stat.mp_max] = ( + random.randint(0, 7) + stats[Stat.intelligence] // 6 + ) + self.history.append(stats) + return stats + + def unroll(self): + self.history.pop() + return self.history[-1] + + +def create_player(stats_builder: StatsBuilder) -> Player: + now = datetime.datetime.now() + random.seed(now) + return Player( + birthday=now, + name=generate_name(), + stats=stats_builder.roll(), + race=random.choice(RACES), + class_=random.choice(CLASSES), + ) diff --git a/pqcli/ui/__init__.py b/pqcli/ui/__init__.py new file mode 100644 index 0000000..4ddd4f0 --- /dev/null +++ b/pqcli/ui/__init__.py @@ -0,0 +1,74 @@ +import typing as T + +import urwid + +from pqcli.game_state import Roster +from pqcli.ui.exit_view import ExitView +from pqcli.ui.new_game_view import NewGameView +from pqcli.ui.roster_view import RosterView + + +urwid.command_map["k"] = "cursor up" +urwid.command_map["K"] = "cursor up" +urwid.command_map["j"] = "cursor down" +urwid.command_map["J"] = "cursor down" +urwid.command_map["h"] = "cursor left" +urwid.command_map["H"] = "cursor left" +urwid.command_map["l"] = "cursor right" +urwid.command_map["L"] = "cursor right" + + +class Ui: + def __init__(self, roster: Roster) -> None: + self.roster = roster + self.loop = urwid.MainLoop(None, unhandled_input=self.unhandled_input) + self.old_view: T.Optional[urwid.Widget] = None + + self.switch_to_roster_view() + + def run(self) -> None: + self.loop.run() + + def unhandled_input(self, key: str) -> bool: + callback = getattr(self.loop.widget, "unhandled_input", None) + if callback and callback(key): + return True + + if key == "ctrl q": + self.old_view = self.loop.widget + self.switch_to_exit_view() + return True + + return False + + def switch_to_roster_view(self) -> None: + self.loop.widget = RosterView( + self.roster, + self.loop, + on_exit=self.switch_to_exit_view, + on_new_game=self.switch_to_new_game_view, + on_resume_game=self.switch_to_game_view, + ) + + def switch_to_exit_view(self) -> None: + self.old_view = self.loop.widget + self.loop.widget = ExitView( + self.old_view, on_exit=self.exit, on_cancel=self.cancel_exit + ) + + def switch_to_new_game_view(self) -> None: + self.loop.widget = NewGameView( + on_confirm=self.switch_to_game_view, + on_cancel=self.switch_to_roster_view, + ) + + def switch_to_game_view(self, player_name: str) -> None: + ... + + def exit(self) -> None: + raise urwid.ExitMainLoop() + + def cancel_exit(self) -> None: + assert self.old_view is not None + self.loop.widget = self.old_view + self.old_view = None diff --git a/pqcli/ui/exit_view.py b/pqcli/ui/exit_view.py new file mode 100644 index 0000000..39f4ef2 --- /dev/null +++ b/pqcli/ui/exit_view.py @@ -0,0 +1,34 @@ +import typing as T + +import urwid + +from pqcli.game_state import Roster + + +class ExitView(urwid.Overlay): + def __init__( + self, parent: urwid.Widget, on_exit: T.Callable, on_cancel: T.Callable + ) -> None: + self.on_exit = on_exit + self.on_cancel = on_cancel + + question = urwid.Text(("bold", "Really quit?"), "center") + yes_btn = urwid.Button("Yes", lambda _user_data: self.on_exit()) + no_btn = urwid.Button("No", lambda _user_data: self.on_cancel()) + + line_box = urwid.LineBox( + urwid.ListBox( + urwid.SimpleFocusListWalker([question, no_btn, yes_btn]) + ) + ) + + super().__init__(line_box, parent, "center", 20, "middle", 5) + + def keypress(self, size: T.Any, key: str) -> bool: + if key in {"y", "Y"}: + self.on_exit() + return True + if key in {"n", "N"}: + self.on_cancel() + return True + return super().keypress(size, key) diff --git a/pqcli/ui/new_game_view.py b/pqcli/ui/new_game_view.py new file mode 100644 index 0000000..b493267 --- /dev/null +++ b/pqcli/ui/new_game_view.py @@ -0,0 +1,141 @@ +import random +import typing as T + +import urwid +import urwid_readline + +from pqcli.game_state import Player, StatsBuilder, generate_name, create_player +from pqcli.config import RACES, CLASSES, PRIME_STATS + + +class StatsGroupBox(urwid.Pile): + def __init__(self, player: Player, stats_builder: StatsBuilder) -> None: + self.player = player + self.stats_builder = stats_builder + self.stat_labels = {stat: urwid.Text("0") for stat in PRIME_STATS} + self.total_label = urwid.Text("0") + self.update_values() + + roll_button = urwid.Button("Roll", on_press=self.on_roll_press) + unroll_button = urwid.Button("Unroll", on_press=self.on_unroll_press) + + value_texts = list(self.stat_labels.values()) + [self.total_label] + label_texts = [ + urwid.Text(f"{stat.value}: ") for stat in PRIME_STATS + ] + [urwid.Text("Total: ")] + + super().__init__( + [ + urwid.Columns( + [ + (8, urwid.ListBox(label_texts)), + urwid.ListBox(value_texts), + ] + ), + urwid.Filler(roll_button), + urwid.Filler(unroll_button), + ] + ) + + def on_roll_press(self, _user_data: T.Any) -> None: + self.player.stats = self.stats_builder.roll() + self.update_values() + + def on_unroll_press(self, _user_data: T.Any) -> None: + try: + self.player.stats = self.stats_builder.unroll() + except IndexError: + return + self.update_values() + + def update_values(self) -> None: + for stat, edit in self.stat_labels.items(): + edit.set_text(str(self.player.stats[stat])) + self.total_label.set_text( + str( + sum( + self.player.stats[stat] for stat in self.stat_labels.keys() + ) + ) + ) + + +class NewGameView(urwid.Pile): + def __init__(self, on_confirm: T.Callable, on_cancel: T.Callable) -> None: + self.on_confirm = on_confirm + self.on_cancel = on_cancel + + stats_builder = StatsBuilder() + self.player = create_player(stats_builder) + + race_checkboxes = [] + for race in RACES: + urwid.RadioButton( + group=race_checkboxes, + label=race.name, + state=race.name == self.player.race.name, + ) + + class_checkboxes = [] + for class_ in CLASSES: + urwid.RadioButton( + group=class_checkboxes, + label=class_.name, + state=class_.name == self.player.class_.name, + ) + + self.char_name_edit = urwid_readline.ReadlineEdit( + "Name: ", self.player.name + ) + generate_char_name_btn = urwid.Button( + "?", on_press=self.on_generate_char_name_press + ) + + urwid.connect_signal( + self.char_name_edit, "postchange", self.on_char_name_change + ) + + super().__init__( + [ + ( + 1, + urwid.Columns( + [ + urwid.Filler(self.char_name_edit), + urwid.Filler(generate_char_name_btn), + ] + ), + ), + urwid.Columns( + [ + urwid.LineBox( + urwid.ListBox(race_checkboxes), title="Race" + ), + urwid.LineBox( + urwid.ListBox(class_checkboxes), title="Class" + ), + urwid.LineBox( + StatsGroupBox(self.player, stats_builder), + title="Stats", + ), + ] + ), + ] + ) + + def generate_random_char_name(self) -> None: + self.char_name_edit.edit_text = generate_name() + self.char_name_edit.edit_pos = len(self.char_name_edit.edit_text) + + def on_char_name_change(self, widget: urwid.Text, old_text: str) -> None: + self.player.name = widget.edit_text + + def on_generate_char_name_press(self, _user_data: T.Any) -> None: + self.generate_random_char_name() + + def unhandled_input(self, key: str) -> bool: + if key == "esc": + self.on_cancel() + return True + + return False diff --git a/pqcli/ui/roster_view.py b/pqcli/ui/roster_view.py new file mode 100644 index 0000000..60fb366 --- /dev/null +++ b/pqcli/ui/roster_view.py @@ -0,0 +1,65 @@ +import typing as T + +import urwid + +from pqcli.game_state import Roster + + +class RosterView(urwid.ListBox): + def __init__( + self, + roster: Roster, + loop: urwid.MainLoop, + on_new_game: T.Callable, + on_resume_game: T.Callable, + on_exit: T.Callable, + ) -> None: + self.roster = roster + self.on_new_game = on_new_game + self.on_resume_game = on_resume_game + self.on_exit = on_exit + + logo = urwid.BigText("ProgressQuest", urwid.HalfBlock5x4Font()) + + buttons = [] + for player in self.roster.players: + buttons.append( + urwid.Button( + label=player.name, + on_press=self.on_resume_game_press, + user_data=player.name, + ) + ) + + buttons.append( + urwid.Button( + label="Create a new character", on_press=self.on_new_game_press + ) + ) + + buttons.append(urwid.Button(label="Exit", on_press=self.on_exit_press)) + + super().__init__( + [ + urwid.Padding(logo, width="clip"), + urwid.Divider(), + urwid.Pile(buttons), + ] + ) + + def on_resume_game_press(self, user_data: T.Any) -> None: + player_name = T.cast(str, user_data) + self.on_resume_game(player_name) + + def on_new_game_press(self, _user_data: T.Any) -> None: + self.on_new_game() + + def on_exit_press(self, _user_data: T.Any) -> None: + self.on_exit() + + def unhandled_input(self, key: str) -> bool: + if key == "esc": + self.on_exit() + return True + + return False