diff --git a/agame/__main__.py b/agame/__main__.py index 3f94116..0b8aa85 100644 --- a/agame/__main__.py +++ b/agame/__main__.py @@ -3,6 +3,8 @@ import importlib.util from pathlib import Path import sys from agame.color import colorize +from agame.display import ANSIDisplay +from agame.game import Game # Parse args @@ -19,25 +21,36 @@ package = args.PACKAGE # Load game module game_module = importlib.import_module(package) -game = game_module.game # type: ignore +database = game_module.database # type: ignore +start_room = game_module.start_room # type: ignore -# Run -game.say("=" * 70) -game.say("Type ((help)) for help, or ((quit)) to exit.") -game.say("=" * 70) -game.say() +# Build the game state +game = Game( + display=ANSIDisplay(), + database=database, + room=database.rooms["prelude"], +) -game.do_teleport_actions() -while True: - try: +try: + # Run + game.say("=" * 70) + game.say("Type ((help)) for help, or ((quit)) to exit.") + game.say("=" * 70) + game.say() + + game.do_teleport_actions() + while True: game.say() - line = input(colorize("{{>}} ")) + line = game.display.input() if line.lower() == "quit": break game.say() game.run_command(line) - except (KeyboardInterrupt, EOFError): - break +except (KeyboardInterrupt, EOFError): + # no-op, these are expected events + pass +finally: + game.display.finish() game.say() game.say("Bye.") diff --git a/agame/action.py b/agame/action.py index be20a0a..921f5c6 100644 --- a/agame/action.py +++ b/agame/action.py @@ -13,6 +13,7 @@ __all__ = ( "PrintAction", "PrintRoomAction", "PlayerInputAction", + "WaitForAckAction", "TeleportAction", "GetAction", "CheckInvItemsAction", @@ -91,11 +92,10 @@ class PlayerInputAction(Action): Waits for a user to press a key, or press enter, or whatever. """ - prompt: str = "Press enter to continue..." store_var_id: Optional[str] = None def act(self, game: "Game"): - line = input(self.prompt) + line = game.display.input() if self.store_var_id: assert ( self.store_var_id in game.vars @@ -103,6 +103,18 @@ class PlayerInputAction(Action): game.vars[self.store_var_id] = line +class WaitForAckAction(Action): + """ + Waits for a player to press a single key, or press enter, to acknowledge and + continue processing. + + This is basically "press any key to continue..." territory. + """ + + def act(self, game: "Game"): + game.display.wait_for_ack() + + @dataclasses.dataclass class TeleportAction(Action): """ diff --git a/agame/display/__init__.py b/agame/display/__init__.py new file mode 100644 index 0000000..ef5cad0 --- /dev/null +++ b/agame/display/__init__.py @@ -0,0 +1,18 @@ +import platform +from .display import Display + +if platform.system() in ("Linux", "Darwin", "Unix"): + from .unix import * + + +class BasicDisplay(Display): + """ + A basic display that should be supported on all platforms. It just outputs + text with some color. + """ + + def line(self, line: str = ""): + print(line) + + def input(self) -> str: + return input("> ") diff --git a/agame/display/display.py b/agame/display/display.py new file mode 100644 index 0000000..db8e058 --- /dev/null +++ b/agame/display/display.py @@ -0,0 +1,32 @@ +import abc +from typing import Optional + + +class Display(metaclass=abc.ABCMeta): + """ + Displays are an abstraction over printing things to the screen. + + Use a specific implementation based on the kind of display you want to use. + """ + + @abc.abstractmethod + def line(self, line: str = ""): + "Print a single line to the display." + + @abc.abstractmethod + def input(self) -> str: + "Get player input." + + @abc.abstractmethod + def wait_for_ack(self, prompt: Optional[str] = None): + """ + Wait for the user to press a key. + + The behavior is up to the implementor. This could be something like + "press any key to continue", "press enter to continue", or "click OK to + continue". This method should block until input is acknowleged by the + user. + """ + + def finish(self): + "Final cleanup code for this display." diff --git a/agame/display/unix.py b/agame/display/unix.py new file mode 100644 index 0000000..91421e2 --- /dev/null +++ b/agame/display/unix.py @@ -0,0 +1,88 @@ +import sys +import termios +from typing import Optional, TextIO +from agame.color import colorize +from .display import Display + +ESC = "\u001b" +ANSI_MOVE_BOTTOM = f"{ESC}[999;0f" + + +class ANSIDisplay(Display): + """ + Display for a modern terminal. + + This display attempts to keep the input at the bottom, and the output + scrolling up. + + This utilizes `termios` and is therefore UNIX-only. + """ + + def __init__( + self, + input_prompt: str = colorize("{{>}} "), + stdin: TextIO = sys.stdin, + stdout: TextIO = sys.stdout, + ): + self.input_prompt = input_prompt + self.__stdin = stdin + self.__stdout = stdout + # Initial set-up: + # . Move the cursor to the bottom left part of the screen (this will set + # the current line to a really high value and will move us to the + # bottom of the screen) + self.stdout.write(ANSI_MOVE_BOTTOM) + + # . Disable echo, set cbreak + fd = self.stdin.fileno() + self.__old_attrs = termios.tcgetattr(fd) + self.cbreak() + + # . Clear + self.clear() + + def line(self, line: str = ""): + self.stdout.write(line) + self.stdout.write("\n") + + def input(self) -> str: + try: + self.nocbreak() + return input(self.input_prompt) + finally: + self.cbreak() + + def wait_for_ack(self, prompt: Optional[str] = "Press any key to continue..."): + # Flush unread data + termios.tcflush(self.stdin.fileno(), termios.TCIFLUSH) + if prompt is not None: + self.line(prompt) + self.stdin.read(1) + + def cbreak(self): + fd = self.stdin.fileno() + new = termios.tcgetattr(fd) + new[3] &= ~(termios.ECHO | termios.ICANON) + termios.tcsetattr(fd, termios.TCSADRAIN, new) + + def nocbreak(self): + fd = self.stdin.fileno() + new = termios.tcgetattr(fd) + 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") + + @property + def stdin(self) -> TextIO: + return self.__stdin + + @property + def stdout(self) -> TextIO: + return self.__stdout diff --git a/agame/game.py b/agame/game.py index 880314d..2b8d325 100644 --- a/agame/game.py +++ b/agame/game.py @@ -3,6 +3,7 @@ import textwrap from typing import Any, MutableMapping, List, Match, Optional, Sequence, Union from agame.action import Action from agame.color import colorize +from agame.display import Display from agame.item import Item, ItemInst from agame.room import Room from agame.trigger import * @@ -67,6 +68,8 @@ class Database: @dataclasses.dataclass class Game: + # Game display. + display: Display # Game room/items database database: Database # Current room. @@ -138,13 +141,14 @@ class Game: "Format, colorize, wrap, and print the message." if lines: head = textwrap.fill(lines[0]) - print(colorize(head)) + # TODO - move colorize to Display + self.display.line(colorize(head)) for line in lines[1:]: message = textwrap.fill(line) - print() - print(colorize(message)) + self.display.line() + self.display.line(colorize(message)) else: - print() + self.display.line() @property def rooms(self): diff --git a/examplegame/__init__.py b/examplegame/__init__.py index 4e15984..8705b6d 100644 --- a/examplegame/__init__.py +++ b/examplegame/__init__.py @@ -7,11 +7,8 @@ from agame.trigger import * # This is the *game* here database = Database() +# This is the start room +start_room = "prelude" + from . import rooms from . import vars - -# Build the game state -game = Game( - database=database, - room=database.rooms["prelude"], -) diff --git a/examplegame/rooms.py b/examplegame/rooms.py index ae8eef2..acc9959 100644 --- a/examplegame/rooms.py +++ b/examplegame/rooms.py @@ -59,7 +59,7 @@ database.add_room( "This is one of those stories.", LINEBREAK, ), - PlayerInputAction(), + WaitForAckAction(), PrintAction( LINEBREAK, # @@ -75,7 +75,7 @@ database.add_room( "No animals patrol the sky nor ground. Life has left this place.", LINEBREAK, ), - PlayerInputAction(), + WaitForAckAction(), PrintAction( LINEBREAK, # @@ -83,22 +83,25 @@ database.add_room( # LINEBREAK, ), - PlayerInputAction(), - SleepAction(1), + WaitForAckAction(), + PrintAction(LINEBREAK), + SleepAction(0.75), PrintAction("."), - SleepAction(1), + SleepAction(0.75), PrintAction(".."), - SleepAction(1), + SleepAction(0.75), PrintAction("..."), - SleepAction(1), + SleepAction(0.75), PrintAction("...."), - SleepAction(1), + SleepAction(0.75), PrintAction("....."), - SleepAction(1), + SleepAction(0.75), PrintAction("......"), - SleepAction(1), + SleepAction(0.75), + PrintAction(LINEBREAK), PrintAction("You awaken from a fitful sleep. Where are you again?"), - PlayerInputAction(), + WaitForAckAction(), + PrintAction(LINEBREAK), TeleportAction("cabin_inside"), ], )