Add display abstraction

In case we want to run this on something that isn't an ANSI terminal, we
have the option to implement it however we want.

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2021-11-20 19:38:06 -08:00
parent 4cf3608f71
commit e868d0e14f
8 changed files with 202 additions and 35 deletions

View File

@@ -3,6 +3,8 @@ import importlib.util
from pathlib import Path from pathlib import Path
import sys import sys
from agame.color import colorize from agame.color import colorize
from agame.display import ANSIDisplay
from agame.game import Game
# Parse args # Parse args
@@ -19,25 +21,36 @@ package = args.PACKAGE
# Load game module # Load game module
game_module = importlib.import_module(package) 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 # Build the game state
game.say("=" * 70) game = Game(
game.say("Type ((help)) for help, or ((quit)) to exit.") display=ANSIDisplay(),
game.say("=" * 70) database=database,
game.say() room=database.rooms["prelude"],
)
game.do_teleport_actions() try:
while True: # Run
try: game.say("=" * 70)
game.say("Type ((help)) for help, or ((quit)) to exit.")
game.say("=" * 70)
game.say() game.say()
line = input(colorize("{{>}} "))
game.do_teleport_actions()
while True:
game.say()
line = game.display.input()
if line.lower() == "quit": if line.lower() == "quit":
break break
game.say() game.say()
game.run_command(line) game.run_command(line)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
break # no-op, these are expected events
pass
finally:
game.display.finish()
game.say() game.say()
game.say("Bye.") game.say("Bye.")

View File

@@ -13,6 +13,7 @@ __all__ = (
"PrintAction", "PrintAction",
"PrintRoomAction", "PrintRoomAction",
"PlayerInputAction", "PlayerInputAction",
"WaitForAckAction",
"TeleportAction", "TeleportAction",
"GetAction", "GetAction",
"CheckInvItemsAction", "CheckInvItemsAction",
@@ -91,11 +92,10 @@ class PlayerInputAction(Action):
Waits for a user to press a key, or press enter, or whatever. 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 store_var_id: Optional[str] = None
def act(self, game: "Game"): def act(self, game: "Game"):
line = input(self.prompt) line = game.display.input()
if self.store_var_id: if self.store_var_id:
assert ( assert (
self.store_var_id in game.vars self.store_var_id in game.vars
@@ -103,6 +103,18 @@ class PlayerInputAction(Action):
game.vars[self.store_var_id] = line 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 @dataclasses.dataclass
class TeleportAction(Action): class TeleportAction(Action):
""" """

18
agame/display/__init__.py Normal file
View File

@@ -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("> ")

32
agame/display/display.py Normal file
View File

@@ -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."

88
agame/display/unix.py Normal file
View File

@@ -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

View File

@@ -3,6 +3,7 @@ import textwrap
from typing import Any, MutableMapping, List, Match, Optional, Sequence, Union from typing import Any, MutableMapping, List, Match, Optional, Sequence, Union
from agame.action import Action from agame.action import Action
from agame.color import colorize from agame.color import colorize
from agame.display import Display
from agame.item import Item, ItemInst from agame.item import Item, ItemInst
from agame.room import Room from agame.room import Room
from agame.trigger import * from agame.trigger import *
@@ -67,6 +68,8 @@ class Database:
@dataclasses.dataclass @dataclasses.dataclass
class Game: class Game:
# Game display.
display: Display
# Game room/items database # Game room/items database
database: Database database: Database
# Current room. # Current room.
@@ -138,13 +141,14 @@ class Game:
"Format, colorize, wrap, and print the message." "Format, colorize, wrap, and print the message."
if lines: if lines:
head = textwrap.fill(lines[0]) head = textwrap.fill(lines[0])
print(colorize(head)) # TODO - move colorize to Display
self.display.line(colorize(head))
for line in lines[1:]: for line in lines[1:]:
message = textwrap.fill(line) message = textwrap.fill(line)
print() self.display.line()
print(colorize(message)) self.display.line(colorize(message))
else: else:
print() self.display.line()
@property @property
def rooms(self): def rooms(self):

View File

@@ -7,11 +7,8 @@ from agame.trigger import *
# This is the *game* here # This is the *game* here
database = Database() database = Database()
# This is the start room
start_room = "prelude"
from . import rooms from . import rooms
from . import vars from . import vars
# Build the game state
game = Game(
database=database,
room=database.rooms["prelude"],
)

View File

@@ -59,7 +59,7 @@ database.add_room(
"This is one of those stories.", "This is one of those stories.",
LINEBREAK, LINEBREAK,
), ),
PlayerInputAction(), WaitForAckAction(),
PrintAction( PrintAction(
LINEBREAK, LINEBREAK,
# #
@@ -75,7 +75,7 @@ database.add_room(
"No animals patrol the sky nor ground. Life has left this place.", "No animals patrol the sky nor ground. Life has left this place.",
LINEBREAK, LINEBREAK,
), ),
PlayerInputAction(), WaitForAckAction(),
PrintAction( PrintAction(
LINEBREAK, LINEBREAK,
# #
@@ -83,22 +83,25 @@ database.add_room(
# #
LINEBREAK, LINEBREAK,
), ),
PlayerInputAction(), WaitForAckAction(),
SleepAction(1), PrintAction(LINEBREAK),
SleepAction(0.75),
PrintAction("."), PrintAction("."),
SleepAction(1), SleepAction(0.75),
PrintAction(".."), PrintAction(".."),
SleepAction(1), SleepAction(0.75),
PrintAction("..."), PrintAction("..."),
SleepAction(1), SleepAction(0.75),
PrintAction("...."), PrintAction("...."),
SleepAction(1), SleepAction(0.75),
PrintAction("....."), PrintAction("....."),
SleepAction(1), SleepAction(0.75),
PrintAction("......"), PrintAction("......"),
SleepAction(1), SleepAction(0.75),
PrintAction(LINEBREAK),
PrintAction("You awaken from a fitful sleep. Where are you again?"), PrintAction("You awaken from a fitful sleep. Where are you again?"),
PlayerInputAction(), WaitForAckAction(),
PrintAction(LINEBREAK),
TeleportAction("cabin_inside"), TeleportAction("cabin_inside"),
], ],
) )