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
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()
line = input(colorize("{{>}} "))
game.do_teleport_actions()
while True:
game.say()
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.")

View File

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

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 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):

View File

@@ -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"],
)

View File

@@ -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"),
],
)