From 2357757b23d288f96d4e8ef2540c7eee72794118 Mon Sep 17 00:00:00 2001 From: Alek Ratzloff Date: Sun, 28 Nov 2021 17:27:05 -0800 Subject: [PATCH] Add dialog actions Rudimentary user input for dialogs has been added. * Displays must add support for showing dialog options, and receiving user input for dialog selections. * A selected dialog can optionally perform actions, and then directs the game into the next dialog state. * Dialog options may be conditionally shown with variables. Signed-off-by: Alek Ratzloff --- agame/action.py | 33 +++++++++++++++++++++++++++- agame/dialog.py | 40 ++++++++++++++++++++++++++++++++++ agame/display/__init__.py | 33 ++++++++++++++++++++++++++-- agame/display/display.py | 35 ++++++++++++++++++++++++++++-- agame/display/unix.py | 45 ++++++++++++++++++++++++++++++++------- 5 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 agame/dialog.py diff --git a/agame/action.py b/agame/action.py index 704ecdd..3c12920 100644 --- a/agame/action.py +++ b/agame/action.py @@ -1,10 +1,11 @@ import dataclasses import time -from typing import Any, Optional, Sequence, Union, TYPE_CHECKING +from typing import Any, Mapping, Optional, Sequence, Union, TYPE_CHECKING import enum if TYPE_CHECKING: from agame.game import Game + from agame.dialog import DialogOption __all__ = ( @@ -24,6 +25,7 @@ __all__ = ( "SetVarAction", "RevealItemAction", "UnrevealItemAction", + "DialogAction", ) @@ -441,3 +443,32 @@ class UnrevealItemAction(Action): self.item_id in game.room.items ), f"item id {self.item_id} does not exist in the current room {game.room.id}" game.room.items[self.item_id].revealed = False + + +@dataclasses.dataclass +class DialogAction(Action): + """ + Displays a new dialog. + """ + + dialog: Mapping[str, Sequence["DialogOption"]] + start: str + + def act(self, game: "Game"): + selection_id: Optional[str] = self.start + + while selection_id: + # Figure out which options should appear + options = [ + opt + for opt in self.dialog[selection_id] + # this is a logical implication + # required_var -> (game.vars[opt.required_var]) + if not opt.required_var + or (opt.required_var and game.vars[opt.required_var]) + ] + # Get the selection + selection = game.display.dialog_options(options) + selection_id = selection.next + # Do dialog actions + game.do_actions(selection.actions) diff --git a/agame/dialog.py b/agame/dialog.py new file mode 100644 index 0000000..f8e9e64 --- /dev/null +++ b/agame/dialog.py @@ -0,0 +1,40 @@ +import dataclasses +from typing import Mapping, Optional, Sequence, TYPE_CHECKING + +if TYPE_CHECKING: + from agame.action import Action + + +@dataclasses.dataclass +class DialogOption: + # The text for this dialog option. + text: str + # The actions that get executed upon choosing this dialog option. + actions: Sequence["Action"] = dataclasses.field(default_factory=list) + # The variable required for being able to display this dialog option. + # + # This variable must not be equal to `False`. + required_var: Optional[str] = None + # The next dialog that is chosen. + next: Optional[str] = None + + +{ + "main": { + "type": "reply", + "options": [ + { + "text": "What happened here?", + "next": "main", + "actions": [], + }, + { + "text": "Bye.", + }, + ], + }, + "bye": { + "type": "print", + "text": "Mhm.", + }, +} diff --git a/agame/display/__init__.py b/agame/display/__init__.py index ef5cad0..faec6b5 100644 --- a/agame/display/__init__.py +++ b/agame/display/__init__.py @@ -1,4 +1,7 @@ import platform +from typing import Optional, Sequence, Tuple +from agame.action import Action +from agame.dialog import DialogOption from .display import Display if platform.system() in ("Linux", "Darwin", "Unix"): @@ -14,5 +17,31 @@ class BasicDisplay(Display): def line(self, line: str = ""): print(line) - def input(self) -> str: - return input("> ") + def input(self, prompt: Optional[str] = None) -> str: + if prompt is None: + prompt = "> " + return input(prompt) + + def wait_for_ack(self, prompt: Optional[str] = "Press enter to continue..."): + input(prompt or "") + + def dialog_options(self, options: Sequence[DialogOption]) -> DialogOption: + valid_opts = range(1, len(options) + 1) + + def print_opts(): + for i, opt in enumerate(options): + self.line(f"{i}. {opt.text}") + + print_opts() + while True: + got = self.input("? ") + try: + choice = int(got) + except ValueError: + print_opts() + continue + if choice in valid_opts: + break + index = choice - 1 + selection = options[index] + return selection diff --git a/agame/display/display.py b/agame/display/display.py index db8e058..dc23c4a 100644 --- a/agame/display/display.py +++ b/agame/display/display.py @@ -1,5 +1,10 @@ import abc -from typing import Optional +from typing import Optional, Sequence, Tuple, TYPE_CHECKING +from agame.action import Action +from agame.dialog import DialogOption + +if TYPE_CHECKING: + from agame.game import Game class Display(metaclass=abc.ABCMeta): @@ -14,7 +19,7 @@ class Display(metaclass=abc.ABCMeta): "Print a single line to the display." @abc.abstractmethod - def input(self) -> str: + def input(self, prompt: Optional[str] = None) -> str: "Get player input." @abc.abstractmethod @@ -28,5 +33,31 @@ class Display(metaclass=abc.ABCMeta): user. """ + def dialog_options(self, options: Sequence[DialogOption]) -> DialogOption: + """ + Displays, and gets, an answer from a list of dialog options. + + Returns the selected DialogOption.next value and actions. + """ + valid_opts = range(1, len(options) + 1) + + def print_opts(): + for i, opt in enumerate(options): + self.line(f"{i}. {opt.text}") + + print_opts() + while True: + got = self.input("? ") + try: + choice = int(got) + except ValueError: + print_opts() + continue + if choice in valid_opts: + break + index = choice - 1 + selection = options[index] + return selection + def finish(self): "Final cleanup code for this display." diff --git a/agame/display/unix.py b/agame/display/unix.py index 91421e2..2546ff4 100644 --- a/agame/display/unix.py +++ b/agame/display/unix.py @@ -1,9 +1,14 @@ import sys import termios -from typing import Optional, TextIO +from typing import Optional, Sequence, TextIO, Tuple, TYPE_CHECKING from agame.color import colorize +from agame.action import Action +from agame.dialog import DialogOption from .display import Display +if TYPE_CHECKING: + from agame.game import Game + ESC = "\u001b" ANSI_MOVE_BOTTOM = f"{ESC}[999;0f" @@ -45,10 +50,12 @@ class ANSIDisplay(Display): self.stdout.write(line) self.stdout.write("\n") - def input(self) -> str: + def input(self, prompt: Optional[str] = None) -> str: + if prompt is None: + prompt = self.input_prompt try: self.nocbreak() - return input(self.input_prompt) + return input(prompt) finally: self.cbreak() @@ -59,6 +66,33 @@ class ANSIDisplay(Display): self.line(prompt) self.stdin.read(1) + def dialog_options(self, options: Sequence[DialogOption]) -> DialogOption: + valid_opts = range(1, len(options) + 1) + + def print_opts(): + for i, opt in zip(valid_opts, options): + # TODO - format/colorize this so it stands out + self.line(f"{i}. {opt.text}") + + print_opts() + while True: + got = self.input("? ") + try: + choice = int(got) + except ValueError: + print_opts() + continue + if choice in valid_opts: + break + index = choice - 1 + selection = options[index] + return selection + + def finish(self): + # Clean up by restoring the old terminal state + fd = self.stdin.fileno() + termios.tcsetattr(fd, termios.TCSADRAIN, self.__old_attrs) + def cbreak(self): fd = self.stdin.fileno() new = termios.tcgetattr(fd) @@ -71,11 +105,6 @@ class ANSIDisplay(Display): new[3] |= termios.ECHO | termios.ICANON termios.tcsetattr(fd, termios.TCSADRAIN, new) - def finish(self): - # Clean up by restoring the old terminal state - fd = self.stdin.fileno() - termios.tcsetattr(fd, termios.TCSADRAIN, self.__old_attrs) - def clear(self): self.stdout.write(f"{ESC}[2J")