From 9a90b7b7b47983e9c718ebe5d91ba109c61f259f Mon Sep 17 00:00:00 2001 From: rr- Date: Fri, 4 Jan 2019 19:29:43 +0100 Subject: [PATCH] ui/curses: add character creation wizard --- pqcli/ui/curses/__init__.py | 150 +++++++++----- pqcli/ui/curses/views/__init__.py | 7 +- .../ui/curses/views/choose_character_view.py | 43 +--- .../ui/curses/views/create_character_view.py | 192 +++++++++++++++++- pqcli/ui/curses/views/menu_view.py | 38 ++++ pqcli/ui/curses/widgets/menu.py | 44 ++-- 6 files changed, 361 insertions(+), 113 deletions(-) create mode 100644 pqcli/ui/curses/views/menu_view.py diff --git a/pqcli/ui/curses/__init__.py b/pqcli/ui/curses/__init__.py index 4287621..80d59e6 100644 --- a/pqcli/ui/curses/__init__.py +++ b/pqcli/ui/curses/__init__.py @@ -6,13 +6,17 @@ import time import typing as T from pqcli import lingo -from pqcli.mechanic import Player +from pqcli.config import Class, Race +from pqcli.mechanic import Player, Stats, create_player from pqcli.roster import Roster from pqcli.ui.curses.views import ( BaseView, + ChooseCharacterClassView, + ChooseCharacterNameView, + ChooseCharacterRaceView, + ChooseCharacterStatsView, ChooseCharacterView, ConfirmView, - CreateCharacterView, GameView, RosterView, ) @@ -28,46 +32,45 @@ 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) + self._screen = curses.initscr() + self._screen.nodelay(True) + self._screen.keypad(True) curses.noecho() curses.curs_set(0) def signal_handler(sig, frame): - print("Quitting") 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_character_view - self.roster_view.on_delete += self.switch_to_delete_character_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() + 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() + self._switch_to_roster_view() while True: - key = T.cast(int, self.screen.getch()) + key = T.cast(int, self._screen.getch()) if key == curses.ERR: time.sleep(0.1) - self.view.idle() + self._view.idle() continue if key == curses.KEY_RESIZE: - self.view.stop() - self.view.start() + self._view.stop() + self._view.start() else: try: - self.view.keypress(key) + self._view.keypress(key) except StopMainLoop: break @@ -75,50 +78,87 @@ class CursesUserInterface(BaseUserInterface): curses.endwin() curses.curs_set(1) - def switch_to_roster_view(self) -> None: - self.switch_view(self.roster_view) + def _switch_to_roster_view(self) -> None: + self._switch_view(self._roster_view) - def switch_to_create_character_view(self) -> None: - view = CreateCharacterView(self.screen) - view.on_cancel += self.switch_to_roster_view - view.on_confirm += self.switch_to_game_view - self.switch_view(view) + def _switch_to_create_char_name_view(self) -> None: + view = ChooseCharacterNameView(self._screen) + 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_delete_character_view(self) -> None: - view = ChooseCharacterView( - self.screen, self.roster, "Choose character to delete" + def _switch_to_create_char_race_view(self, player_name: str) -> None: + view = ChooseCharacterRaceView(self._screen) + view.on_cancel += self._switch_to_roster_view + view.on_confirm += lambda race: self._switch_to_create_char_class_view( + player_name, race ) - view.on_cancel += self.switch_to_roster_view - view.on_confirm += self.switch_to_confirm_delete_character_view - self.switch_view(view) + self._switch_view(view) - def switch_to_confirm_delete_character_view(self, player: Player) -> None: - view = ConfirmView(self.screen, lingo.terminate_message(player.name)) - view.on_cancel += self.switch_to_delete_character_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" + def _switch_to_create_char_class_view( + self, player_name: str, race: Race + ) -> None: + view = ChooseCharacterClassView(self._screen) + view.on_cancel += self._switch_to_roster_view + view.on_confirm += lambda class_: self._switch_to_create_char_stats_view( + player_name, race, class_ ) - view.on_cancel += self.switch_to_roster_view - view.on_confirm += self.switch_to_game_view - self.switch_view(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_to_create_char_stats_view( + self, player_name: str, race: Race, class_: Class + ) -> None: + view = ChooseCharacterStatsView(self._screen) + view.on_cancel += self._switch_to_roster_view + view.on_confirm += lambda stats: self._create_character( + player_name, race, class_, stats + ) + self._switch_view(view) - def switch_view(self, view: BaseView) -> None: - self.view.stop() - self.view = view - self.view.start() + 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 delete_character(self, player: Player) -> None: + 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() + self._switch_to_roster_view() - def quit(self) -> None: + def _quit(self) -> None: raise StopMainLoop diff --git a/pqcli/ui/curses/views/__init__.py b/pqcli/ui/curses/views/__init__.py index 4514486..6af44e4 100644 --- a/pqcli/ui/curses/views/__init__.py +++ b/pqcli/ui/curses/views/__init__.py @@ -1,6 +1,11 @@ from .base_view import BaseView from .choose_character_view import ChooseCharacterView from .confirm_view import ConfirmView -from .create_character_view import CreateCharacterView +from .create_character_view import ( + ChooseCharacterClassView, + ChooseCharacterNameView, + ChooseCharacterRaceView, + ChooseCharacterStatsView, +) from .game_view import GameView from .roster_view import RosterView diff --git a/pqcli/ui/curses/views/choose_character_view.py b/pqcli/ui/curses/views/choose_character_view.py index 47ada67..42b718f 100644 --- a/pqcli/ui/curses/views/choose_character_view.py +++ b/pqcli/ui/curses/views/choose_character_view.py @@ -2,55 +2,30 @@ import functools import typing as T from pqcli.roster import Roster - -from ..event_handler import EventHandler -from ..util import Choice -from ..widgets import Menu -from .base_view import BaseView +from pqcli.ui.curses.event_handler import EventHandler +from pqcli.ui.curses.util import Choice +from pqcli.ui.curses.views.menu_view import MenuView +from pqcli.ui.curses.widgets import Menu -class ChooseCharacterView(BaseView): +class ChooseCharacterView(MenuView): def __init__(self, screen: T.Any, roster: Roster, title: str) -> None: - super().__init__(screen) + super().__init__(screen, title) - self.title = f"{title}:" - self.on_cancel = EventHandler() - self.on_confirm = EventHandler() - - self.choices: T.List[Choice] = [] for y, player in enumerate(roster.players, 1): key: T.Optional[str] = str(y % 10) if y <= 10 else None - self.choices.append( + self._choices.append( Choice( keys=[ord(key)] if key is not None else [], desc=f"[{key or '-'}] {player.name}", callback=functools.partial(self.on_confirm, player), ) ) - self.choices.append( + + self._choices.append( Choice( keys=list(map(ord, "qQ\N{ESC}")), desc="[Q] Cancel", 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, - 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() diff --git a/pqcli/ui/curses/views/create_character_view.py b/pqcli/ui/curses/views/create_character_view.py index 36e35a0..40105a9 100644 --- a/pqcli/ui/curses/views/create_character_view.py +++ b/pqcli/ui/curses/views/create_character_view.py @@ -1,16 +1,200 @@ +import curses +import curses.ascii +import functools import typing as T -from ..event_handler import EventHandler -from .base_view import BaseView +from pqcli.config import CLASSES, PRIME_STATS, RACES +from pqcli.mechanic import StatsBuilder, generate_name +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.views.menu_view import MenuView -class CreateCharacterView(BaseView): +class ChooseCharacterNameView(BaseView): def __init__(self, screen: T.Any) -> None: super().__init__(screen) self.on_cancel = EventHandler() self.on_confirm = EventHandler() + self._text = generate_name() + + def start(self) -> None: + self.screen.erase() + self.screen.noutrefresh() + + scr_height, scr_width = self.screen.getmaxyx() + + h = 1 + w = 30 + y = (scr_height - h - 2) // 2 + x = (scr_width - w - 2) // 2 + self._win = curses.newwin(h + 2, w + 2, y, x) + self._win.box() + self.screen.addstr(y - 1, x, "Choose character name:") + self.screen.addstr(y + h + 2, x, "[ F5 ] generate random name") + self.screen.addstr(y + h + 3, x, "[ Esc ] cancel") + self.screen.addstr(y + h + 4, x, "[Enter] confirm") + self._win.refresh() + self._render() + + def stop(self) -> None: + del self._win + self._win = None + def keypress(self, key: int) -> None: - if key in map(ord, "qQ\N{ESC}"): + if key == curses.ascii.ESC: self.on_cancel() + + elif key == curses.KEY_F5: + self._text = generate_name() + + 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._text = self._text.strip() + if self._text: + self.on_confirm(self._text) + else: + self.on_cancel() + + elif key == curses.ascii.ETB: # ^w + self._text = "" + + self._render() + + def _render(self) -> None: + if not self._win: + return + + self._win.erase() + self._win.box() + self._win.addnstr(1, 1, self._text, self._win.getmaxyx()[1] - 2) + self._win.refresh() + + +class ChooseCharacterRaceView(MenuView): + def __init__(self, screen: T.Any) -> None: + super().__init__(screen, "Choose character race") + + 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(map(ord, "qQ\N{ESC}")), + desc="[Q] Cancel", + callback=self.on_cancel, + ) + ) + + +class ChooseCharacterClassView(MenuView): + def __init__(self, screen: T.Any) -> None: + super().__init__(screen, "Choose character class") + + 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(map(ord, "qQ\N{ESC}")), + desc="[Q] Cancel", + callback=self.on_cancel, + ) + ) + + +class ChooseCharacterStatsView(BaseView): + def __init__(self, screen: T.Any) -> None: + super().__init__(screen) + + self.on_cancel = EventHandler() + self.on_confirm = EventHandler() + + 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 = 5 + 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 + 1, x) + 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 == curses.ascii.ESC or key in map(ord, "qQ"): + self.on_cancel() + + elif key == curses.ascii.NL: + self.on_confirm(self._stats) + + elif key == curses.KEY_F5: + self._stats = self._stats_builder.roll() + + elif key == curses.KEY_F6: + try: + self._stats = self._stats_builder.unroll() + except IndexError: + pass + + self._render() + + def _render(self) -> None: + if not self._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:") + self._win.addstr(1 + stats_h + 0, 0, "[ F5 ] roll") + self._win.addstr(1 + stats_h + 1, 0, "[ F6 ] unroll") + self._win.addstr(1 + stats_h + 2, 0, "[ Esc ] cancel") + self._win.addstr(1 + stats_h + 3, 0, "[Enter] confirm") + 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() diff --git a/pqcli/ui/curses/views/menu_view.py b/pqcli/ui/curses/views/menu_view.py new file mode 100644 index 0000000..0e9b605 --- /dev/null +++ b/pqcli/ui/curses/views/menu_view.py @@ -0,0 +1,38 @@ +import functools +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) -> None: + super().__init__(screen) + + self.on_cancel = EventHandler() + self.on_confirm = EventHandler() + + self._title = f"{title}:" + self._choices: T.List[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, + 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() diff --git a/pqcli/ui/curses/widgets/menu.py b/pqcli/ui/curses/widgets/menu.py index e4bb1be..ede0b88 100644 --- a/pqcli/ui/curses/widgets/menu.py +++ b/pqcli/ui/curses/widgets/menu.py @@ -15,20 +15,20 @@ class Menu(Widget): 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 + self._header_lines = header.split("\n") + self._choices = choices + self._scr_width = scr_width + self._scr_height = scr_height - w = max(map(len, self.header_lines)) - h = len(self.header_lines) + 1 + len(choices) + w = max(map(len, self._header_lines)) + h = len(self._header_lines) + 1 + len(choices) self._pad: T.Optional[T.Any] = curses.newpad(h, w) - for y, line in enumerate(self.header_lines): + for y, line in enumerate(self._header_lines): self._pad.move(y, 0) self._pad.addstr(line) - self.active_choice = 0 + self._active_choice = 0 def stop(self) -> None: del self._pad @@ -40,17 +40,23 @@ class Menu(Widget): return self._pad.getmaxyx() def next(self) -> None: - self.active_choice = min(len(self.choices) - 1, self.active_choice + 1) + 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) + self._active_choice = max(0, self._active_choice - 1) def keypress(self, key: int) -> None: - for choice in self.choices: + for choice in self._choices: if key in choice.keys: choice.callback() return + if key == curses.ascii.NL: + self._choices[self._active_choice].callback() + return + if key in KEYS_DOWN: self.next() return @@ -63,19 +69,19 @@ class Menu(Widget): if not self._pad: return - for y, choice in enumerate(self.choices): - self._pad.move(len(self.header_lines) + 1 + y, 0) - if y == self.active_choice: + for y, choice in enumerate(self._choices): + self._pad.move(len(self._header_lines) + 1 + y, 0) + if y == self._active_choice: self._pad.standout() self._pad.addstr(choice.desc) - if y == self.active_choice: + if y == self._active_choice: self._pad.standend() 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, + (self._scr_height - self._pad.getmaxyx()[0]) // 2, + (self._scr_width - self._pad.getmaxyx()[1]) // 2, + self._scr_height - 1, + self._scr_width - 1, )