93 Commits
Author SHA1 Message Date
rr- 1ad563ad93 misc: release 1.0.2 2020-08-17 09:12:56 +02:00
rr- 29207c1d4c misc: remove unused imports 2020-08-17 09:12:56 +02:00
CJ Kucera 67c3888f82 docs: add troubleshooting section 2020-06-22 21:57:43 +02:00
rr- d4675c747a misc: require windows-curses for Windows 2020-05-07 15:36:41 +02:00
Petr Gašparík c5f0c60ec8 roster: fix overwriting old saves on Windows 2020-05-07 15:27:56 +02:00
rr- fc4dcff9e3 ui/curses: fix division by zero 2020-05-07 15:04:58 +02:00
rr- f8a030a648 misc: release 1.0.1 2019-04-10 20:09:47 +02:00
rr- 82c8d21395 ui/curses: fix division by zero 2019-04-10 20:09:22 +02:00
rr- 746ca551f3 misc: release 1.0 2019-02-02 00:32:48 +01:00
rr- 5d47a0f44c mechanic: fix simulation stopping under certain conditions 2019-01-17 00:47:23 +01:00
rr- 555f386b24 ui/curses: highlight changed and added items 2019-01-14 16:10:26 +01:00
rr- 5f34a85c80 ui/curses: fix new quests and acts not appearing 2019-01-14 14:52:24 +01:00
rr- 97552a711a misc: don't use relative imports 2019-01-14 14:42:29 +01:00
rr- 561f28bd95 readme: add curses title screen 2019-01-13 22:25:47 +01:00
rr- 0bd4583c73 ui/curses: add swords to logo 2019-01-13 22:20:59 +01:00
rr- adc3c7914d ui/curses: use title case in menus 2019-01-13 21:44:26 +01:00
rr- d2c06b08bb ui/curses: cycle widgets on tab 2019-01-13 21:41:24 +01:00
rr- 4dc674816b ui/curses: add focus to character stats view 2019-01-13 21:41:24 +01:00
rr- 8c6b1a2504 ui/curses: add focus to character name view 2019-01-13 21:41:21 +01:00
rr- 8d385523ad ui/curses: split into files 2019-01-13 21:41:00 +01:00
rr- 05c9bcd0e6 ui/curses: improve margins 2019-01-13 20:53:18 +01:00
rr- 2c3da6484b ui/curses: align button hints to left 2019-01-13 20:50:05 +01:00
rr- d4f9326759 ui/curses: make escape work in game view 2019-01-13 20:48:33 +01:00
rr- e85a8d5826 ui/curses: improve character choice 2019-01-13 19:48:54 +01:00
rr- 2da46a44a4 ui/curses: implement cheats 2019-01-13 18:34:09 +01:00
rr- 0ecfc2c1fd ui/urwid: remove urwid ui
It was too similar and inferior to curses.
2019-01-13 18:26:12 +01:00
rr- ffe5734433 ui: default to curses user interface 2019-01-13 18:06:27 +01:00
rr- ae99f1f1a4 ui/urwid: respect --no-colors 2019-01-13 17:46:07 +01:00
rr- a47d6233be ui/urwid: colorize logo 2019-01-13 17:33:19 +01:00
rr- 1980049c6f ui/urwid: drop customizing color palettes 2019-01-13 17:33:11 +01:00
rr- 9fc078ad94 ui/curses: add colors 2019-01-13 17:33:11 +01:00
rr- 7a835c615e ui/urwid: split to files 2019-01-13 16:33:07 +01:00
rr- 9412a03da7 misc: add pyproject.toml 2019-01-04 20:49:33 +01:00
rr- 9012363437 misc: update readme, add new screenshots 2019-01-04 20:48:20 +01:00
rr- 021d7eb602 misc: improve mypy health 2019-01-04 20:13:52 +01:00
rr- 3d8034b285 ui/curses: cancel char creation one step at a time 2019-01-04 20:07:43 +01:00
rr- bd3f196f84 mechanic: fix stat unrolling 2019-01-04 19:49:50 +01:00
rr- 9a90b7b7b4 ui/curses: add character creation wizard 2019-01-04 19:49:50 +01:00
rr- e3ffe51979 ui/curses: let user scroll game view windows 2019-01-04 16:59:29 +01:00
rr- 0133802570 ui/curses: estimate time in progress bars 2019-01-04 16:40:30 +01:00
rr- b20c4b4b34 ui/curses: fix arrows not working in game view 2019-01-04 16:25:58 +01:00
rr- 5bdfc2ed25 ui/curses: fix scrollable visual glitches 2019-01-04 15:44:51 +01:00
rr- 8b38eeecac ui/curses: remove flickering 2019-01-04 00:56:55 +01:00
rr- ed9e513271 mechanic: emit bar signals if significant changes 2019-01-04 00:43:44 +01:00
rr- 97d958d2e7 pqcli: fix signals 2019-01-04 00:43:21 +01:00
rr- 9db61f7191 ui/curses: implement game view widget focusing 2019-01-04 00:22:29 +01:00
rr- 4230c57e73 ui/curses: remove redundant code 2019-01-04 00:22:29 +01:00
rr- 871144bf0c ui/curses: add scrollbars 2019-01-03 23:23:47 +01:00
rr- 3fdc2459ca sim: remove simulator script 2019-01-03 21:44:58 +01:00
rr- e008fc6048 ui: save periodically in every frontend 2019-01-03 21:44:23 +01:00
rr- ca7ab192c5 ui/curses: add curses interface draft 2019-01-03 21:38:19 +01:00
rr- 81f6dbede2 mechanic: remove urwid dependency 2019-01-03 21:32:55 +01:00
rr- 478075347c ui/basic: save periodically 2018-12-28 00:32:53 +01:00
rr- 595538cdca ui/basic: add basic user interface 2018-12-14 00:32:21 +01:00
rr- e27f3470a4 ui: rename Ui class to UrwidUserInterface 2018-12-14 00:32:21 +01:00
rr- 5545e2324a misc: release 0.3 2018-11-13 16:22:32 +01:00
rr- 000b11c26c ui: remove space before % 2018-11-13 16:20:40 +01:00
rr- b9a0934e74 ui: show 1d and 1h rather than 1.0d and 1.0h 2018-11-13 16:06:50 +01:00
rr- a823c9041e ui: show >24h as days 2018-11-13 16:06:20 +01:00
rr- 58758d35d5 docs: update screenshot 2018-11-10 17:24:42 +01:00
rr- e7d5947299 ui: improve predicted times near zero values 2018-11-10 17:24:42 +01:00
rr- 16ec0cfa5d ui: show time left in some progress bars 2018-11-10 17:24:42 +01:00
rr- ce7a422482 ui: add time_left property to progress bar 2018-11-10 17:24:42 +01:00
rr- 3cdafe4ec8 mechanic: remember player age 2018-11-10 17:24:42 +01:00
rr- 9bfbaf1646 ui: change proportions 2018-11-10 17:24:42 +01:00
rr- 4bb0e9250a ui: change paddings in game view 2018-11-09 23:37:40 +01:00
rr- a930b2b3db ui: remove horizontal wraparound from game view 2018-11-09 12:21:25 +01:00
rr- 2372dcdcaa ui: continue ticking on confirmation dialogs 2018-11-09 00:56:50 +01:00
rr- 8e25fbea04 ui: fix scrollbar rendering bugs 2018-11-09 00:56:38 +01:00
rr- 70ab6eab48 ui: show 2 decimal places in progress bars 2018-11-09 00:56:06 +01:00
rr- 37252d2727 ui: count only prime stats as best in roster view 2018-11-09 00:55:34 +01:00
rr- 284f549966 ui: implement tab navigation 2018-11-08 22:07:04 +01:00
rr- 904951acf4 ui: fix arrow navigation 2018-11-08 21:41:20 +01:00
rr- 3b19191120 ui: use urwid constants 2018-11-08 20:14:50 +01:00
rr- 72fe5439b0 ui: remove unused custom list box 2018-11-08 20:14:50 +01:00
rr- 88a936c293 ui: make plot development view scrollable 2018-11-08 20:01:12 +01:00
rr- 0b74d1a36e ui: make equipment scrollable 2018-11-08 19:52:41 +01:00
rr- 14149cd112 ui: make spell book scrollable 2018-11-08 19:15:59 +01:00
rr- bb233b82b8 ui: make character sheet scrollable 2018-11-08 19:15:59 +01:00
rr- 723c4aae88 ui: add padding to scrollbar 2018-11-08 19:15:59 +01:00
rr- 6bd12797f0 ui: create data table widget 2018-11-08 18:22:41 +01:00
rr- 066fc692e4 misc: add undocumented --no-save option 2018-11-08 13:09:37 +01:00
rr- 86f59d8fb2 ui: make inventory scrollable 2018-11-08 13:09:37 +01:00
rr- 78e39e639e ui: add --no-config 2018-11-08 13:09:37 +01:00
rr- 6c2fed9d40 ui: stylize scrollbar 2018-11-08 13:09:37 +01:00
rr- b5b9554f9b ui: make quest book scrollable 2018-11-08 11:35:54 +01:00
rr- 3afa965eeb ui: simplify quest book 2018-11-08 10:46:06 +01:00
rr- b00cf34e8f misc: reformat numbers 2018-11-08 10:07:51 +01:00
rr- c7c5abf7c8 ui: make palette configurable 2018-11-06 15:03:38 +01:00
rr- c8d8d646e8 misc: obscure cheats help 2018-11-06 14:52:51 +01:00
rr- 7692d2e531 misc: correct spelling 2018-11-06 00:25:52 +01:00
rr- 69cab66c9e misc: release 0.2 2018-11-06 00:22:24 +01:00
rr- eb07950527 mechanic: fix wrong progress bar max value 2018-11-06 00:21:02 +01:00
57 changed files with 2668 additions and 1354 deletions
+24 -6
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img alt="ProgressQuest" src="http://progressquest.com/pq.png"> <img alt="Progress Quest" src="http://progressquest.com/pq.png">
</p> </p>
Relive the great adventure… this time in the terminal realm! Relive the great adventure… this time in the terminal realm!
@@ -10,16 +10,23 @@ Relive the great adventure… this time in the terminal realm!
## Features ## Features
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Clean terminal interface
- Smooth progress bars using UTF-8 block characters
- Scrollable list boxes
- Faithful port of the game logic - Faithful port of the game logic
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Terminal interface that comes in 2 flavors:
- Rich and colorful (`--curses`, default)
- Minimal, suitable for raw grind (`--basic`)
- Ideal to run on your server - Ideal to run on your server
## How it looks like ## How it looks like
![20181106_001341_mjt](https://user-images.githubusercontent.com/1045476/48032665-d616f300-e158-11e8-8978-4ee3a69e44f8.png) Curses interface:
![Screenshot](screen-curses-logo.png)
![Screenshot](screen-curses.png)
Basic interface:
![Screenshot](screen-basic.png)
## How to install ## How to install
@@ -34,3 +41,14 @@ $ git clone https://github.com/rr-/pq-cli.git
$ cd pq-cli $ cd pq-cli
$ pip install --user . $ pip install --user .
``` ```
## Troubleshooting
### `_curses.error: init_pair() returned ERR`
If running on Linux and you get the error `_curses.error: init_pair() returned ERR`,
try making sure that your `$TERM` variable is set to a value which supports
256 colors, such as via the following:
TERM=xterm-256color pqcli
+37 -3
View File
@@ -4,14 +4,46 @@ from pathlib import Path
import xdg import xdg
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui import Ui from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface
SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat"
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="pqcli") parser = argparse.ArgumentParser(prog="pqcli")
parser.add_argument("--cheats", help="enable cheats", action="store_true")
group = parser.add_mutually_exclusive_group()
group.set_defaults(ui=CursesUserInterface)
group.add_argument(
"--basic",
dest="ui",
action="store_const",
const=BasicUserInterface,
help="Use basic user interface (very crude, but uses least CPU)",
)
group.add_argument(
"--curses",
dest="ui",
action="store_const",
const=CursesUserInterface,
help="Use curses user interface (fast, but no colors output)",
)
parser.add_argument(
"--no-colors",
dest="colors",
action="store_false",
help="Disable color highlighting in curses interface",
)
parser.add_argument(
"--no-save",
dest="use_saves",
action="store_false",
help=argparse.SUPPRESS,
)
parser.add_argument("--cheats", action="store_true", help="???")
return parser.parse_args() return parser.parse_args()
@@ -20,8 +52,10 @@ def main() -> None:
roster = Roster.load(SAVE_PATH) roster = Roster.load(SAVE_PATH)
try: try:
Ui(roster, args).run() ui = args.ui(roster, args)
ui.run()
finally: finally:
if args.use_saves:
roster.save() roster.save()
+27
View File
@@ -1,8 +1,30 @@
import datetime
import typing as T import typing as T
from pqcli import random from pqcli import random
def format_float(num: float) -> str:
ret = f"{num:.01f}"
if ret.endswith("0"):
ret = ret[:-2]
return ret
def format_timespan(timespan: datetime.timedelta) -> str:
num = timespan.total_seconds()
if num < 60.0:
return f"~{int(num)}s"
num /= 60
if num < 60.0:
return f"~{int(num)}m"
num /= 60
if num < 24.0:
return f"~{format_float(num)}h"
num /= 24
return f"~{format_float(num)}d"
def generate_name() -> str: def generate_name() -> str:
parts = [ parts = [
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"), "br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
@@ -122,3 +144,8 @@ def special(m: int, subject: str) -> str:
return prefix( return prefix(
["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, "" ["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, ""
) )
def terminate_message(player_name: str) -> str:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
return f"Terminate {adjective} {player_name}?"
+81 -55
View File
@@ -1,11 +1,11 @@
import datetime import datetime
import itertools import itertools
import logging import logging
import math
import typing as T import typing as T
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
import urwid
from pqcli import random from pqcli import random
from pqcli.config import * from pqcli.config import *
from pqcli.lingo import * from pqcli.lingo import *
@@ -13,23 +13,29 @@ from pqcli.lingo import *
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SIGNALS: T.Dict[
T.Tuple["SignalMixin", str], T.List[T.Callable[..., T.Any]]
] = defaultdict(list)
class SignalMixin: class SignalMixin:
def __init_subclass__(cls: T.Any, **kwargs: T.Any) -> None:
urwid.signals.register_signal(cls, cls.signals)
super().__init_subclass__(**kwargs)
def emit(self, signal_name: str, *user_data: T.Any) -> None: def emit(self, signal_name: str, *user_data: T.Any) -> None:
urwid.signals.emit_signal(self, signal_name, *user_data) for callback in _SIGNALS[self, signal_name]:
callback(*user_data)
def connect(self, signal_name: str, callback: T.Callable) -> None: def connect(
urwid.signals.connect_signal(self, signal_name, callback) self, signal_name: str, callback: T.Callable[..., T.Any]
) -> None:
_SIGNALS[self, signal_name].append(callback)
def __getstate__(self) -> T.Any: def disconnect(
return { self, signal_name: str, callback: T.Callable[..., T.Any]
key: value ) -> None:
for key, value in self.__dict__.items() try:
if "signal" not in key idx = _SIGNALS[self, signal_name].index(callback)
} except ValueError:
return
del _SIGNALS[self, signal_name][idx]
def level_up_time(level: int) -> int: def level_up_time(level: int) -> int:
@@ -46,6 +52,9 @@ class Bar(SignalMixin):
self.emit("change") self.emit("change")
def reset(self, new_max: int, position: float = 0.0) -> None: def reset(self, new_max: int, position: float = 0.0) -> None:
if not math.isclose(new_max, self._max) or not math.isclose(
position, self._position
):
self._max = new_max self._max = new_max
self._position = position self._position = position
self.emit("change") self.emit("change")
@@ -66,7 +75,10 @@ class Bar(SignalMixin):
return self._position >= self.max_ return self._position >= self.max_
def reposition(self, new_pos: float) -> None: def reposition(self, new_pos: float) -> None:
self._position = float(min(new_pos, self._max)) old_pos = self._position
new_pos = float(min(new_pos, self._max))
self._position = new_pos
if not math.isclose(new_pos, old_pos):
self.emit("change") self.emit("change")
@@ -86,6 +98,12 @@ class Stats(SignalMixin):
def best(self) -> StatType: def best(self) -> StatType:
return max(self, key=lambda kv: kv[1])[0] return max(self, key=lambda kv: kv[1])[0]
@property
def best_prime(self) -> StatType:
return max(
(kv for kv in self if kv[0] in PRIME_STATS), key=lambda kv: kv[1]
)[0]
def increment(self, stat: StatType, qty: int = 1) -> None: def increment(self, stat: StatType, qty: int = 1) -> None:
self._values[stat] += qty self._values[stat] += qty
logger.info("Increased %s to %d", stat.value, self[stat]) logger.info("Increased %s to %d", stat.value, self[stat])
@@ -93,7 +111,7 @@ class Stats(SignalMixin):
class QuestBook(SignalMixin): class QuestBook(SignalMixin):
signals = ["complete_act", "complete_quest"] signals = ["start_act", "start_quest"]
def __init__(self) -> None: def __init__(self) -> None:
self._quests: T.List[str] = [] self._quests: T.List[str] = []
@@ -118,7 +136,7 @@ class QuestBook(SignalMixin):
def increment_act(self) -> None: def increment_act(self) -> None:
self._act += 1 self._act += 1
self.emit("complete_act") self.emit("start_act", self._act)
def add_quest(self, name: str) -> None: def add_quest(self, name: str) -> None:
logger.info("Commencing quest: %s", name) logger.info("Commencing quest: %s", name)
@@ -164,7 +182,7 @@ class Inventory(SignalMixin):
def pop(self, index: int) -> None: def pop(self, index: int) -> None:
item = self._items[index] item = self._items[index]
logger.info("Lost %s (qty=%d)", item.name, item.quantity) logger.info("Lost %s", indefinite(item.name, item.quantity))
self._items.pop(index) self._items.pop(index)
self.sync_encumbrance() self.sync_encumbrance()
self.emit("item_del", item) self.emit("item_del", item)
@@ -231,6 +249,12 @@ class SpellBook(SignalMixin):
def __iter__(self) -> T.Iterator[Spell]: def __iter__(self) -> T.Iterator[Spell]:
return iter(self._spells) return iter(self._spells)
def __bool__(self) -> bool:
return bool(self._spells)
def __len__(self) -> int:
return len(self._spells)
def add(self, spell_name: str, level: int) -> None: def add(self, spell_name: str, level: int) -> None:
for spell in self._spells: for spell in self._spells:
if spell.name == spell_name: if spell.name == spell_name:
@@ -302,6 +326,7 @@ class Player(SignalMixin):
self.race: Race = race self.race: Race = race
self.class_: Class = class_ self.class_: Class = class_
self.stats: Stats = stats self.stats: Stats = stats
self.elapsed = 0.0
self.exp_bar = Bar(max_=level_up_time(1)) self.exp_bar = Bar(max_=level_up_time(1))
self.level = 1 self.level = 1
@@ -311,10 +336,14 @@ class Player(SignalMixin):
self.equipment = Equipment() self.equipment = Equipment()
self.spell_book = SpellBook() self.spell_book = SpellBook()
self.task_bar = Bar(max_=0) self.task_bar = Bar(max_=1)
self.task: T.Optional[BaseTask] = None self.task: T.Optional[BaseTask] = None
self.queue: T.List[BaseTask] = [] self.queue: T.List[BaseTask] = []
def __setstate__(self, obj: T.Any) -> None:
self.__dict__.update(obj)
self.elapsed = obj.get("elapsed", 0.0)
def set_task(self, task: BaseTask) -> None: def set_task(self, task: BaseTask) -> None:
self.task = task self.task = task
self.task_bar.reset(task.duration) self.task_bar.reset(task.duration)
@@ -423,38 +452,38 @@ class Simulation:
def __init__(self, player: Player) -> None: def __init__(self, player: Player) -> None:
self.player = player self.player = player
self.last_tick = datetime.datetime.now() self.last_tick = datetime.datetime.now()
self.elapsed = 0.0
def tick(self, elapsed: float = 100.0) -> None: def tick(self, elapsed: float = 100.0) -> None:
self.player.elapsed += elapsed
if self.player.task is None: if self.player.task is None:
self.player.set_task(RegularTask("Loading", 2_000)) self.player.set_task(RegularTask("Loading", 2000))
self.player.queue += [ self.player.queue += [
RegularTask( RegularTask(
"Experiencing an enigmatic and foreboding night vision", "Experiencing an enigmatic and foreboding night vision",
10_000, 10000,
), ),
RegularTask( RegularTask(
"Much is revealed about that wise old bastard " "Much is revealed about that wise old bastard "
"you'd underestimated", "you'd underestimated",
6_000, 6000,
), ),
RegularTask( RegularTask(
"A shocking series of events leaves you " "A shocking series of events leaves you "
"alone and bewildered, but resolute", "alone and bewildered, but resolute",
6_000, 6000,
), ),
RegularTask( RegularTask(
"Drawing upon an unrealized reserve of determination, " "Drawing upon an unrealized reserve of determination, "
"you set out on a long and dangerous journey", "you set out on a long and dangerous journey",
4_000, 4000,
), ),
PlotTask(f"Loading {act_name(1)}", 2_000), PlotTask(f"Loading {act_name(1)}", 2000),
] ]
self.player.quest_book.plot_bar.reset(28) self.player.quest_book.plot_bar.reset(28)
return return
if not self.player.task_bar.done: if not self.player.task_bar.done:
self.elapsed += elapsed
self.player.task_bar.increment(elapsed) self.player.task_bar.increment(elapsed)
return return
@@ -528,7 +557,7 @@ class Simulation:
self.player.set_task( self.player.set_task(
SellTask( SellTask(
"Selling " + indefinite(item.name, item.quantity), "Selling " + indefinite(item.name, item.quantity),
1_000, 1000,
) )
) )
break break
@@ -541,21 +570,19 @@ class Simulation:
self.player.set_task(self.player.queue.pop(0)) self.player.set_task(self.player.queue.pop(0))
elif self.player.inventory.encum_bar.done: elif self.player.inventory.encum_bar.done:
self.player.set_task( self.player.set_task(
HeadingToMarketTask( HeadingToMarketTask("Heading to market to sell loot", 4000)
"Heading to market to sell loot", 4_000
)
) )
elif not isinstance(old, (KillTask, HeadingToKillingFieldsTask)): elif not isinstance(old, (KillTask, HeadingToKillingFieldsTask)):
if self.player.inventory.gold > self.player.equip_price(): if self.player.inventory.gold > self.player.equip_price():
self.player.set_task( self.player.set_task(
BuyTask( BuyTask(
"Negotiating purchase of better equipment", 5_000 "Negotiating purchase of better equipment", 5000
) )
) )
else: else:
self.player.set_task( self.player.set_task(
HeadingToKillingFieldsTask( HeadingToKillingFieldsTask(
"Heading to the killing fields", 4_000 "Heading to the killing fields", 4000
) )
) )
else: else:
@@ -612,7 +639,7 @@ class Simulation:
raise AssertionError raise AssertionError
self.player.quest_book.add_quest(caption) self.player.quest_book.add_quest(caption)
self.player.quest_book.emit("complete_quest") self.player.quest_book.emit("start_quest", caption)
def interplot_cinematic(self) -> None: def interplot_cinematic(self) -> None:
def enqueue(task: BaseTask) -> None: def enqueue(task: BaseTask) -> None:
@@ -625,19 +652,19 @@ class Simulation:
RegularTask( RegularTask(
"Exhausted, you arrive at a friendly oasis " "Exhausted, you arrive at a friendly oasis "
"in a hostile land", "in a hostile land",
1_000, 1000,
) )
) )
enqueue( enqueue(
RegularTask("You greet old friends and meet new allies", 2_000) RegularTask("You greet old friends and meet new allies", 2000)
) )
enqueue( enqueue(
RegularTask( RegularTask(
"You are privy to a council of powerful do-gooders", 2_000 "You are privy to a council of powerful do-gooders", 2000
) )
) )
enqueue( enqueue(
RegularTask("There is much to be done. You are chosen!", 1_000) RegularTask("There is much to be done. You are chosen!", 1000)
) )
elif choice == 1: elif choice == 1:
@@ -645,7 +672,7 @@ class Simulation:
RegularTask( RegularTask(
"Your quarry is in sight, " "Your quarry is in sight, "
"but a mighty enemy bars your path!", "but a mighty enemy bars your path!",
1_000, 1000,
) )
) )
@@ -653,7 +680,7 @@ class Simulation:
enqueue( enqueue(
RegularTask( RegularTask(
f"A desperate struggle commences with {nemesis}", 4_000 f"A desperate struggle commences with {nemesis}", 4000
) )
) )
@@ -665,20 +692,20 @@ class Simulation:
if s % 3 == 0: if s % 3 == 0:
enqueue( enqueue(
RegularTask( RegularTask(
f"Locked in grim combat with {nemesis}", 2_000 f"Locked in grim combat with {nemesis}", 2000
) )
) )
elif s % 3 == 1: elif s % 3 == 1:
enqueue( enqueue(
RegularTask( RegularTask(
f"{nemesis} seems to have the upper hand", 2_000 f"{nemesis} seems to have the upper hand", 2000
) )
) )
elif s % 3 == 2: elif s % 3 == 2:
enqueue( enqueue(
RegularTask( RegularTask(
f"You seem to gain the advantage over {nemesis}", f"You seem to gain the advantage over {nemesis}",
2_000, 2000,
) )
) )
else: else:
@@ -688,12 +715,12 @@ class Simulation:
RegularTask( RegularTask(
f"Victory! {nemesis} is slain! " f"Victory! {nemesis} is slain! "
"Exhausted, you lose conciousness", "Exhausted, you lose conciousness",
3_000, 3000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
"You awake in a friendly place, but the road awaits", 2_000 "You awake in a friendly place, but the road awaits", 2000
) )
) )
@@ -703,37 +730,35 @@ class Simulation:
RegularTask( RegularTask(
"Oh sweet relief! " "Oh sweet relief! "
f"You've reached the protection of the good {nemesis}", f"You've reached the protection of the good {nemesis}",
2_000, 2000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
"There is rejoicing, " "There is rejoicing, "
f"and an unnerving encouter with {nemesis} in private", f"and an unnerving encouter with {nemesis} in private",
3_000, 3000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
f"You forget your {boring_item()} and go back to get it", f"You forget your {boring_item()} and go back to get it",
2_000, 2000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
"What's this!? You overhear something shocking!", 2_000 "What's this!? You overhear something shocking!", 2000
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(f"Could {nemesis} be a dirty double-dealer?", 2000)
f"Could {nemesis} be a dirty double-dealer?", 2_000
)
) )
enqueue( enqueue(
RegularTask( RegularTask(
"Who can possibly be trusted with this news!? ... " "Who can possibly be trusted with this news!? ... "
"Oh yes, of course", "Oh yes, of course",
3_000, 3000,
) )
) )
@@ -742,7 +767,7 @@ class Simulation:
enqueue( enqueue(
PlotTask( PlotTask(
f"Loading {act_name(self.player.quest_book.act + 1)}", 1_000 f"Loading {act_name(self.player.quest_book.act + 1)}", 1000
) )
) )
@@ -892,6 +917,7 @@ class StatsBuilder:
return stats return stats
def unroll(self) -> Stats: def unroll(self) -> Stats:
if len(self.history) > 1:
self.history.pop() self.history.pop()
return self.history[-1] return self.history[-1]
+11
View File
@@ -1,5 +1,7 @@
import logging
import pickle import pickle
import typing as T import typing as T
from datetime import datetime
from pathlib import Path from pathlib import Path
from pqcli.mechanic import Player from pqcli.mechanic import Player
@@ -9,6 +11,7 @@ class Roster:
def __init__(self, path: Path, players: T.List[Player]) -> None: def __init__(self, path: Path, players: T.List[Player]) -> None:
self.path = path self.path = path
self.players = players self.players = players
self._last_save = datetime.now()
@staticmethod @staticmethod
def load(path: T.Union[str, Path]) -> "Roster": def load(path: T.Union[str, Path]) -> "Roster":
@@ -20,10 +23,18 @@ class Roster:
return Roster(real_path, players=[]) return Roster(real_path, players=[])
def save(self) -> None: def save(self) -> None:
self._last_save = datetime.now()
old_path = self.path.with_name(self.path.name + ".old") old_path = self.path.with_name(self.path.name + ".old")
tmp_path = self.path.with_name(self.path.name + ".new") tmp_path = self.path.with_name(self.path.name + ".new")
tmp_path.parent.mkdir(parents=True, exist_ok=True) tmp_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_bytes(pickle.dumps(self.players)) tmp_path.write_bytes(pickle.dumps(self.players))
if self.path.exists(): if self.path.exists():
if old_path.exists():
old_path.unlink()
self.path.rename(old_path) self.path.rename(old_path)
tmp_path.rename(self.path) tmp_path.rename(self.path)
def save_periodically(self) -> None:
if (datetime.now() - self._last_save).total_seconds() >= 300:
logging.info("Saving...")
self.save()
-115
View File
@@ -1,115 +0,0 @@
import argparse
import datetime
import logging
import sys
from pathlib import Path
from signal import SIG_DFL, SIGPIPE, signal
from pqcli import random
from pqcli.config import CLASSES, RACES
from pqcli.mechanic import (
Player,
Simulation,
StatsBuilder,
act_name,
create_player,
generate_name,
)
from pqcli.roster import Roster
signal(SIGPIPE, SIG_DFL)
logging.basicConfig(
stream=sys.stdout, level=logging.DEBUG, format="%(message)s"
)
# make things predictable
SEED_A = 1664525
SEED_B = 2141
SEED_C = 1013904223
def custom_random(num: int) -> int:
global SEED_A, SEED_B, SEED_C
SEED_B = (SEED_A * SEED_B + SEED_C) % 0x100000000
return SEED_B % num
random.below = custom_random
def character_sheet(player: Player) -> None:
print(f"{player.name} the {player.race.name}")
print(f"Level {player.level} {player.class_.name}")
print(f"Plot stage: {act_name(player.quest_book.act)}")
print(f"Quest: {player.quest_book.current_quest}")
print()
print("Stats:")
for stat, value in player.stats:
print(f" {stat.value}: {value}")
print()
print(f"Equipment (best: {player.equipment.best}):")
for equipment, item_name in player.equipment:
print(f" {equipment.value}: {item_name}")
print()
print("Spell Book:")
for spell in player.spell_book:
print(f" {spell.name}: {spell.level}")
print()
print("Inventory:")
print(f" Gold: {player.inventory.gold}")
for item in player.inventory:
print(f" {item.name}: {item.quantity}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-r", "--roster", type=Path)
parser.add_argument("-l", "--max-level", type=int, default=5)
return parser.parse_args()
def random_player() -> Player:
return create_player(
stats=StatsBuilder().roll(),
name=generate_name(),
race=random.choice(RACES),
class_=random.choice(CLASSES),
)
def main() -> None:
args = parse_args()
if args.roster:
roster = Roster.load(args.roster)
if not roster.players:
roster.players.append(random_player())
roster.save()
player = roster.players[0]
else:
roster = None
player = random_player()
start = datetime.datetime.now()
elapsed = 0
last_level = 0
simulation = Simulation(player)
try:
while True:
if player.level != last_level:
last_level = player.level
character_sheet(player)
if player.level >= args.max_level:
break
simulation.tick()
elapsed += 100
finally:
print("real time: ", (datetime.datetime.now() - start))
print("virtual time: ", datetime.timedelta(milliseconds=elapsed))
if roster:
roster.save()
if __name__ == "__main__":
main()
-142
View File
@@ -1,142 +0,0 @@
import argparse
import typing as T
import urwid
from pqcli import random
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.confirm_dialog import ConfirmDialog
from pqcli.ui.game_view import GameView
from pqcli.ui.new_game_view import NewGameView
from pqcli.ui.roster_view import RosterView
TICK_FREQ = 0.1
SAVE_FREQ = 60.0
PALETTE: T.List[T.Tuple[str, ...]] = [
("button", "", ""),
("button-focus", "light red", "black"),
("linebox", "", ""),
("linebox-focus", "light red", "black"),
("linebox-content", "", ""),
("linebox-content-focus", "", ""),
("progressbar-normal", "", "", "standout"),
("progressbar-done", "black", "light red"),
("progressbar-smooth", "light red", ""),
]
class ConfirmExitDialog(ConfirmDialog):
def __init__(self, old_view: urwid.Widget) -> None:
super().__init__("Really quit?", old_view)
def bind_commands() -> None:
for key, direction in {
"k": "up",
"j": "down",
"h": "left",
"l": "right",
}.items():
for key_variant in {key, key.upper()}:
urwid.command_map[key_variant] = f"cursor {direction}"
urwid.command_map["tab"] = f"cursor down"
urwid.command_map["shift tab"] = f"cursor up"
class Ui:
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
bind_commands()
self.roster = roster
self.args = args
self.loop = urwid.MainLoop(
None, PALETTE, unhandled_input=self.unhandled_input
)
self.old_views: T.List[urwid.Widget] = []
self.switch_to_roster_view()
def run(self) -> None:
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
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.switch_to_exit_view()
return True
return False
def switch_to_roster_view(self) -> None:
self.loop.widget = RosterView(self.roster)
self._connect("load_game", self.switch_to_game_view)
self._connect("new_game", self.switch_to_new_game_view)
self._connect("delete_game", self.switch_to_delete_player_view)
self._connect("exit", self.switch_to_exit_view)
def switch_to_exit_view(self) -> None:
if isinstance(self.loop.widget, ConfirmExitDialog):
return
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmExitDialog(self.old_views[-1])
self._connect("confirm", self.exit)
self._connect("cancel", self.cancel_dialog)
def switch_to_new_game_view(self) -> None:
self.loop.widget = NewGameView()
self._connect("confirm", self.create_player)
self._connect("cancel", self.switch_to_roster_view)
def create_player(self, player: Player) -> None:
self.roster.players.append(player)
self.switch_to_roster_view()
def delete_player(self, player_idx: int) -> None:
del self.roster.players[player_idx]
self.switch_to_roster_view()
def switch_to_game_view(self, player_idx: int) -> None:
player = self.roster.players[player_idx]
self.loop.widget = GameView(self.loop, player, self.args)
self._connect("cancel", self.switch_to_roster_view)
def switch_to_delete_player_view(self, player_idx: int) -> None:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
player_name = self.roster.players[player_idx].name
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmDialog(
f"Terminate {adjective} {player_name}?", self.old_views[-1]
)
self._connect("confirm", lambda: self.delete_player(player_idx))
self._connect("cancel", self.cancel_dialog)
def exit(self) -> None:
raise urwid.ExitMainLoop()
def cancel_dialog(self) -> None:
assert len(self.old_views)
self.loop.widget = self.old_views.pop()
def _connect(self, signal_name: str, callback: T.Callable) -> None:
urwid.signals.connect_signal(
self.loop.widget,
signal_name,
lambda _sender, *args: callback(*args),
)
def on_tick(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
if hasattr(self.loop.widget, "tick"):
self.loop.widget.tick()
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
def on_save(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
self.roster.save()
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
+14
View File
@@ -0,0 +1,14 @@
import abc
import argparse
from pqcli.roster import Roster
class BaseUserInterface(abc.ABC):
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
self.roster = roster
self.args = args
@abc.abstractmethod
def run(self) -> None:
raise NotImplementedError("not implemented")
+222
View File
@@ -0,0 +1,222 @@
import argparse
import enum
import logging
import signal
import sys
import time
import typing as T
from datetime import datetime, timedelta
from pqcli import lingo
from pqcli.config import CLASSES, PRIME_STATS, RACES
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
LOGO = """
____ ___ _
| _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_
| |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __|
| __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_
|_| |_| \___/ \__, |_| \___||___/___/\__\_\\\\__,_|\___||___/\__|
|___/
"""
class MainMenu(enum.IntEnum):
create = 1
play = 2
info = 3
delete = 4
quit = 5
class BasicUserInterface(BaseUserInterface):
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
super().__init__(roster, args)
def signal_handler(sig: T.Any, frame: T.Any) -> None:
logging.info("Quitting")
if self.args.use_saves:
self.roster.save()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
def run(self) -> None:
logging.basicConfig(
stream=sys.stdout,
level=logging.INFO,
format="[%(asctime)s] %(message)s",
)
self.logo()
self.main_menu()
def logo(self) -> None:
print(LOGO)
def main_menu(self) -> None:
while True:
choice = self.menu(
[
(MainMenu.create, "Create a new character"),
(MainMenu.play, "Play as character"),
(MainMenu.info, "Query character info"),
(MainMenu.delete, "Delete a character"),
(MainMenu.quit, "Quit"),
]
)
if choice == MainMenu.create:
player = self.create_player()
elif choice == MainMenu.play:
player = self.choose_player()
if not player:
continue
self.play(player)
elif choice == MainMenu.info:
player = self.choose_player()
if not player:
continue
self.print_player_info(player)
elif choice == MainMenu.delete:
player = self.choose_player()
if not player:
continue
self.delete_player(player)
elif choice == MainMenu.quit:
self.quit()
break
def create_player(self) -> T.Optional[Player]:
name = input("Name your new character: ")
if not name:
print("Cancelled.")
return None
race = self.menu([(race, race.name) for race in RACES])
class_ = self.menu([(class_, class_.name) for class_ in CLASSES])
stats_builder = StatsBuilder()
while True:
stats = stats_builder.roll()
for stat in PRIME_STATS:
print(f"{stat.value}: {stats[stat]}")
print(f"Total: {sum(stats[stat] for stat in PRIME_STATS)}")
if self.confirm("Is this okay?"):
break
player = create_player(
name=name, race=race, class_=class_, stats=stats
)
self.roster.players.append(player)
if self.confirm("Do you want to play as your new character?"):
self.play(player)
return player
def play(self, player: Player) -> None:
print(f"Playing as {player.name}")
simulation = Simulation(player)
last_save = datetime.now()
last_tick = datetime.now()
last_level = 0
while True:
elapsed = datetime.now() - last_tick
simulation.tick(elapsed.total_seconds() * 1000)
last_tick = datetime.now()
wait = (timedelta(milliseconds=100) - elapsed).total_seconds()
wait = max(0, wait)
time.sleep(wait)
if player.level != last_level:
last_level = player.level
self.print_player_info(player)
if self.args.use_saves:
self.roster.save_periodically()
def print_player_info(self, player: Player) -> None:
print("--- Character Sheet ---")
print(f"Name: {player.name}")
print(f"Race: {player.race.name}")
print(f"Class: {player.class_.name}")
print(f"Level: {player.level}")
print()
for stat, value in player.stats:
print(f"{stat.value}: {value}")
print()
print("--- Spell Book ---")
if not player.spell_book:
print("No spells memorized yet.")
else:
for spell in player.spell_book:
print(f"{spell.name} {lingo.to_roman(spell.level)}")
print()
print("--- Equipment ---")
for equipment_type, name in player.equipment:
print(f"{equipment_type.value}: {name}")
print()
print("--- Inventory ---")
print(f"Gold: {player.inventory.gold}")
for item in player.inventory:
print(f"{item.name}: {item.quantity}")
print()
print("--- Plot ---")
print(f"Current act: {lingo.to_roman(player.quest_book.act)}")
print(f"Current quest: {player.quest_book.current_quest or '?'}")
print(
f"Current task: {player.task.description if player.task else '?'}"
)
def delete_player(self, player: Player) -> None:
if self.confirm(lingo.terminate_message(player.name)):
del self.roster.players[self.roster.players.index(player)]
def quit(self) -> None:
if self.args.use_saves:
self.roster.save()
def choose_player(self) -> T.Optional[Player]:
if not self.roster.players:
print("No characters to choose from!")
return None
return self.menu(
[(player, player.name) for player in self.roster.players],
title="Choose your character",
)
def confirm(self, message: str) -> bool:
while True:
choice = input(f"{message} [y/n] ")
if choice.lower() in {"y", "yes", "1"}:
return True
if choice.lower() in {"n", "no", "0"}:
return False
def menu(
self,
options: T.Sequence[T.Tuple[T.Any, str]],
title: T.Optional[str] = None,
) -> T.Any:
print()
if title:
print(f"{title}:")
for i, item in enumerate(options, 1):
_, description = item
print(f"{i}) {description}")
print()
while True:
try:
num = int(input("Your choice: "))
except ValueError:
print("Not a number")
continue
if num not in range(1, len(options) + 1):
print(f"Expected a number betwen 1..{len(options)}")
continue
return options[num - 1][0]
-44
View File
@@ -1,44 +0,0 @@
import typing as T
import urwid
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
class ConfirmDialog(urwid.Overlay):
signals = ["confirm", "cancel"]
def __init__(self, label: str, parent: urwid.Widget) -> None:
question = urwid.Text(("bold", label), "center")
yes_btn = CustomButton(
"Yes", hint="Y", on_press=lambda _user_data: self.confirm()
)
no_btn = CustomButton(
"No", hint="N", on_press=lambda _user_data: self.cancel()
)
line_box = CustomLineBox(
urwid.ListBox(
urwid.SimpleFocusListWalker([question, no_btn, yes_btn])
)
)
super().__init__(
line_box, parent, "center", len(label) + 6, "middle", 5
)
def keypress(self, size: T.Any, key: str) -> T.Optional[str]:
if key in {"y", "Y"}:
self.confirm()
return None
if key in {"n", "N"}:
self.cancel()
return None
return T.cast(T.Optional[str], super().keypress(size, key))
def confirm(self) -> None:
self._emit("confirm")
def cancel(self) -> None:
self._emit("cancel")
+196
View File
@@ -0,0 +1,196 @@
import argparse
import curses
import os
import signal
import time
import typing as T
from pqcli import lingo
from pqcli.config import Class, Race
from pqcli.mechanic import Player, Stats, create_player
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import (
COLOR_FOCUSED,
COLOR_HIGHLIGHT,
COLOR_LOGO,
COLOR_LOGO_ALT,
COLOR_PROGRESSBAR,
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
set_colors,
)
from pqcli.ui.curses.views import (
BaseView,
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
ChooseCharacterView,
ConfirmView,
GameView,
RosterView,
)
class StopMainLoop(Exception):
pass
class CursesUserInterface(BaseUserInterface):
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
super().__init__(roster, args)
os.environ.setdefault("ESCDELAY", "25")
self._screen = curses.initscr()
self._screen.nodelay(True)
self._screen.keypad(True)
curses.noecho()
curses.curs_set(0)
set_colors(args.colors)
if has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(COLOR_LOGO, -1, -1)
curses.init_pair(COLOR_LOGO_ALT, -1, 9)
curses.init_pair(COLOR_FOCUSED, -1, 8)
curses.init_pair(COLOR_SCROLLBAR_THUMB, -1, 7)
curses.init_pair(COLOR_SCROLLBAR_TRACK, -1, 0)
curses.init_pair(COLOR_PROGRESSBAR, 0, 9)
curses.init_pair(COLOR_HIGHLIGHT, 3, -1)
def signal_handler(sig: T.Any, frame: T.Any) -> None:
if self.args.use_saves:
self.roster.save()
exit(0)
signal.signal(signal.SIGINT, signal_handler)
self._roster_view = RosterView(self._screen)
self._roster_view.on_create += self._switch_to_create_char_name_view
self._roster_view.on_delete += self._switch_to_delete_char_view
self._roster_view.on_play += self._switch_to_play_view
self._roster_view.on_quit += self._quit
self._view: BaseView = self._roster_view
self._view.start()
def run(self) -> None:
try:
self._switch_to_roster_view()
while True:
key = T.cast(int, self._screen.getch())
if key == curses.ERR:
time.sleep(0.1)
self._view.idle()
continue
if key == curses.KEY_RESIZE:
self._view.stop()
self._view.start()
else:
try:
self._view.keypress(key)
except StopMainLoop:
break
finally:
curses.endwin()
curses.curs_set(1)
def _switch_to_roster_view(self) -> None:
self._switch_view(self._roster_view)
def _switch_to_create_char_name_view(
self, player_name: T.Optional[str] = None
) -> None:
view = ChooseCharacterNameView(self._screen, player_name)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_create_char_race_view
self._switch_view(view)
def _switch_to_create_char_race_view(
self, player_name: str, race: T.Optional[Race] = None
) -> None:
view = ChooseCharacterRaceView(self._screen, race)
view.on_cancel += lambda: self._switch_to_create_char_name_view(
player_name
)
view.on_confirm += lambda race: self._switch_to_create_char_class_view(
player_name, race
)
self._switch_view(view)
def _switch_to_create_char_class_view(
self, player_name: str, race: Race, class_: T.Optional[Class] = None
) -> None:
view = ChooseCharacterClassView(self._screen, class_)
view.on_cancel += lambda: self._switch_to_create_char_race_view(
player_name, race
)
view.on_confirm += lambda class_: self._switch_to_create_char_stats_view(
player_name, race, class_
)
self._switch_view(view)
def _switch_to_create_char_stats_view(
self, player_name: str, race: Race, class_: Class
) -> None:
view = ChooseCharacterStatsView(self._screen)
view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_
)
view.on_confirm += lambda stats: self._create_character(
player_name, race, class_, stats
)
self._switch_view(view)
def _switch_to_delete_char_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to delete"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_confirm_delete_char_view
self._switch_view(view)
def _switch_to_confirm_delete_char_view(self, player: Player) -> None:
view = ConfirmView(self._screen, lingo.terminate_message(player.name))
view.on_cancel += self._switch_to_delete_char_view
view.on_confirm += lambda: self._delete_character(player)
self._switch_view(view)
def _switch_to_play_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to play"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_game_view
self._switch_view(view)
def _switch_to_game_view(self, player: Player) -> None:
view = GameView(self._screen, self.roster, player, self.args)
view.on_exit += self._switch_to_roster_view
self._switch_view(view)
def _switch_view(self, view: BaseView) -> None:
self._view.stop()
self._view = view
self._view.start()
def _create_character(
self, name: str, race: Race, class_: Class, stats: Stats
) -> None:
player = create_player(name, race, class_, stats)
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
self._switch_to_game_view(player)
def _delete_character(self, player: Player) -> None:
self.roster.players.remove(player)
self._switch_to_roster_view()
def _quit(self) -> None:
raise StopMainLoop
+18
View File
@@ -0,0 +1,18 @@
_HAS_COLORS = False
COLOR_LOGO = 1
COLOR_LOGO_ALT = 2
COLOR_FOCUSED = 3
COLOR_SCROLLBAR_TRACK = 4
COLOR_SCROLLBAR_THUMB = 5
COLOR_PROGRESSBAR = 6
COLOR_HIGHLIGHT = 7
def has_colors() -> bool:
return _HAS_COLORS
def set_colors(has_colors: bool) -> None:
global _HAS_COLORS
_HAS_COLORS = has_colors
+14
View File
@@ -0,0 +1,14 @@
import typing as T
class EventHandler:
def __init__(self) -> None:
self.callbacks: T.List[T.Callable[..., T.Any]] = []
def __call__(self, *args: T.Any, **kwargs: T.Any) -> None:
for callback in self.callbacks:
callback(*args, **kwargs)
def __iadd__(self, callback: T.Callable[..., T.Any]) -> "EventHandler":
self.callbacks.append(callback)
return self
+22
View File
@@ -0,0 +1,22 @@
import curses
import curses.ascii
import typing as T
from dataclasses import dataclass
KEYS_CYCLE = {curses.ascii.TAB}
KEYS_DOWN = set(map(ord, "jJ")) | {curses.KEY_DOWN}
KEYS_LEFT = set(map(ord, "hH")) | {curses.KEY_LEFT}
KEYS_RIGHT = set(map(ord, "lL")) | {curses.KEY_RIGHT}
KEYS_UP = set(map(ord, "kK")) | {curses.KEY_UP}
KEYS_CANCEL = set(map(ord, "qQ")) | {curses.ascii.ESC}
@dataclass
class Choice:
keys: T.List[int]
desc: str
callback: T.Callable[..., T.Any]
def first(source: T.Iterable[T.Any], default: T.Any = None) -> T.Any:
return next(iter(source), default)
+11
View File
@@ -0,0 +1,11 @@
from .base_view import BaseView
from .choose_character_view import ChooseCharacterView
from .confirm_view import ConfirmView
from .create_character_view import (
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
)
from .game_view import GameView
from .roster_view import RosterView
+18
View File
@@ -0,0 +1,18 @@
import typing as T
class BaseView:
def __init__(self, screen: T.Any) -> None:
self.screen = screen
def start(self) -> None:
pass
def stop(self) -> None:
pass
def idle(self) -> None:
pass
def keypress(self, key: int) -> None:
pass
@@ -0,0 +1,53 @@
import functools
import typing as T
from pqcli.lingo import act_name, to_roman
from pqcli.roster import Roster
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterView(MenuView):
def __init__(self, screen: T.Any, roster: Roster, title: str) -> None:
super().__init__(screen, title)
for y, player in enumerate(roster.players, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None
best_equip = player.equipment.best
best_spell = player.spell_book.best
best_spell_name = (
(best_spell.name + " " + to_roman(best_spell.level))
if best_spell
else "-"
)
best_stat_name = (
f"{player.stats.best_prime.value} "
f"{player.stats[player.stats.best_prime]}"
)
label = (
f"[{key or '-'}] "
f"{player.name} the {player.race.name} "
f"({act_name(player.quest_book.act)})\n"
f" Level {player.level} {player.class_.name}\n"
f" {best_equip} / {best_spell_name} / {best_stat_name}"
)
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=label,
callback=functools.partial(self.on_confirm, player),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
+50
View File
@@ -0,0 +1,50 @@
import typing as T
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.widgets import Menu
from .base_view import BaseView
class ConfirmView(BaseView):
def __init__(self, screen: T.Any, title: str) -> None:
super().__init__(screen)
self.title = f"{title}:"
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self.choices: T.List[Choice] = [
Choice(
keys=list(map(ord, "yY")),
desc=f"[Y] Yes",
callback=self.on_confirm,
),
Choice(
keys=list(map(ord, "nNqQ\N{ESC}")),
desc="[N] No",
callback=self.on_cancel,
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = Menu(
header=self.title,
choices=self.choices,
active_choice=0,
scr_height=scr_height,
scr_width=scr_width,
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
@@ -0,0 +1,4 @@
from .choose_character_class_view import ChooseCharacterClassView
from .choose_character_name_view import ChooseCharacterNameView
from .choose_character_race_view import ChooseCharacterRaceView
from .choose_character_stats_view import ChooseCharacterStatsView
@@ -0,0 +1,35 @@
import functools
import typing as T
from pqcli.config import CLASSES, Class
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterClassView(MenuView):
def __init__(
self, screen: T.Any, class_: T.Optional[Class] = None
) -> None:
super().__init__(
screen,
"Choose character class",
CLASSES.index(class_) if class_ is not None else 0,
)
for y, class_ in enumerate(CLASSES, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=f"[{key or '-'}] {class_.name}",
callback=functools.partial(self.on_confirm, class_),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
@@ -0,0 +1,120 @@
import curses
import curses.ascii
import typing as T
from pqcli.mechanic import generate_name
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterNameView(BaseView):
def __init__(
self, screen: T.Any, character_name: T.Optional[str] = None
) -> None:
super().__init__(screen)
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self._active_widget = 0
self._win: T.Optional[T.Any] = None
self._text_win: T.Optional[T.Any] = None
self._text = character_name or generate_name()
def start(self) -> None:
self.screen.erase()
self.screen.noutrefresh()
scr_height, scr_width = self.screen.getmaxyx()
h = 8
w = 30
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
self._win = curses.newwin(h + 2, w + 2, y, x)
self._render()
def stop(self) -> None:
del self._win
del self._text_win
self._win = None
self._text_win = None
def keypress(self, key: int) -> None:
if key == curses.KEY_F5:
self._text = generate_name()
elif key == curses.KEY_F10:
self._finish()
elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4
elif self._active_widget == 0:
if key == curses.ascii.ESC:
self.on_cancel()
elif curses.ascii.isprint(key):
self._text += chr(key)
elif key == curses.KEY_BACKSPACE or key == curses.ascii.DEL:
self._text = self._text[:-1]
elif key == curses.ascii.NL:
self._finish()
elif key == curses.ascii.ETB: # ^w
self._text = ""
elif key == curses.KEY_DOWN:
self._active_widget = 1
else:
if key == curses.ascii.NL:
if self._active_widget == 1:
self._text = generate_name()
elif self._active_widget == 2:
self._finish()
elif self._active_widget == 3:
self.on_cancel()
else:
assert False
elif key in KEYS_CANCEL:
self.on_cancel()
elif key in KEYS_DOWN and self._active_widget < 3:
self._active_widget += 1
elif key in KEYS_UP and self._active_widget > 0:
self._active_widget -= 1
self._render()
def _finish(self) -> None:
self._text = self._text.strip()
if self._text:
self.on_confirm(self._text)
else:
self.on_cancel()
def _render(self) -> None:
if not self._win or not self._text_win:
return
self._win.erase()
self._win.addstr(0, 0, "Choose character name:")
with focus_standout(self._active_widget == 1, self._win):
self._win.addstr(6, 0, "[F5 ] Generate random name")
with focus_standout(self._active_widget == 2, self._win):
self._win.addstr(7, 0, "[F10 ] Continue")
with focus_standout(self._active_widget == 3, self._win):
self._win.addstr(8, 0, "[Esc ] Cancel")
self._win.refresh()
self._text_win.erase()
with focus_standout(self._active_widget == 0, self._text_win):
self._text_win.box()
self._text_win.addnstr(
1, 1, self._text, self._text_win.getmaxyx()[1] - 2
)
self._text_win.refresh()
@@ -0,0 +1,33 @@
import functools
import typing as T
from pqcli.config import RACES, Race
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterRaceView(MenuView):
def __init__(self, screen: T.Any, race: T.Optional[Race] = None) -> None:
super().__init__(
screen,
"Choose character race",
RACES.index(race) if race is not None else 0,
)
for y, race in enumerate(RACES, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=f"[{key or '-'}] {race.name}",
callback=functools.partial(self.on_confirm, race),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
@@ -0,0 +1,111 @@
import curses.ascii
import typing as T
from pqcli.config import PRIME_STATS
from pqcli.mechanic import StatsBuilder
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterStatsView(BaseView):
def __init__(self, screen: T.Any) -> None:
super().__init__(screen)
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self._active_widget = 0
self._win: T.Optional[T.Any] = None
self._stats_win: T.Optional[T.Any] = None
self._stats_builder = StatsBuilder()
self._stats = self._stats_builder.roll()
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
h2 = len(PRIME_STATS) + 2
h = 7 + h2
w = 30
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self.screen.erase()
self.screen.noutrefresh()
self._win = curses.newwin(h, w, y, x)
self._stats_win = curses.newwin(h2, w, y + 2, x - 1)
self._render()
def stop(self) -> None:
del self._win
del self._stats_win
self._win = None
self._stats_win = None
def keypress(self, key: int) -> None:
if key in KEYS_CANCEL:
self.on_cancel()
elif key == curses.ascii.NL:
if self._active_widget == 0:
self._stats = self._stats_builder.roll()
elif self._active_widget == 1:
self._stats = self._stats_builder.unroll()
elif self._active_widget == 2:
self.on_confirm(self._stats)
elif self._active_widget == 3:
self.on_cancel()
else:
assert False
elif key == curses.KEY_F5:
self._stats = self._stats_builder.roll()
elif key == curses.KEY_F6:
self._stats = self._stats_builder.unroll()
elif key == curses.KEY_F10:
self.on_confirm(self._stats)
elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4
elif key in KEYS_DOWN and self._active_widget < 3:
self._active_widget += 1
elif key in KEYS_UP and self._active_widget > 0:
self._active_widget -= 1
self._render()
def _render(self) -> None:
if not self._win or not self._stats_win:
return
y, x = self._win.getbegyx()
stats_h = self._stats_win.getmaxyx()[0]
self._win.erase()
self._win.addstr(0, 0, "Roll character stats:")
with focus_standout(self._active_widget == 0, self._win):
self._win.addstr(3 + stats_h + 0, 0, "[F5 ] Roll")
with focus_standout(self._active_widget == 1, self._win):
self._win.addstr(3 + stats_h + 1, 0, "[F6 ] Unroll")
with focus_standout(self._active_widget == 2, self._win):
self._win.addstr(3 + stats_h + 2, 0, "[F10 ] Continue")
with focus_standout(self._active_widget == 3, self._win):
self._win.addstr(3 + stats_h + 3, 0, "[Esc ] Cancel")
self._win.noutrefresh()
self._stats_win.erase()
for y, stat in enumerate(PRIME_STATS):
self._stats_win.addstr(
y + 1, 1, f"{stat.value}: {self._stats[stat]}"
)
self._stats_win.box()
self._stats_win.noutrefresh()
curses.doupdate()
+263
View File
@@ -0,0 +1,263 @@
import argparse
import curses
import datetime
import typing as T
from pqcli.mechanic import Player, SignalMixin, Simulation
from pqcli.roster import Roster
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import (
KEYS_CANCEL,
KEYS_CYCLE,
KEYS_DOWN,
KEYS_LEFT,
KEYS_RIGHT,
KEYS_UP,
)
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Focusable, Scrollable, Widget
from .character_sheet_window import CharacterSheetWindow
from .equipment_window import EquipmentWindow
from .inventory_window import InventoryWindow
from .plot_window import PlotWindow
from .quest_book_window import QuestBookWindow
from .spell_book_window import SpellBookWindow
from .task_progress_window import TaskProgressWindow
class GameView(BaseView):
def __init__(
self,
screen: T.Any,
roster: Roster,
player: Player,
args: argparse.Namespace,
) -> None:
super().__init__(screen)
self.on_exit = EventHandler()
self._roster = roster
self._player = player
self._args = args
self._simulation = Simulation(player)
self._last_tick = datetime.datetime.now()
def keypress(self, key: int) -> None:
focused = self.focused
if key in KEYS_CANCEL:
self.on_exit()
elif key in {curses.KEY_PPAGE}:
if hasattr(focused, "scroll_page_up"):
focused.scroll_page_up()
elif key in {curses.KEY_NPAGE}:
if hasattr(focused, "scroll_page_down"):
focused.scroll_page_down()
elif key in KEYS_CYCLE:
if focused == self._char_sheet_win:
self.focus(self._spell_book_win)
elif focused == self._spell_book_win:
self.focus(self._equipment_win)
elif focused == self._equipment_win:
self.focus(self._inventory_win)
elif focused == self._inventory_win:
self.focus(self._plot_win)
elif focused == self._plot_win:
self.focus(self._quest_book_win)
elif focused == self._quest_book_win:
self.focus(self._char_sheet_win)
elif key in KEYS_DOWN:
if focused == self._char_sheet_win:
self.focus(self._spell_book_win)
elif focused == self._equipment_win:
self.focus(self._inventory_win)
elif focused == self._plot_win:
self.focus(self._quest_book_win)
elif key in KEYS_UP:
if focused == self._spell_book_win:
self.focus(self._char_sheet_win)
elif focused == self._inventory_win:
self.focus(self._equipment_win)
elif focused == self._quest_book_win:
self.focus(self._plot_win)
elif key in KEYS_LEFT:
if focused == self._equipment_win:
self.focus(self._char_sheet_win)
elif focused == self._inventory_win:
self.focus(self._spell_book_win)
elif focused == self._plot_win:
self.focus(self._equipment_win)
elif focused == self._quest_book_win:
self.focus(self._inventory_win)
elif key in KEYS_RIGHT:
if focused == self._char_sheet_win:
self.focus(self._equipment_win)
elif focused == self._spell_book_win:
self.focus(self._inventory_win)
elif focused == self._equipment_win:
self.focus(self._plot_win)
elif focused == self._inventory_win:
self.focus(self._quest_book_win)
elif self._args.cheats and key == ord("t"):
self._simulation.tick()
elif self._args.cheats and key == ord("T"):
for _ in range(100):
self._simulation.tick()
elif self._args.cheats and key == curses.ascii.DC4: # ^t
old = SignalMixin.emit
SignalMixin.emit = lambda *_: None
for _ in range(10000):
self._simulation.tick()
SignalMixin.emit = old
for child in self._children:
child.sync()
else:
super().keypress(key)
curses.doupdate()
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
col1_width = int(scr_width * 0.3)
col3_width = col1_width
col2_width = scr_width - col1_width - col3_width
self.screen.erase()
self.screen.noutrefresh()
self._big_win = curses.newwin(scr_height, scr_width)
self._col1_win = self._big_win.derwin(scr_height - 2, col1_width, 0, 0)
self._col2_win = self._big_win.derwin(
self._col1_win.getmaxyx()[0],
col2_width,
0,
self._col1_win.getmaxyx()[1],
)
self._col3_win = self._big_win.derwin(
self._col1_win.getmaxyx()[0],
col3_width,
0,
self._col1_win.getmaxyx()[1] + self._col2_win.getmaxyx()[1],
)
self._char_sheet_win = CharacterSheetWindow(
player=self._player,
parent=self._col1_win,
h=min(17, self._col1_win.getmaxyx()[0]),
w=self._col1_win.getmaxyx()[1],
y=0,
x=0,
)
self._spell_book_win = SpellBookWindow(
player=self._player,
parent=self._col1_win,
h=self._col1_win.getmaxyx()[0]
- self._char_sheet_win.getmaxyx()[0],
w=self._col1_win.getmaxyx()[1],
y=self._char_sheet_win.getmaxyx()[0],
x=0,
)
self._equipment_win = EquipmentWindow(
player=self._player,
parent=self._col2_win,
h=min(15, self._col2_win.getmaxyx()[0]),
w=self._col2_win.getmaxyx()[1],
y=0,
x=0,
)
self._inventory_win = InventoryWindow(
player=self._player,
parent=self._col2_win,
h=max(
1,
self._col2_win.getmaxyx()[0]
- self._equipment_win.getmaxyx()[0],
),
w=self._col2_win.getmaxyx()[1],
y=self._equipment_win.getmaxyx()[0],
x=0,
)
self._plot_win = PlotWindow(
player=self._player,
parent=self._col3_win,
h=self._equipment_win.getmaxyx()[0],
w=self._col3_win.getmaxyx()[1],
y=0,
x=0,
)
self._quest_book_win = QuestBookWindow(
player=self._player,
parent=self._col3_win,
h=max(
1, self._col3_win.getmaxyx()[0] - self._plot_win.getmaxyx()[0]
),
w=self._col3_win.getmaxyx()[1],
y=self._plot_win.getmaxyx()[0],
x=0,
)
self._task_win = TaskProgressWindow(
player=self._player,
parent=self._big_win,
h=2,
w=scr_width,
y=scr_height - 2,
x=0,
)
self._focusable_children: T.List[Focusable] = [
self._char_sheet_win,
self._spell_book_win,
self._equipment_win,
self._inventory_win,
self._plot_win,
self._quest_book_win,
]
self._children: T.List[Widget] = self._focusable_children + [
self._task_win
]
def stop(self) -> None:
for child in self._children:
child.stop()
del self._col1_win
del self._col2_win
del self._col3_win
del self._big_win
def idle(self) -> None:
elapsed = datetime.datetime.now() - self._last_tick
self._simulation.tick(elapsed.total_seconds() * 1000)
self._last_tick = datetime.datetime.now()
if self._args.use_saves:
self._roster.save_periodically()
curses.doupdate()
@property
def focused(self) -> Focusable:
for widget in self._focusable_children:
if widget.focused:
return widget
raise AssertionError
def focus(self, widget: Focusable) -> None:
self.focused.focused = False
widget.focused = True
@@ -0,0 +1,66 @@
import typing as T
from pqcli.mechanic import Player, StatType
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow
class CharacterSheetWindow(Focusable, DataTableProgressBarWindow):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(
parent,
h,
w,
y,
x,
" Character Sheet ",
align_right=False,
show_time=True,
)
self._on_focus_change += self._render
self._focused = True
self._player = player
self._player.connect("level_up", self._sync_traits)
self._player.stats.connect("change", self._sync_traits)
self._player.exp_bar.connect("change", self._sync_exp)
self.sync()
def stop(self) -> None:
super().stop()
self._player.disconnect("level_up", self._sync_traits)
self._player.stats.disconnect("change", self._sync_traits)
self._player.exp_bar.disconnect("change", self._sync_exp)
def sync(self) -> None:
self._sync_traits()
self._sync_exp()
def _sync_traits(self) -> None:
if not self._win:
return
self._data_table.clear()
self._data_table.add("Name", self._player.name)
self._data_table.add("Race", self._player.race.name)
self._data_table.add("Class", self._player.class_.name)
self._data_table.add("Level", str(self._player.level))
self._data_table.add(" " * 15, "")
for stat in StatType:
self._data_table.add(stat.value, str(self._player.stats[stat]))
self._render_data_table()
def _sync_exp(self) -> None:
self._cur_pos = self._player.exp_bar.position
self._max_pos = self._player.exp_bar.max_
self._progress_title = (
f"Experience ({self._max_pos-self._cur_pos:.0f} XP to go)"
)
self._render_progress_bar()
@@ -0,0 +1,59 @@
import typing as T
from pqcli.mechanic import EquipmentType, Player
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class EquipmentWindow(Focusable, WindowWrapper):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render
self._data_table = DataTable(
self._win, h - 2, w - 2, 1, 1, align_right=False
)
self._player = player
self._player.equipment.connect("change", self._sync_equipment_change)
self.sync()
def stop(self) -> None:
super().stop()
self._data_table.stop()
self._player.equipment.disconnect(
"change", self._sync_equipment_change
)
def sync(self) -> None:
self._data_table.clear()
for equipment_type in EquipmentType:
self._data_table.add(
equipment_type.value.ljust(15),
self._player.equipment[equipment_type] or "",
)
self._data_table.select(None)
self._render()
def _sync_equipment_change(
self, equipment_type: EquipmentType, item_name: T.Optional[str]
) -> None:
self._data_table.set(equipment_type.value, item_name or "")
self._data_table.select(equipment_type.value)
self._render()
def _render(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
text = " Equipment "
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
self._win.noutrefresh()
self._data_table.render()
@@ -0,0 +1,106 @@
import typing as T
from pqcli.mechanic import InventoryItem, Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow
class InventoryWindow(Focusable, DataTableProgressBarWindow):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(
parent,
h,
w,
y,
x,
" Inventory ",
align_right=True,
show_time=False,
)
self._on_focus_change += self._render
self._player = player
self._player.connect("new_task", self._on_new_task)
self._player.inventory.connect("gold_change", self._sync_gold)
self._player.inventory.connect("item_add", self._sync_item_add)
self._player.inventory.connect("item_del", self._sync_item_del)
self._player.inventory.connect("item_change", self._sync_item_change)
self._player.inventory.encum_bar.connect(
"change", self._sync_encumbrance
)
self.sync()
def stop(self) -> None:
super().stop()
self._player.disconnect("new_task", self._on_new_task)
self._player.inventory.disconnect("gold_change", self._sync_gold)
self._player.inventory.disconnect("item_add", self._sync_item_add)
self._player.inventory.disconnect("item_del", self._sync_item_del)
self._player.inventory.disconnect(
"item_change", self._sync_item_change
)
self._player.inventory.encum_bar.disconnect(
"change", self._sync_encumbrance
)
def scroll_page_up(self) -> None:
self._data_table.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._data_table.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_gold()
self._sync_items()
self._sync_encumbrance()
def _sync_encumbrance(self) -> None:
self._cur_pos = self._player.inventory.encum_bar.position
self._max_pos = self._player.inventory.encum_bar.max_
self._progress_title = (
f"Encumbrance ({self._cur_pos:.0f}/{self._max_pos} cubits)"
)
self._render_progress_bar()
def _sync_gold(self) -> None:
self._data_table.set("Gold", str(self._player.inventory.gold))
self._data_table.select("Gold")
self._render_data_table()
def _sync_items(self) -> None:
self._data_table.clear()
self._sync_gold()
for item in self._player.inventory:
self._data_table.add(item.name, str(item.quantity))
self._data_table.scroll_to_item(0)
self._data_table.select(None)
self._render_data_table()
def _sync_item_add(self, item: InventoryItem) -> None:
self._data_table.add(item.name, str(item.quantity))
self._data_table.select(item.name)
self._render_data_table()
def _sync_item_del(self, item: InventoryItem) -> None:
self._data_table.delete(item.name)
self._data_table.select(None)
self._render_data_table()
def _sync_item_change(self, item: InventoryItem) -> None:
self._data_table.set(item.name, str(item.quantity))
self._data_table.select(item.name)
self._render_data_table()
def _on_new_task(self) -> None:
description = (
self._player.task.description if self._player.task else "?"
)
if description.lower().startswith("sell"):
self._data_table.scroll_to_item(0)
@@ -0,0 +1,69 @@
import typing as T
from pqcli.lingo import act_name
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import ListBoxProgressBarWindow
class PlotWindow(Focusable, ListBoxProgressBarWindow):
cutoff = 100
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(
parent, h, w, y, x, " Plot Development ", show_time=True
)
self._on_focus_change += self._render
self._player = player
self._player.quest_book.connect("start_act", self._sync_act_add)
self._player.quest_book.plot_bar.connect("change", self._sync_position)
self.sync()
def stop(self) -> None:
super().stop()
self._list_box.stop()
self._player.quest_book.disconnect("start_act", self._sync_act_add)
self._player.quest_book.plot_bar.disconnect(
"change", self._sync_position
)
def scroll_page_up(self) -> None:
self._list_box.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._list_box.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_acts()
self._sync_position()
def _sync_acts(self) -> None:
self._list_box.clear()
for act_number in range(
max(0, self._player.quest_book.act - self.cutoff),
self._player.quest_book.act + 1,
):
self._sync_act_add(act_number)
self._render_list_box()
def _sync_act_add(self, act_number: int) -> None:
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
prev = self._list_box.get(-1)
if prev is not None:
self._list_box.set(-1, "[X] " + prev[4:])
self._list_box.add("[ ] " + act_name(act_number))
self._list_box.select(-1)
self._render_list_box()
def _sync_position(self) -> None:
self._cur_pos = self._player.quest_book.plot_bar.position
self._max_pos = self._player.quest_book.plot_bar.max_
self._render_progress_bar()
@@ -0,0 +1,155 @@
import curses
import typing as T
from pqcli.ui.curses.widgets import (
DataTable,
Focusable,
ListBox,
ProgressBar,
WindowWrapper,
)
class ProgressBarWindow(WindowWrapper):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
title: str,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x)
self._title = title
self._cur_pos = 0.0
self._max_pos = 1.0
self._progress_title = ""
try:
self._progress_bar_win = (
self._win.derwin(3, w, h - 3, 0) if self._win else None
)
except curses.error:
self._progress_bar_win = None
self._progress_bar = ProgressBar(
self._progress_bar_win, 1, w - 2, 1, 1, show_time=show_time
)
def stop(self) -> None:
super().stop()
self._progress_bar.stop()
del self._progress_bar_win
self._progress_bar_win = None
def _render_progress_bar(self) -> None:
if not self._progress_bar_win:
return
self._progress_bar_win.erase()
with Focusable.focus_standout(self, self._progress_bar_win):
self._progress_bar_win.border(
curses.ACS_VLINE,
curses.ACS_VLINE,
curses.ACS_HLINE,
curses.ACS_HLINE,
curses.ACS_LTEE,
curses.ACS_RTEE,
curses.ACS_LLCORNER,
curses.ACS_LRCORNER,
)
text = self._progress_title
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._progress_bar_win.addnstr(
0,
x,
text,
min(len(text), self._progress_bar_win.getmaxyx()[1]),
)
self._progress_bar.set_position(self._cur_pos, self._max_pos)
self._progress_bar_win.noutrefresh()
class DataTableProgressBarWindow(ProgressBarWindow):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
title: str,
align_right: bool,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x, title, show_time)
self._data_table = DataTable(
self._win, h - 4, w - 2, 1, 1, align_right
)
def stop(self) -> None:
super().stop()
self._data_table.stop()
def _render_data_table(self) -> None:
if not self._win:
return
with Focusable.focus_standout(self, self._win):
self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr(
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
)
self._win.noutrefresh()
self._data_table.render()
def _render(self) -> None:
self._render_data_table()
self._render_progress_bar()
class ListBoxProgressBarWindow(ProgressBarWindow):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
title: str,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x, title, show_time)
self._list_box = ListBox(self._win, h - 4, w - 2, 1, 1)
def stop(self) -> None:
super().stop()
self._list_box.stop()
def _render_list_box(self) -> None:
if not self._win:
return
with Focusable.focus_standout(self, self._win):
self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr(
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
)
self._win.noutrefresh()
self._list_box.render()
def _render(self) -> None:
self._render_list_box()
self._render_progress_bar()
@@ -0,0 +1,66 @@
import typing as T
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import ListBoxProgressBarWindow
class QuestBookWindow(Focusable, ListBoxProgressBarWindow):
cutoff = 100
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x, " Quests ", show_time=True)
self._on_focus_change += self._render
self._player = player
self._player.quest_book.connect("start_quest", self._sync_quest_add)
self._player.quest_book.quest_bar.connect(
"change", self._sync_position
)
self.sync()
def stop(self) -> None:
super().stop()
self._list_box.stop()
self._player.quest_book.disconnect("start_quest", self._sync_quest_add)
self._player.quest_book.quest_bar.disconnect(
"change", self._sync_position
)
def scroll_page_up(self) -> None:
self._list_box.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._list_box.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_quests()
self._sync_position()
def _sync_quests(self) -> None:
self._list_box.clear()
for quest_name in self._player.quest_book.quests[-self.cutoff :]:
self._sync_quest_add(quest_name)
self._list_box.select(-1)
self._render_list_box()
def _sync_quest_add(self, quest_name: str) -> None:
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
prev = self._list_box.get(-1)
if prev is not None:
self._list_box.set(-1, "[X] " + prev[4:])
self._list_box.add("[ ] " + quest_name)
self._list_box.select(-1)
self._render_list_box()
def _sync_position(self) -> None:
self._cur_pos = self._player.quest_book.quest_bar.position
self._max_pos = self._player.quest_book.quest_bar.max_
self._render_progress_bar()
@@ -0,0 +1,68 @@
import typing as T
from pqcli.lingo import to_roman
from pqcli.mechanic import Player, Spell
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class SpellBookWindow(Focusable, WindowWrapper):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render
self._data_table = DataTable(
self._win, h - 2, w - 2, 1, 1, align_right=True
)
self._player = player
self._player.spell_book.connect("add", self._sync_spell_add)
self._player.spell_book.connect("change", self._sync_spell_change)
self.sync()
def stop(self) -> None:
super().stop()
self._data_table.stop()
self._player.spell_book.disconnect("add", self._sync_spell_add)
self._player.spell_book.disconnect("change", self._sync_spell_change)
def scroll_page_up(self) -> None:
self._data_table.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._data_table.scroll_page_down()
self._render()
def sync(self) -> None:
self._data_table.clear()
for spell in self._player.spell_book:
self._data_table.add(spell.name, to_roman(spell.level))
self._data_table.select(None)
self._render()
def _sync_spell_add(self, spell: Spell) -> None:
self._data_table.add(spell.name, to_roman(spell.level))
self._data_table.select(spell.name)
self._render()
def _sync_spell_change(self, spell: Spell) -> None:
self._data_table.set(spell.name, to_roman(spell.level))
self._data_table.select(spell.name)
self._render()
def _render(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
text = " Spell Book "
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
self._win.noutrefresh()
self._data_table.render()
@@ -0,0 +1,50 @@
import typing as T
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import (
Focusable,
Label,
ProgressBar,
WindowWrapper,
)
class TaskProgressWindow(Focusable, WindowWrapper):
def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._desc_win = Label(self._win, 1, w, 0, 0)
self._progress_bar = ProgressBar(
self._win, 1, w, 1, 0, show_time=False
)
self._player = player
self._player.connect("new_task", self._sync_task_name)
self._player.task_bar.connect("change", self._sync_position)
self.sync()
def stop(self) -> None:
super().stop()
self._desc_win.stop()
self._progress_bar.stop()
self._player.disconnect("new_task", self._sync_task_name)
self._player.task_bar.disconnect("change", self._sync_position)
def sync(self) -> None:
self._sync_task_name()
self._sync_position()
def _sync_task_name(self) -> None:
self._desc_win.set_text(
f"{self._player.task.description}..." if self._player.task else "?"
)
def _sync_position(self) -> None:
self._progress_bar.set_position(
self._player.task_bar.position, self._player.task_bar.max_
)
+41
View File
@@ -0,0 +1,41 @@
import typing as T
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Menu
class MenuView(BaseView):
def __init__(
self, screen: T.Any, title: str, active_choice: int = 0
) -> None:
super().__init__(screen)
self.on_cancel = EventHandler()
self.on_confirm = EventHandler()
self._title = f"{title}:"
self._choices: T.List[Choice] = []
self._active_choice = active_choice
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = Menu(
header=self._title,
choices=self._choices,
active_choice=self._active_choice,
scr_height=scr_height,
scr_width=scr_width,
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
+99
View File
@@ -0,0 +1,99 @@
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_LOGO, COLOR_LOGO_ALT, has_colors
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.widgets import Menu
from .base_view import BaseView
LOGO = """
█▀▀▄ ▄▀▀█
▀▄ ▀▄ ▄▀ ▄▀
▀▄ ▀▄ ▄▀ ▄▀
▀▄ ▄▀ ▄▀
▄▀ ▄▀▄
▄▄▄▄▀ ▄▀▄ ▀▄▄▄▄
▀▄ █▄▄▀ ▀▄▄█ ▄▀
▄▀█▄ █ █ ▄█▀▄
▄▄▀▄▀ ▀▀ ▀▀ ▀▄▀▄▄
█▄█ █▄█
█▀▀▄ ▄▀▀▄ █
█▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀
█ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █
▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀
""".strip(
"\n"
)
class MainMenu(Menu):
def __init__(
self, choices: T.List[Choice], scr_height: int, scr_width: int
) -> None:
super().__init__(
header=LOGO,
choices=choices,
active_choice=0,
scr_height=scr_height,
scr_width=scr_width,
)
if has_colors():
for y in range(len(self._header_lines)):
self._pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
self._pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
class RosterView(BaseView):
def __init__(self, screen: T.Any) -> None:
super().__init__(screen)
self.on_create = EventHandler()
self.on_play = EventHandler()
self.on_delete = EventHandler()
self.on_quit = EventHandler()
self.choices: T.List[Choice] = [
Choice(
keys=list(map(ord, "cC")),
desc="[C] Create new character",
callback=self.on_create,
),
Choice(
keys=list(map(ord, "pP")),
desc="[P] Play",
callback=self.on_play,
),
Choice(
keys=list(map(ord, "dD")),
desc="[D] Delete character",
callback=self.on_delete,
),
Choice(
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = MainMenu(
choices=self.choices, scr_height=scr_height, scr_width=scr_width
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
+8
View File
@@ -0,0 +1,8 @@
from .base import Widget, WindowWrapper
from .data_table import DataTable
from .focusable import Focusable
from .label import Label
from .list_box import ListBox
from .menu import Menu
from .progress_bar import ProgressBar
from .scrollable import Scrollable
+41
View File
@@ -0,0 +1,41 @@
import curses
import typing as T
class Widget:
def __del__(self) -> None:
self.stop()
def stop(self) -> None:
pass
class WindowWrapper(Widget):
def __init__(
self, parent: T.Optional[T.Any], h: int, w: int, y: int, x: int
) -> None:
self._parent = parent
self._win: T.Optional[T.Any]
if not parent:
self._win = None
else:
try:
self._win = parent.derwin(h, w, y, x)
self._win.noutrefresh()
except curses.error:
self._win = None
def stop(self) -> None:
del self._win
self._win = None
def getbegyx(self) -> T.Tuple[int, int]:
if not self._win:
return (0, 0)
return self._win.getbegyx()
def getmaxyx(self) -> T.Tuple[int, int]:
if not self._win:
return (0, 0)
return self._win.getmaxyx()
+80
View File
@@ -0,0 +1,80 @@
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable
class DataTable(Scrollable):
def __init__(
self,
parent: T.Any,
h: int,
w: int,
y: int,
x: int,
align_right: bool = False,
) -> None:
super().__init__(parent, h, w, y, x)
self._align_right = align_right
self._items: T.List[T.Tuple[str, str]]
self._selected: T.Optional[int] = None
def add(self, text: str, value: str) -> None:
self._items.append((text, value))
self.scroll_to_item(-1)
def set(self, text: str, value: str) -> None:
idx = self.get_idx(text)
if idx is None:
self.add(text, value)
else:
self._items[idx] = (self._items[idx][0], value)
self.scroll_to_item(idx)
def delete(self, text: str) -> None:
idx = self.get_idx(text)
if idx is not None:
del self._items[idx]
self.scroll_to_item(idx)
def select(self, text: T.Optional[str]) -> None:
self._selected = None if text is None else self.get_idx(text)
def get_idx(self, text: str) -> T.Optional[int]:
for idx, row in enumerate(self._items):
if row[0].strip() == text.strip():
return idx
return None
def _render_impl(self, h: int, w: int) -> None:
assert self._pad
if self._align_right:
for y, row in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.move(y, 0)
self._pad.addnstr(row[0], min(len(row[0]), w))
col2_x = max(0, w - len(row[1]))
if col2_x < w:
self._pad.move(y, col2_x)
self._pad.addstr(row[1])
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
else:
col2_x = self._get_col_width(0)
for y, row in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, row[0], min(len(row[0]), w))
if col2_x < w:
self._pad.addnstr(
y, col2_x, row[1], min(len(row[1]), w - col2_x)
)
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
def _get_col_width(self, x: int) -> int:
return max([len(row[0]) for row in self._items] + [0])
+42
View File
@@ -0,0 +1,42 @@
import contextlib
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_FOCUSED, has_colors
from pqcli.ui.curses.event_handler import EventHandler
@contextlib.contextmanager
def focus_standout(focused: bool, win: T.Any) -> T.Generator:
if focused:
if has_colors():
win.attron(curses.color_pair(COLOR_FOCUSED))
else:
win.standout()
yield
if focused:
if has_colors():
win.attroff(curses.color_pair(COLOR_FOCUSED))
else:
win.standend()
class Focusable:
def __init__(self, *args: T.Any, **kwargs: T.Any) -> None:
self._focused = False
self._on_focus_change = EventHandler()
super().__init__(*args, **kwargs)
@property
def focused(self) -> bool:
return self._focused
@focused.setter
def focused(self, focused: bool) -> None:
self._focused = focused
self._on_focus_change()
@contextlib.contextmanager
def focus_standout(self, win: T.Any) -> T.Generator:
with focus_standout(self._focused, win):
yield
+10
View File
@@ -0,0 +1,10 @@
from .base import WindowWrapper
class Label(WindowWrapper):
def set_text(self, text: str) -> None:
if not self._win:
return
self._win.erase()
self._win.addnstr(text, min(len(text), self.getmaxyx()[1] - 1))
self._win.noutrefresh()
+50
View File
@@ -0,0 +1,50 @@
import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable
class ListBox(Scrollable):
_items: T.List[str]
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None:
super().__init__(parent, h, w, y, x)
self._selected: T.Optional[int] = None
def add(self, text: str) -> None:
self._items.append(text)
self.scroll_to_item(-1)
def set(self, idx: int, value: str) -> None:
if idx < 0:
idx %= len(self._items)
self._items[idx] = value
def get(self, idx: int) -> T.Optional[str]:
if not self._items:
return None
if idx < 0:
idx %= len(self._items)
try:
return self._items[idx]
except ValueError:
return None
def delete(self, idx: int, count: int = 1) -> None:
del self._items[idx : idx + count]
def select(self, idx: T.Optional[int]) -> None:
if idx < 0 and len(self._items):
idx %= len(self._items)
self._selected = idx
def _render_impl(self, h: int, w: int) -> None:
assert self._pad
for y, item in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, item, min(len(item), w))
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
+95
View File
@@ -0,0 +1,95 @@
import curses
import curses.ascii
import typing as T
from pqcli.ui.curses.util import KEYS_CYCLE, KEYS_DOWN, KEYS_UP, Choice
from pqcli.ui.curses.widgets.focusable import focus_standout
from .base import Widget
class Menu(Widget):
def __init__(
self,
header: str,
choices: T.List[Choice],
active_choice: int,
scr_height: int,
scr_width: int,
) -> None:
self._header_lines = header.split("\n")
self._choices = choices
self._scr_width = scr_width
self._scr_height = scr_height
all_lines = self._header_lines + sum(
[choice.desc.splitlines() for choice in choices], []
)
w = max(map(len, all_lines)) + 1
h = len(all_lines) + 1
self._pad: T.Optional[T.Any] = curses.newpad(h, w)
for y, line in enumerate(self._header_lines):
self._pad.move(y, 0)
self._pad.addstr(line)
self._active_choice = active_choice
def stop(self) -> None:
del self._pad
self._pad = None
def getmaxyx(self) -> T.Tuple[int, int]:
if not self._pad:
return (0, 0)
return self._pad.getmaxyx()
def next(self) -> None:
self._active_choice = min(
len(self._choices) - 1, self._active_choice + 1
)
def prev(self) -> None:
self._active_choice = max(0, self._active_choice - 1)
def cycle(self) -> None:
self._active_choice = (self._active_choice + 1) % len(self._choices)
def keypress(self, key: int) -> None:
for choice in self._choices:
if key in choice.keys:
choice.callback()
return
if key == curses.ascii.NL:
self._choices[self._active_choice].callback()
elif key in KEYS_DOWN:
self.next()
elif key in KEYS_UP:
self.prev()
elif key in KEYS_CYCLE:
self.cycle()
def render(self) -> None:
if not self._pad:
return
y = len(self._header_lines) + 1
for i, choice in enumerate(self._choices):
self._pad.move(y, 0)
with focus_standout(i == self._active_choice, self._pad):
lines = choice.desc.splitlines()
max_len = max(map(len, lines))
lines = [line.ljust(max_len) for line in lines]
self._pad.addstr("\n".join(lines))
y += len(choice.desc.splitlines())
self._pad.refresh(
0,
0,
(self._scr_height - self._pad.getmaxyx()[0]) // 2,
(self._scr_width - self._pad.getmaxyx()[1]) // 2,
self._scr_height - 1,
self._scr_width - 1,
)
+63
View File
@@ -0,0 +1,63 @@
import curses
import datetime
import typing as T
from pqcli.lingo import format_timespan
from pqcli.ui.curses.colors import COLOR_PROGRESSBAR, has_colors
from pqcli.ui.curses.widgets.base import WindowWrapper
class ProgressBar(WindowWrapper):
def __init__(
self, parent: T.Any, h: int, w: int, y: int, x: int, show_time: bool
) -> None:
super().__init__(parent, h, w, y, x)
self._cur_pos = 0.0
self._max_pos = 1.0
self._show_time = show_time
self._last_tick: T.Optional[T.Tuple[datetime.datetime, float]] = None
@property
def time_left(self) -> T.Optional[datetime.timedelta]:
if self._last_tick is None:
return None
time_then, pos_then = self._last_tick
time_now, pos_now = datetime.datetime.now(), self._cur_pos
div = (time_now - time_then).total_seconds()
if div == 0:
return None
speed = (pos_now - pos_then) / div
if not speed:
return None
return datetime.timedelta(seconds=(self._max_pos - pos_now) / speed)
def set_position(self, cur_pos: float, max_pos: float) -> None:
if not self._win:
return
self._win.erase()
if self._last_tick is None or cur_pos == 0 or max_pos != self._max_pos:
self._last_tick = (datetime.datetime.now(), cur_pos)
self._cur_pos = cur_pos
self._max_pos = max_pos
text = f"{cur_pos / max_pos:.02%}"
if self.time_left and self._show_time:
text += f" ({format_timespan(self.time_left)})"
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1] - 1))
x = int(cur_pos * self.getmaxyx()[1] // max_pos)
if x > 0:
self._win.chgat(
0,
0,
curses.color_pair(COLOR_PROGRESSBAR)
if has_colors()
else curses.A_REVERSE,
)
if x < self.getmaxyx()[1]:
self._win.chgat(0, x, curses.A_NORMAL)
self._win.noutrefresh()
+114
View File
@@ -0,0 +1,114 @@
import curses
import typing as T
from pqcli.ui.curses.colors import (
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
)
from .base import WindowWrapper
class Scrollable(WindowWrapper):
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None:
super().__init__(parent, h, w, y, x)
self._scroll_pos = 0
self._pad: T.Optional[T.Any] = curses.newpad(1, 1)
self._items: T.List[T.Any] = []
def clear(self) -> None:
self._items = []
self.scroll_to_item(0)
def scroll_page_up(self) -> None:
self.scroll_to_item(max(0, self._scroll_pos - self.getmaxyx()[0]))
def scroll_page_down(self) -> None:
self.scroll_to_item(
min(
len(self._items) - 1,
self._scroll_pos + self.getmaxyx()[0] * 2 - 1,
)
)
def scroll_to_item(self, y: int) -> None:
if not self._items or not self._win:
return
if y < 0:
y %= len(self._items)
h = self.getmaxyx()[0]
delta = 1 if self._scroll_pos < y else -1
while not self._scroll_pos <= y < self._scroll_pos + h:
self._scroll_pos += delta
def stop(self) -> None:
super().stop()
del self._pad
self._pad = None
def __len__(self) -> int:
return len(self._items)
def render(self) -> None:
if not self._pad or not self._win:
return
if self._items:
self._pad.resize(
max(1, len(self._items)), max(1, self.getmaxyx()[1] + 1)
)
else:
self._pad.resize(1, 1)
self._win.erase()
self._win.noutrefresh()
self._pad.erase()
h, w = self.getmaxyx()
y, x = self.getbegyx()
try:
if self._scroll_pos > 0 or self._scroll_pos + h < len(self._items):
y1 = self._scroll_pos
y2 = self._scroll_pos + h
thumb_y1 = int(y1 * h // len(self._items))
thumb_y2 = int(y2 * h // len(self._items))
attr_thumb = (
curses.color_pair(COLOR_SCROLLBAR_THUMB)
if has_colors()
else curses.A_REVERSE
)
attr_track = (
curses.color_pair(COLOR_SCROLLBAR_TRACK)
if has_colors()
else curses.A_NORMAL
)
for win_y in range(h):
self._win.chgat(
win_y,
w - 1,
attr_thumb
if thumb_y1 <= win_y <= thumb_y2
else attr_track,
)
self._win.noutrefresh()
self._render_impl(h, w - 1)
self._pad.noutrefresh(
self._scroll_pos, 0, y, x, y + h - 1, x + w - 2
)
else:
self._render_impl(h, w)
self._pad.noutrefresh(
self._scroll_pos, 0, y, x, y + h - 1, x + w - 1
)
except curses.error:
pass
def _render_impl(self, h: int, w: int) -> None:
raise NotImplementedError("not implemented")
-31
View File
@@ -1,31 +0,0 @@
import typing as T
import urwid
_MAGIC = 999 # where to display the cursor (high value = don't display it)
class CustomButton(urwid.Button):
def __init__(
self,
label: str,
*args: T.Any,
hint: T.Optional[str] = None,
**kwargs: T.Any,
) -> None:
super().__init__("", *args, **kwargs)
self._w = urwid.AttrMap(
urwid.Columns(
[
("pack", urwid.Text("< ")),
urwid.SelectableIcon([label], _MAGIC),
(
"pack",
urwid.Text(f"[{hint}]" if hint else "", align="right"),
),
("pack", urwid.Text(" >")),
]
),
"button",
"button-focus",
)
-18
View File
@@ -1,18 +0,0 @@
import typing as T
import urwid
class CustomLineBox(urwid.AttrMap):
def __init__(
self, widget: urwid.Widget, *args: T.Any, **kwargs: T.Any
) -> None:
super().__init__(
urwid.LineBox(
urwid.AttrMap(widget, "linebox-content", "linebox-content"),
*args,
**kwargs,
),
"linebox",
"linebox-focus",
)
-14
View File
@@ -1,14 +0,0 @@
import typing as T
import urwid
class CustomListWalker(urwid.SimpleFocusListWalker):
def append(self, item: T.Any) -> None:
super().append(item)
self.set_focus(len(self) - 1)
class CustomListBox(urwid.ListBox):
def __init__(self, widgets: T.List[urwid.Widget]) -> None:
super().__init__(CustomListWalker(widgets))
-68
View File
@@ -1,68 +0,0 @@
import typing as T
import urwid
# workaround for https://github.com/urwid/urwid/issues/317
class CustomProgressBar(urwid.Widget):
_sizing = frozenset(["flow"])
text_align = "center"
eighths = u" ▏▎▍▌▋▊▉"
def __init__(self) -> None:
self.position = 0.0
self.max_ = 1.0
self.normal = "progressbar-normal"
self.complete = "progressbar-done"
self.smooth = "progressbar-smooth"
def set_completion(self, value: float) -> None:
self.position = value
self._invalidate()
def set_max(self, value: T.Union[int, float]) -> None:
self.max_ = value
self._invalidate()
def rows(self, size: T.Tuple[int, int], focus: bool = False) -> int:
return 1
def get_text(self) -> str:
percent = min(100, max(0, int(self.position * 100 / self.max_)))
return str(percent) + " %"
def render(self, size: T.Tuple[int, int], focus: bool = False) -> T.Any:
(maxcol,) = size
txt = urwid.Text(self.get_text(), self.text_align, "clip")
c = txt.render((maxcol,))
t = c._text[0]
cf = self.position * maxcol / self.max_
ccol_dirty = int(cf)
ccol = len(
c._text[0][:ccol_dirty].decode("utf-8", "ignore").encode("utf-8")
)
cs = 0
cs = int((cf - ccol) * 8)
if ccol < 0 or (ccol == 0 and cs == 0):
c._attr = [[(self.normal, maxcol)]]
elif ccol >= maxcol:
c._attr = [[(self.complete, maxcol)]]
elif cs and c._text[0][ccol] == 32:
t = c._text[0]
cenc = self.eighths[cs].encode("utf-8")
c._text[0] = t[:ccol] + cenc + t[ccol + 1 :]
a = []
if ccol > 0:
a.append((self.complete, ccol))
a.append((self.smooth, len(cenc)))
if maxcol - ccol - 1 > 0:
a.append((self.normal, maxcol - ccol - 1))
c._attr = [a]
c._cs = [[(None, len(c._text[0]))]]
else:
c._attr = [[(self.complete, ccol), (self.normal, maxcol - ccol)]]
return c
-32
View File
@@ -1,32 +0,0 @@
import urwid
class DoubleLineBox(urwid.AttrMap):
def __init__(
self,
top_widget: urwid.Widget,
bottom_widget: urwid.Widget,
top_title: str = "",
bottom_title: str = "",
) -> None:
self.top_line_box = urwid.LineBox(
urwid.AttrMap(top_widget, "linebox-content", "linebox-content"),
title=top_title,
bline="",
)
self.bottom_line_box = urwid.LineBox(
urwid.AttrMap(bottom_widget, "linebox-content", "linebox-content"),
title=bottom_title,
tlcorner="├",
trcorner="┤",
)
super().__init__(
urwid.Pile([self.top_line_box, ("pack", self.bottom_line_box)]),
"linebox",
"linebox-focus",
)
def set_bottom_title(self, text: str) -> None:
self.bottom_line_box.set_title(text)
-463
View File
@@ -1,463 +0,0 @@
import argparse
import contextlib
import datetime
import typing as T
import urwid
from pqcli.config import EquipmentType, StatType
from pqcli.lingo import act_name, to_roman
from pqcli.mechanic import (
InventoryItem,
Player,
SignalMixin,
Simulation,
Spell,
)
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.custom_list_box import CustomListBox
from pqcli.ui.custom_progress_bar import CustomProgressBar
from pqcli.ui.double_line_box import DoubleLineBox
from pqcli.ui.read_only_check_box import ReadOnlyCheckBox
class CharacterSheetView(DoubleLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.level_text = urwid.Text("")
self.stat_texts = {stat: urwid.Text("") for stat in list(StatType)}
self.exp_bar = CustomProgressBar()
self.list_box = CustomListBox(
[
urwid.Columns([urwid.Text("Name"), urwid.Text(player.name)]),
urwid.Columns(
[urwid.Text("Race"), urwid.Text(player.race.name)]
),
urwid.Columns(
[urwid.Text("Class"), urwid.Text(player.class_.name)]
),
urwid.Columns([urwid.Text("Level"), self.level_text]),
urwid.Divider(),
]
+ [
urwid.Columns([urwid.Text(stat.value), self.stat_texts[stat]])
for stat in StatType
]
)
self.player.connect("level_up", self.sync_level)
self.player.stats.connect("change", self.sync_stats)
self.player.exp_bar.connect("change", self.sync_exp)
super().__init__(
top_widget=self.list_box,
bottom_widget=self.exp_bar,
top_title="Character Sheet",
bottom_title="Experience",
)
self.sync()
def sync(self) -> None:
self.sync_level()
self.sync_stats()
self.sync_exp()
def sync_level(self) -> None:
self.level_text.set_text(str(self.player.level))
def sync_stats(self) -> None:
for stat in StatType:
self.stat_texts[stat].set_text(str(self.player.stats[stat]))
def sync_exp(self) -> None:
cur = self.player.exp_bar.position
max_ = self.player.exp_bar.max_
self.exp_bar.set_completion(cur)
self.exp_bar.set_max(max_)
self.set_bottom_title(f"Experience ({max_-cur:.0f} XP to go)")
class SpellBookView(CustomLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.list_box = CustomListBox([])
self.player.spell_book.connect("add", self.sync_spell_add)
self.player.spell_book.connect("change", self.sync_spell_change)
super().__init__(self.list_box, title="Spell Book")
self.sync()
def sync(self) -> None:
del self.list_box.body[:]
for spell in self.player.spell_book:
self.sync_spell_add(spell)
def sync_spell_add(self, spell: Spell) -> None:
self.list_box.body.append(
urwid.Columns(
[
("weight", 3, urwid.Text(spell.name)),
urwid.Text(to_roman(spell.level)),
]
)
)
def sync_spell_change(self, spell: Spell) -> None:
for widget in self.list_box.body:
if widget.contents[0][0].text == spell.name:
widget.contents[1][0].set_text(to_roman(spell.level))
class EquipmentView(CustomLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.value_texts = {
equipment_type: urwid.Text("") for equipment_type in EquipmentType
}
self.list_box = CustomListBox(
[
urwid.Columns(
[
urwid.Text(equipment_type.value),
("weight", 3, self.value_texts[equipment_type]),
]
)
for equipment_type in EquipmentType
]
)
self.player.equipment.connect("change", self.sync_equipment_change)
super().__init__(self.list_box, title="Equipment")
self.sync()
def sync(self) -> None:
for equipment_type in EquipmentType:
self.sync_equipment_change(
equipment_type, self.player.equipment[equipment_type]
)
def sync_equipment_change(
self, equipment_type: EquipmentType, item_name: T.Optional[str]
) -> None:
self.value_texts[equipment_type].set_text(item_name or "")
class InventoryView(DoubleLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.gold_text = urwid.Text("")
self.list_box = CustomListBox(
[
urwid.Columns(
[("weight", 3, urwid.Text("Gold")), self.gold_text]
)
]
)
self.encumbrance_bar = CustomProgressBar()
self.player.connect("new_task", self.on_new_task)
self.player.inventory.connect("gold_change", self.sync_gold)
self.player.inventory.connect("item_add", self.sync_item_add)
self.player.inventory.connect("item_del", self.sync_item_del)
self.player.inventory.connect("item_change", self.sync_item_change)
self.player.inventory.encum_bar.connect(
"change", self.sync_encumbrance_position
)
super().__init__(
top_widget=self.list_box,
top_title="Inventory",
bottom_widget=self.encumbrance_bar,
bottom_title="Encumbrance",
)
self.sync()
def sync(self) -> None:
self.sync_gold()
self.sync_items()
self.sync_encumbrance_position()
def sync_gold(self) -> None:
self.gold_text.set_text(str(self.player.inventory.gold))
def sync_items(self) -> None:
del self.list_box.body[1:]
for item in self.player.inventory:
self.sync_item_add(item)
def sync_encumbrance_position(self) -> None:
cur = self.player.inventory.encum_bar.position
max_ = self.player.inventory.encum_bar.max_
self.encumbrance_bar.set_completion(cur)
self.encumbrance_bar.set_max(max_)
self.set_bottom_title(f"Encumbrance ({cur:.0f}/{max_} cubits)")
def sync_item_add(self, item: InventoryItem) -> None:
self.list_box.body.append(
urwid.Columns(
[
("weight", 3, urwid.Text(item.name)),
urwid.Text(str(item.quantity)),
]
)
)
def sync_item_del(self, item: InventoryItem) -> None:
idx = self.get_item_idx_by_name(item.name)
if idx is not None:
del self.list_box.body[idx]
def sync_item_change(self, item: InventoryItem) -> None:
idx = self.get_item_idx_by_name(item.name)
if idx is not None:
self.list_box.body[idx].contents[1][0].set_text(str(item.quantity))
def get_item_idx_by_name(self, name: str) -> T.Optional[int]:
for i, column_widget in enumerate(self.list_box.body):
if column_widget.contents[0][0].text == name:
return i
return None
def on_new_task(self) -> None:
description = self.player.task.description if self.player.task else "?"
if description.lower().startswith("sell"):
self.list_box.set_focus(0)
class PlotView(DoubleLineBox):
cutoff = 100
def __init__(self, player: Player) -> None:
self.player = player
self.list_box = CustomListBox([])
self.plot_bar = CustomProgressBar()
self.player.quest_book.connect("complete_act", self.sync_act_add)
self.player.quest_book.plot_bar.connect("change", self.sync_position)
super().__init__(
top_widget=self.list_box,
top_title="Plot Development",
bottom_widget=self.plot_bar,
)
self.sync()
def sync(self) -> None:
self.sync_acts()
self.sync_position()
def sync_acts(self) -> None:
del self.list_box.body[:]
for act_number in range(
max(0, self.player.quest_book.act - self.cutoff),
self.player.quest_book.act,
):
self.list_box.body.append(
ReadOnlyCheckBox(act_name(act_number), state=True)
)
self.list_box.body.append(
ReadOnlyCheckBox(act_name(self.player.quest_book.act), state=False)
)
def sync_act_add(self) -> None:
del self.list_box.body[: -self.cutoff]
if self.list_box.body:
self.list_box.body[-1].set_state(True)
self.list_box.body.append(
ReadOnlyCheckBox(act_name(self.player.quest_book.act), state=False)
)
def sync_position(self) -> None:
self.plot_bar.set_completion(self.player.quest_book.plot_bar.position)
self.plot_bar.set_max(self.player.quest_book.plot_bar.max_)
class QuestBookView(DoubleLineBox):
cutoff = 100
def __init__(self, player: Player) -> None:
self.player = player
self.list_box = CustomListBox([])
self.quest_bar = CustomProgressBar()
self.player.quest_book.connect("complete_quest", self.sync_quest_add)
self.player.quest_book.quest_bar.connect("change", self.sync_position)
super().__init__(
top_widget=self.list_box,
top_title="Quests",
bottom_widget=self.quest_bar,
)
self.sync()
def sync(self) -> None:
self.sync_quests()
self.sync_position()
def sync_quests(self) -> None:
del self.list_box.body[:]
for quest_name in self.player.quest_book.quests[-self.cutoff :]:
self.list_box.body.append(ReadOnlyCheckBox(quest_name, state=True))
if self.list_box.body:
self.list_box.body[-1].set_state(False)
def sync_quest_add(self) -> None:
del self.list_box.body[: -self.cutoff]
if self.list_box.body:
self.list_box.body[-1].set_state(True)
self.list_box.body.append(
ReadOnlyCheckBox(self.player.quest_book.quests[-1], state=False)
)
def sync_position(self) -> None:
self.quest_bar.set_completion(
self.player.quest_book.quest_bar.position
)
self.quest_bar.set_max(self.player.quest_book.quest_bar.max_)
class TaskView(urwid.Pile):
def __init__(self, player: Player) -> None:
self.player = player
self.current_task_text = urwid.Text("...")
self.current_task_bar = CustomProgressBar()
self.player.connect("new_task", self.sync_task_name)
self.player.task_bar.connect("change", self.sync_position)
super().__init__([self.current_task_text, self.current_task_bar])
self.sync()
def sync(self) -> None:
self.sync_task_name()
self.sync_position()
def sync_task_name(self) -> None:
self.current_task_text.set_text(
f"{self.player.task.description}..." if self.player.task else "?"
)
def sync_position(self) -> None:
self.current_task_bar.set_completion(self.player.task_bar.position)
self.current_task_bar.set_max(self.player.task_bar.max_)
def pack(self, size: T.Tuple[int, int]) -> T.Tuple[int, int]:
return (size[0], 2)
class GameView(urwid.Pile):
signals = ["cancel"]
def __init__(
self, loop: urwid.MainLoop, player: Player, args: argparse.Namespace
) -> None:
self.loop = loop
self.player = player
self.args = args
self.simulation = Simulation(player)
self.last_tick = datetime.datetime.now()
self.character_sheet_view = CharacterSheetView(player)
self.spell_book_view = SpellBookView(player)
self.equipment_view = EquipmentView(player)
self.inventory_view = InventoryView(player)
self.plot_view = PlotView(player)
self.quest_book_view = QuestBookView(player)
self.task_view = TaskView(player)
super().__init__(
[
urwid.Columns(
[
(
"weight",
1,
urwid.Pile(
[
(19, self.character_sheet_view),
self.spell_book_view,
]
),
),
(
"weight",
2,
urwid.Pile(
[
(15, self.equipment_view),
self.inventory_view,
]
),
),
(
"weight",
2,
urwid.Pile(
[(15, self.plot_view), self.quest_book_view]
),
),
]
),
("pack", self.task_view),
]
)
def cancel(self) -> None:
self._emit("cancel")
def tick(self) -> None:
now = datetime.datetime.now()
elapsed = (now - self.last_tick).total_seconds()
self.simulation.tick(elapsed * 1000)
self.last_tick = datetime.datetime.now()
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self.cancel()
return True
if self.args.cheats:
if key == "e":
self.simulation.player.exp_bar.increment(1)
return True
fast_forward = {"t": 1, "T": 100, "ctrl t": 10000}
if key in fast_forward:
iterations = fast_forward[key]
ctx_mgr = (
self.suppress_updates()
if iterations >= 1000
else contextlib.suppress()
)
with ctx_mgr:
for _ in range(iterations):
self.simulation.tick()
return True
return False
@contextlib.contextmanager
def suppress_updates(self) -> T.Generator:
old = SignalMixin.emit
SignalMixin.emit = lambda *_: None
yield
self.character_sheet_view.sync()
self.spell_book_view.sync()
self.equipment_view.sync()
self.inventory_view.sync()
self.plot_view.sync()
self.quest_book_view.sync()
self.task_view.sync()
SignalMixin.emit = old
-221
View File
@@ -1,221 +0,0 @@
import random
import typing as T
import urwid
import urwid_readline
from pqcli.config import CLASSES, PRIME_STATS, RACES
from pqcli.mechanic import StatsBuilder, create_player, generate_name
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
class StatsBox(CustomLineBox):
def __init__(self) -> None:
self.stats_builder = StatsBuilder()
self.stats = self.stats_builder.roll()
self.stat_labels = {stat: urwid.Text("0") for stat in PRIME_STATS}
self.total_label = urwid.Text("0")
self.update_values()
roll_button = CustomButton(
"Roll", hint="F5", on_press=self.on_roll_press
)
unroll_button = CustomButton(
"Unroll", hint="F6", on_press=self.on_unroll_press
)
value_texts = list(self.stat_labels.values())
label_texts = [urwid.Text(f"{stat.value}: ") for stat in PRIME_STATS]
super().__init__(
urwid.ListBox(
[
urwid.Columns(
[urwid.Pile(label_texts), urwid.Pile(value_texts)]
),
urwid.Divider(),
urwid.Columns([urwid.Text("Total: "), self.total_label]),
urwid.Divider(),
roll_button,
unroll_button,
]
),
title="Stats",
)
def roll(self) -> None:
self.stats = self.stats_builder.roll()
self.update_values()
def unroll(self) -> None:
try:
self.stats = self.stats_builder.unroll()
except IndexError:
return
self.update_values()
def on_roll_press(self, _user_data: T.Any) -> None:
self.roll()
def on_unroll_press(self, _user_data: T.Any) -> None:
self.unroll()
def update_values(self) -> None:
for stat, edit in self.stat_labels.items():
edit.set_text(str(self.stats[stat]))
self.total_label.set_text(
str(sum(self.stats[stat] for stat in self.stat_labels.keys()))
)
class RaceBox(CustomLineBox):
def __init__(self) -> None:
self.race = random.choice(RACES)
radio_buttons: T.List[urwid.RadioButton] = []
for race in RACES:
urwid.RadioButton(
group=radio_buttons,
label=race.name,
state=race.name == self.race.name,
on_state_change=self.on_state_change,
user_data=race,
)
super().__init__(urwid.ListBox(radio_buttons), title="Race")
def on_state_change(
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
) -> None:
if new_state:
self.race = user_data
class ClassBox(CustomLineBox):
def __init__(self) -> None:
self.class_ = random.choice(CLASSES)
radio_buttons: T.List[urwid.RadioButton] = []
for class_ in CLASSES:
urwid.RadioButton(
group=radio_buttons,
label=class_.name,
state=class_.name == self.class_.name,
on_state_change=self.on_state_change,
user_data=class_,
)
super().__init__(urwid.ListBox(radio_buttons), title="Class")
def on_state_change(
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
) -> None:
if new_state:
self.class_ = user_data
class NewGameView(urwid.Pile):
signals = ["confirm", "cancel"]
def __init__(self) -> None:
self.race_box = RaceBox()
self.class_box = ClassBox()
self.stats_box = StatsBox()
self.char_name_edit = urwid_readline.ReadlineEdit(
"Name: ", generate_name()
)
generate_char_name_btn = CustomButton(
"Generate random name",
hint="F7",
on_press=self.on_generate_char_name_press,
)
buttons_box = urwid.Filler(
urwid.Padding(
urwid.Pile(
[
CustomButton(
"Sold!", hint="F10", on_press=self.on_confirm_press
),
CustomButton(
"Cancel", hint="Esc", on_press=self.on_cancel_press
),
]
),
width=20,
align="center",
)
)
super().__init__(
[
(1, urwid.Filler(self.char_name_edit)),
(
1,
urwid.Filler(
urwid.Padding(
generate_char_name_btn,
left=len(self.char_name_edit.caption),
width=25,
)
),
),
urwid.Columns(
[
("weight", 2, self.race_box),
("weight", 2, self.class_box),
urwid.Pile([(13, self.stats_box), buttons_box]),
]
),
]
)
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 confirm(self) -> None:
player = create_player(
name=self.char_name_edit.edit_text,
race=self.race_box.race,
class_=self.class_box.class_,
stats=self.stats_box.stats,
)
self._emit("confirm", player)
def cancel(self) -> None:
self._emit("cancel")
def on_generate_char_name_press(self, _user_data: T.Any) -> None:
self.generate_random_char_name()
def on_confirm_press(self, _user_data: T.Any) -> None:
self.confirm()
def on_cancel_press(self, _user_data: T.Any) -> None:
self.cancel()
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self.cancel()
return True
if key == "f7":
self.generate_random_char_name()
return True
if key == "f5":
self.stats_box.roll()
return True
if key == "f6":
self.stats_box.unroll()
return True
if key == "f10":
self.confirm()
return True
return False
-16
View File
@@ -1,16 +0,0 @@
import urwid
class ReadOnlyCheckBox(urwid.Text):
def __init__(self, text: str, state: bool = False) -> None:
self.icons = {
key: widget.text for key, widget in urwid.CheckBox.states.items()
}
self._caption = text
super().__init__("")
self.set_state(state)
def set_state(self, state: bool) -> None:
self.set_text(self.icons[state] + " " + self._caption)
-116
View File
@@ -1,116 +0,0 @@
import typing as T
import urwid
from pqcli.lingo import act_name, to_roman
from pqcli.roster import Roster
from pqcli.ui.custom_button import CustomButton
class RosterView(urwid.Filler):
signals = ["load_game", "new_game", "delete_game", "exit"]
def __init__(self, roster: Roster) -> None:
self.roster = roster
logo = urwid.BigText("ProgressQuest", urwid.HalfBlock5x4Font())
buttons = []
for player_idx, player in enumerate(self.roster.players):
best_equip = player.equipment.best
best_spell = player.spell_book.best
if best_spell:
best_spell_name = (
best_spell.name + " " + to_roman(best_spell.level)
)
else:
best_spell_name = "-"
best_stat_name = (
f"{player.stats.best.value} {player.stats[player.stats.best]}"
)
label = (
f"{player.name} the {player.race.name} "
f"({act_name(player.quest_book.act)})\n"
f"Level {player.level} {player.class_.name}\n"
f"{best_equip} / {best_spell_name} / {best_stat_name}"
)
buttons.append(
urwid.Columns(
[
CustomButton(
label=label,
on_press=self.on_resume_game_press,
user_data=player_idx,
),
(
10,
CustomButton(
label="Delete",
on_press=self.on_delete_game_press,
user_data=player_idx,
),
),
]
)
)
buttons.append(urwid.Divider())
buttons.append(
CustomButton(
label="Create a new character",
hint="F1",
on_press=self.on_new_game_press,
)
)
buttons.append(
CustomButton(label="Exit", hint="Esc", on_press=self.on_exit_press)
)
super().__init__(
urwid.Padding(
urwid.Pile(
[urwid.Padding(logo, width="clip"), urwid.Divider()]
+ buttons
+ [
urwid.Divider(),
urwid.Text("Use arrow keys to move around."),
]
),
align="center",
width=logo.pack()[0],
)
)
def on_resume_game_press(
self, _widget: urwid.Widget, user_data: T.Any
) -> None:
player_idx = T.cast(int, user_data)
self._emit("load_game", player_idx)
def on_delete_game_press(
self, _widget: urwid.Widget, user_data: T.Any
) -> None:
player_idx = T.cast(int, user_data)
self._emit("delete_game", player_idx)
def on_new_game_press(self, _user_data: T.Any) -> None:
self._emit("new_game")
def on_exit_press(self, _user_data: T.Any) -> None:
self._emit("exit")
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self._emit("exit")
return True
if key == "f1":
self._emit("new_game")
return True
return False
+8
View File
@@ -0,0 +1,8 @@
[tool.black]
line-length = 79
py36 = true
[tool.isort]
known_first_party = "pqcli"
multi_line_output = 3
include_trailing_comma = true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+8 -4
View File
@@ -1,17 +1,21 @@
from setuptools import find_packages, setup from setuptools import find_packages, setup
setup( setup(
author="rr-", author="rr-",
author_email="rr-@sakuya.pl", author_email="rr-@sakuya.pl",
name="pqcli", name="pqcli",
long_description="ProgressQuest: the CLI edition", long_description="Progress Quest: the CLI edition",
version="0.1", version="1.0.2",
url="https://github.com/rr-/pq-cli.git", url="https://github.com/rr-/pq-cli.git",
packages=find_packages(), packages=find_packages(),
entry_points={"console_scripts": ["pqcli = pqcli.__main__:main"]}, entry_points={"console_scripts": ["pqcli = pqcli.__main__:main"]},
package_dir={"pqcli": "pqcli"}, package_dir={"pqcli": "pqcli"},
install_requires=["xdg", "urwid", "urwid_readline"], install_requires=[
"xdg",
"urwid",
"urwid_readline",
'windows-curses;platform_system=="Windows"',
],
classifiers=[ classifiers=[
"Environment :: Console", "Environment :: Console",
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",