When I run the game, it works for a bit but then when the first asteroid

gets to the bottom I get this:

Traceback (most recent call last):
  File "/root/making_with_code/mwc1/unit3/lab_retro/nav_game.py", line 14, in <module>
    game.play()
  File "/root/making_with_code/mwc1/unit3/lab_retro/retro/game.py", line 80, in play
    agent.play_turn(self)
  File "/root/making_with_code/mwc1/unit3/lab_retro/asteroid.py", line 16, in play_turn
    game.remove_agent_by_name(self.name)
AttributeError: 'Asteroid' object has no attribute 'name'
This commit is contained in:
root
2024-12-12 16:42:57 -05:00
parent 2ce382cfb6
commit 52c1128ed4
82 changed files with 5972 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

99
retro/agent.py Normal file
View File

@@ -0,0 +1,99 @@
class Agent:
"""Represents a character in the game. To create an Agent, define a new
class with some of the attributes and methods below. You may change any of
the Agent's attributes at any time, and the result will immediately be
visible in the game.
After you create your Agents, add them to the ``Game``, either when it is created
or using ``Game.add_agent`` later on. Then the Game will take care of calling
the Agent's methods at the appropriate times.
Attributes:
position: (Required) The character's ``(int, int)`` position on the game
board.
character: (Required unless display is ``False``.) A one-character string
which will be displayed at the Agent's position on the game board.
name: (Optional) If an agent has a name, it must be unique within the game.
Agent names can be used to look up agents with
:py:meth:`retro.game.Game.get_agent_by_name`.
color (str): (Optional) The agent's color.
`Available colors <https://blessed.readthedocs.io/en/latest/colors.html>`_.
display: (Optional) When ``False``, the Agent will not be displayed on the
board. This is useful when you want to create an agent which will be displayed
later, or when you want to create an agent which acts on the Game indirectly,
for example by spawning other Agents. Defaults to True.
z: (Optional) When multiple Agents have the same position on the board, the
Agent with the highest ``z`` value will be displayed.
The Game is played on a two-dimensional (x, y) board, but you can think of
``z`` as a third "up" dimension. Defaults to 0.
"""
character = "*"
position = (0, 0)
name = "agent"
color = "white_on_black"
display = True
z = 0
def play_turn(self, game):
"""If an Agent has this method, it will be called once
each turn.
Arguments:
game (Game): The game which is currently being played will be
passed to the Agent, in case it needs to check anything about
the game or make any changes.
"""
pass
def handle_keystroke(self, keystroke, game):
"""If an Agent has a this method, it will be called every
time a key is pressed in the game.
Arguments:
keystroke (blessed.keyboard.Keystroke): The key which was pressed. You can
compare a Keystroke with a string (e.g. ``if keystroke == 'q'``) to check
whether it is a regular letter, number, or symbol on the keyboard. You can
check special keys using the keystroke's name
(e.g. ``if keystroke.name == "KEY_RIGHT"``). Run your game in debug mode to
see the names of keystrokes.
game (Game): The game which is currently being played will be
passed to the Agent, in case it needs to check anything about
the game or make any changes.
"""
pass
class ArrowKeyAgent:
"""A simple agent which can be moved around with the arrow keys.
"""
name = "ArrowKeyAgent"
character = "*"
position = (0,0)
display = True
z = 0
def play_turn(self, game):
pass
def handle_keystroke(self, keystroke, game):
"""Moves the agent's position if the keystroke is one of the arrow keys.
One by one, checks the keystroke's name against each arrow key.
Then uses :py:meth:`try_to_move` to check whether the move is on the
game's board before moving.
"""
x, y = self.position
if keystroke.name == "KEY_RIGHT":
self.try_to_move((x + 1, y), game)
elif keystroke.name == "KEY_UP":
self.try_to_move((x, y - 1), game)
elif keystroke.name == "KEY_LEFT":
self.try_to_move((x - 1, y), game)
elif keystroke.name == "KEY_DOWN":
self.try_to_move((x, y + 1), game)
def try_to_move(self, position, game):
"""Moves to the position if it is on the game board.
"""
if game.on_board(position):
self.position = position
game.log(f"Position: {self.position}")

View File

40
retro/errors.py Normal file
View File

@@ -0,0 +1,40 @@
class GameError(Exception):
pass
class AgentWithNameAlreadyExists(GameError):
def __init__(self, name):
message = f"There is already an agent named {agent.name} in the game"
super().__init__(message)
class AgentNotFoundByName(GameError):
def __init__(self, name):
message = f"There is no agent named {agent.name} in the game"
super().__init__(message)
class AgentNotInGame(GameError):
def __init__(self, agent):
name = agent.name or f"anonymous {agent.__class__.__name__}"
message = f"Agent {name} is not in the game"
super().__init__(message)
class IllegalMove(GameError):
def __init__(self, agent, position):
message = f"Agent {agent.name} tried to move to {position}"
super().__init__(message)
class GraphError(GameError):
pass
class TerminalTooSmall(GameError):
BORDER_X = 2
BORDER_Y = 3
STATE_HEIGHT = 5
def __init__(self, width=None, width_needed=None, height=None, height_needed=None):
if width is not None and width_needed is not None and width_needed < width:
err = f"The terminal width ({width}) is less than the required {width_needed}."
super().__init__(err)
elif height is not None and height_needed is not None and height_needed < height:
err = f"The terminal height ({height}) is less than the required {height_needed}."
else:
raise ValueError(f"TerminalTooSmall called with illegal values.")

View File

Binary file not shown.

6
retro/examples/debug.py Normal file
View File

@@ -0,0 +1,6 @@
from retro.game import Game
from retro.agent import ArrowKeyAgent
game = Game([ArrowKeyAgent()], {}, debug=True)
game.play()

View File

139
retro/examples/nav.py Normal file
View File

@@ -0,0 +1,139 @@
from random import randint
from retro.game import Game
HEIGHT = 25
WIDTH = 25
class Spaceship:
"""A player-controlled agent which moves left and right, dodging asteroids.
Spaceship is a pretty simple class. The ship's character is ``^``, and
its position starts at the bottom center of the screen.
"""
name = "ship"
character = '^'
position = (WIDTH // 2, HEIGHT - 1)
color = "black_on_skyblue1"
def handle_keystroke(self, keystroke, game):
"""When the
left or arrow key is pressed, it moves left or right. If the ship's
new position is empty, it moves to that position. If the new position
is occupied (by an asteroid!) the game ends.
"""
x, y = self.position
if keystroke.name in ("KEY_LEFT", "KEY_RIGHT"):
if keystroke.name == "KEY_LEFT":
new_position = (x - 1, y)
else:
new_position = (x + 1, y)
if game.on_board(new_position):
if game.is_empty(new_position):
self.position = new_position
else:
self.explode()
game.end()
def explode(self):
"""Sets the ship's character to ``*`` and its color to red.
"""
self.color = "crimson_on_skyblue1"
self.character = '*'
class Asteroid:
"""When Asteroids are spawned, they fall down the screen until they
reach the bottom row and are removed.
An Asteroid's position is set when it is created.
Whenever an asteroid moves, it
checks whether it has it the ship.
"""
character = 'O'
color = "deepskyblue1_on_skyblue1"
def __init__(self, position):
self.position = position
def play_turn(self, game):
"""Nothing happens unless
``game.turn_number`` is divisible by 2. The result is that asteroids
only move on even-numbered turns. If the asteroid is at the bottom of
the screen, it has run its course and should be removed from the game.
Otherwise, the asteroid's new position is one space down from its old
position. If the asteroid's new position is the same as the ship's
position, the game ends.
"""
if game.turn_number % 2 == 0:
self.set_color()
x, y = self.position
if y == HEIGHT - 1:
game.remove_agent(self)
else:
ship = game.get_agent_by_name('ship')
new_position = (x, y + 1)
if new_position == ship.position:
ship.explode()
game.end()
else:
self.position = new_position
def set_color(self):
"""To add to the game's drama, asteroids gradually become visible as they
fall down the screen. This method calculates the ratio of the asteroid's
position compared to the screen height--0 is the top of the screen and 1 is
the bottom ot the screen. Then sets the asteroid's color depending on the
ratio. (`Available colors <https://blessed.readthedocs.io/en/latest/colors.html>`_)
"""
x, y = self.position
ratio = y / HEIGHT
if ratio < 0.2:
self.color = "deepskyblue1_on_skyblue1"
elif ratio < 0.4:
self.color = "deepskyblue2_on_skyblue1"
elif ratio < 0.6:
self.color = "deepskyblue3_on_skyblue1"
else:
self.color = "deepskyblue4_on_skyblue1"
class AsteroidSpawner:
"""An agent which is not displayed on the board, but which constantly spawns
asteroids.
"""
display = False
def play_turn(self, game):
"""Adds 1 to the game score and then uses
:py:meth:`~retro.examples.nav.should_spawn_asteroid` to decide whether to
spawn an asteroid. When :py:meth:`~retro.examples.nav.should_spawn_asteroid`
comes back ``True``, creates a new instance of
:py:class:`~retro.examples.nav.Asteroid` at a random position along the
top of the screen and adds the asteroid to the game.
"""
game.state['score'] += 1
if self.should_spawn_asteroid(game.turn_number):
asteroid = Asteroid((randint(0, WIDTH - 1), 0))
game.add_agent(asteroid)
def should_spawn_asteroid(self, turn_number):
"""Decides whether to spawn an asteroid.
Uses a simple but effective algorithm to make the game get
progressively more difficult: choose a random number and return
``True`` if the number is less than the current turn number. At
the beginning of the game, few asteroids will be spawned. As the
turn number climbs toward 1000, asteroids are spawned almost
every turn.
Arguments:
turn_number (int): The current turn in the game.
"""
return randint(0, 1000) < turn_number
if __name__ == '__main__':
ship = Spaceship()
spawner = AsteroidSpawner()
game = Game(
[ship, spawner],
{"score": 0},
board_size=(WIDTH, HEIGHT),
color="deepskyblue4_on_skyblue1",
)
game.play()

View File

7
retro/examples/simple.py Normal file
View File

@@ -0,0 +1,7 @@
from retro.game import Game
from retro.agent import ArrowKeyAgent
agent = ArrowKeyAgent()
state = {}
game = Game([agent], state)
game.play()

View File

198
retro/examples/snake.py Normal file
View File

@@ -0,0 +1,198 @@
from random import randint
from retro.game import Game
class Apple:
"""An agent representing the Apple.
Note how Apple doesn't have ``play_turn`` or
``handle_keystroke`` methods: the Apple doesn't need to do
anything in this game. It just sits there waiting to get
eaten.
Attributes:
name: "Apple"
character: '@'
color: "red_on_black" (`Here's documentation on how colors
work <https://blessed.readthedocs.io/en/latest/colors.html>`_
position: (0, 0). The Apple will choose a random position
as soon as the game starts, but it needs an initial
position to be assigned.
"""
name = "Apple"
character = '@'
color = "red_on_black"
position = (0, 0)
def relocate(self, game):
"""Sets position to a random empty position. This method is
called whenever the snake's head touches the apple.
Arguments:
game (Game): The current game.
"""
self.position = self.random_empty_position(game)
def random_empty_position(self, game):
"""Returns a randomly-selected empty position. Uses a very
simple algorithm: Get the game's board size, choose a
random x-value between 0 and the board width, and choose
a random y-value between 0 and the board height. Now use
the game to check whether any Agents are occupying this
position. If so, keep randomly choosing a new position
until the position is empty.
"""
bw, bh = game.board_size
occupied_positions = game.get_agents_by_position()
while True:
position = (randint(0, bw-1), randint(0, bh-1))
if position not in occupied_positions:
return position
class SnakeHead:
"""An Agent representing the snake's head. When the game starts, you control
the snake head using the arrow keys. The SnakeHead always has a direction, and
will keep moving in that direction every turn. When you press an arrow key,
you change the SnakeHead's direction.
Attributes:
name: "Snake head"
position: (0,0)
character: ``'v'`` Depending on the snake head's direction, its character
changes to ``'<'``, ``'^'``, ``'>'``, or ``'v'``.
next_segment: Initially ``None``, this is a reference to a SnakeBodySegment.
growing: When set to True, the snake will grow a new segment on its next move.
"""
RIGHT = (1, 0)
UP = (0, -1)
LEFT = (-1, 0)
DOWN = (0, 1)
name = "Snake head"
position = (0, 0)
direction = DOWN
character = 'v'
next_segment = None
growing = False
def play_turn(self, game):
"""On each turn, the snake head uses its position and direction to figure out
its next position. If the snake head is able to move there (it's on the board and
not occuppied by part of the snake's body), it moves.
Then, if the snake head is on the Apple, the Apple moves to a new random position
and ``growing`` is set to True.
Now we need to deal with two situations. First, if ``next_segment`` is not None, there is
a SnakeBodySegment attached to the head. We need the body to follow the head,
so we call ``self.next_segment.move``, passing the head's old position
(this will be the body's new position), a reference to the game, and a value for
``growing``. If the snake needs to grow, we need to pass this information along
the body until it reaches the tail--this is where the next segment will be attached.
If there is no ``next_segment`` but ``self.growing`` is True, it's time to add
a body! We set ``self.next_segment`` to a new SnakeBodySegment, set its
position to the head's old position, and add it to the game. We also add 1 to the
game's score.
"""
x, y = self.position
dx, dy = self.direction
if self.can_move((x+dx, y+dy), game):
self.position = (x+dx, y+dy)
if self.is_on_apple(self.position, game):
apple = game.get_agent_by_name("Apple")
apple.relocate(game)
self.growing = True
if self.next_segment:
self.next_segment.move((x, y), game, growing=self.growing)
elif self.growing:
self.next_segment = SnakeBodySegment(1, (x, y))
game.add_agent(self.next_segment)
game.state['score'] += 1
self.growing = False
def handle_keystroke(self, keystroke, game):
"""Checks whether one of the arrow keys has been pressed.
If so, sets the SnakeHead's direction and character.
"""
x, y = self.position
if keystroke.name == "KEY_RIGHT":
self.direction = self.RIGHT
self.character = '>'
elif keystroke.name == "KEY_UP":
self.direction = self.UP
self.character = '^'
elif keystroke.name == "KEY_LEFT":
self.direction = self.LEFT
self.character = '<'
elif keystroke.name == "KEY_DOWN":
self.direction = self.DOWN
self.character = 'v'
def can_move(self, position, game):
on_board = game.on_board(position)
empty = game.is_empty(position)
on_apple = self.is_on_apple(position, game)
return on_board and (empty or on_apple)
def is_on_apple(self, position, game):
apple = game.get_agent_by_name("Apple")
return apple.position == position
class SnakeBodySegment:
"""Finally, we need an Agent for the snake's body segments.
SnakeBodySegment doesn't have ``play_turn`` or ``handle_keystroke`` methods because
it never does anything on its own. It only moves when the SnakeHead, or the previous
segment, tells it to move.
Arguments:
segment_id (int): Keeps track of how far back this segment is from the head.
This is used to give the segment a unique name, and also to keep track
of how many points the player earns for eating the next apple.
position (int, int): The initial position.
Attributes:
character: '*'
next_segment: Initially ``None``, this is a reference to a SnakeBodySegment
when this segment is not the last one in the snake's body.
"""
character = '*'
next_segment = None
def __init__(self, segment_id, position):
self.segment_id = segment_id
self.name = f"Snake body segment {segment_id}"
self.position = position
def move(self, new_position, game, growing=False):
"""When SnakeHead moves, it sets off a chain reaction, moving all its
body segments. Whenever the head or a body segment has another segment
(``next_segment``), it calls that segment's ``move`` method.
This method updates the SnakeBodySegment's position. Then, if
``self.next_segment`` is not None, calls that segment's ``move`` method.
If there is no next segment and ``growing`` is True, then we set
``self.next_segment`` to a new SnakeBodySegment in this segment's old
position, and update the game's score.
Arguments:
new_position (int, int): The new position.
game (Game): A reference to the current game.
growing (bool): (Default False) When True, the snake needs to
add a new segment.
"""
old_position = self.position
self.position = new_position
if self.next_segment:
self.next_segment.move(old_position, game, growing=growing)
elif growing:
self.next_segment = SnakeBodySegment(self.segment_id + 1, old_position)
game.add_agent(self.next_segment)
game.state['score'] += self.segment_id + 1
if __name__ == '__main__':
head = SnakeHead()
apple = Apple()
game = Game([head, apple], {'score': 0}, board_size=(32, 16), framerate=12)
apple.relocate(game)
game.play()

View File

216
retro/game.py Normal file
View File

@@ -0,0 +1,216 @@
from collections import defaultdict
from signal import signal, SIGWINCH
from time import sleep, perf_counter
from blessed import Terminal
from retro.view import View
from retro.validation import (
validate_agent,
validate_state,
validate_agent_name,
validate_position,
)
from retro.errors import (
AgentWithNameAlreadyExists,
AgentNotFoundByName,
IllegalMove,
)
class Game:
"""
Creates a playable game.
You will use Game to create games, but don't need to read or understand how
this class works. The main work in creating a
Arguments:
agents (list): A list of agents to add to the game.
state (dict): A dict containing the game's initial state.
board_size (int, int): (Optional) The two-dimensional size of the game board. D
debug (bool): (Optional) Turn on debug mode, showing log messages while playing.
framerate (int): (Optional) The target number of frames per second at which the
game should run.
color (str): (Optional) The game's background color scheme. `Available colors <https://blessed.readthedocs.io/en/latest/colors.html>`_.
::
# This example will create a simple game.
from retro.game import Game
from retro.agent import ArrowKeyAgent
agents = [ArrowKeyAgent()]
state = {}
game = Game(agents, state)
game.play()
"""
STATE_HEIGHT = 5
EXIT_CHARACTERS = ("KEY_ENTER", "KEY_ESCAPE")
def __init__(self, agents, state, board_size=(64, 32), debug=False, framerate=24,
color="white_on_black"):
self.log_messages = []
self.agents_by_name = {}
self.agents = []
self.state = validate_state(state)
self.board_size = board_size
self.debug = debug
self.framerate = framerate
self.turn_number = 0
self.color = color
for agent in agents:
self.add_agent(agent)
def play(self):
"""Starts the game.
"""
self.playing = True
terminal = Terminal()
with terminal.fullscreen(), terminal.hidden_cursor(), terminal.cbreak():
view = View(terminal, color=self.color)
while self.playing:
turn_start_time = perf_counter()
self.turn_number += 1
self.keys_pressed = self.collect_keystrokes(terminal)
if self.debug and self.keys_pressed:
self.log("Keys: " + ', '.join(k.name or str(k) for k in self.keys_pressed))
for agent in self.agents:
if hasattr(agent, 'handle_keystroke'):
for key in self.keys_pressed:
agent.handle_keystroke(key, self)
if hasattr(agent, 'play_turn'):
agent.play_turn(self)
if getattr(agent, 'display', True):
if not self.on_board(agent.position):
raise IllegalMove(agent, agent.position)
view.render(self)
turn_end_time = perf_counter()
time_elapsed_in_turn = turn_end_time - turn_start_time
time_remaining_in_turn = max(0, 1/self.framerate - time_elapsed_in_turn)
sleep(time_remaining_in_turn)
while True:
if terminal.inkey().name in self.EXIT_CHARACTERS:
break
def collect_keystrokes(self, terminal):
keys = set()
while True:
key = terminal.inkey(0.001)
if key:
keys.add(key)
else:
break
return keys
def log(self, message):
"""Write a log message.
Log messages are only shown when debug mode is on.
They can be very useful for debugging.
Arguments:
message (str): The message to log.
"""
self.log_messages.append((self.turn_number, message))
def end(self):
"""Ends the game. No more turns will run.
"""
self.playing = False
def add_agent(self, agent):
"""Adds an agent to the game.
Whenever you want to add a new agent during the game, you must add it to
the game using this method.
Arguments:
agent: An instance of an agent class.
"""
validate_agent(agent)
if getattr(agent, "display", True) and not self.on_board(agent.position):
raise IllegalMove(agent, agent.position)
if hasattr(agent, "name"):
if agent.name in self.agents_by_name:
raise AgentWithNameAlreadyExists(agent.name)
self.agents_by_name[agent.name] = agent
self.agents.append(agent)
def get_agent_by_name(self, name):
"""Looks up an agent by name.
This is useful when one agent needs to interact with another agent.
Arguments:
name (str): The agent's name. If there is no agent with this name,
you will get an error.
Returns:
An agent.
"""
validate_agent_name(name)
if name in self.agents_by_name:
return self.agents_by_name[name]
else:
raise AgentNotFoundByName(name)
def is_empty(self, position):
"""Checks whether a position is occupied by any agents.
Arguments:
position (int, int): The position to check.
Returns:
A bool
"""
return position not in self.get_agents_by_position()
def get_agents_by_position(self):
"""Returns a dict where each key is a position (e.g. (10, 20)) and
each value is a list containing all the agents at that position.
This is useful when an agent needs to find out which other agents are
on the same space or nearby.
"""
positions = defaultdict(list)
for agent in self.agents:
if getattr(agent, "display", True):
validate_position(agent.position)
positions[agent.position].append(agent)
return positions
def remove_agent(self, agent):
"""Removes an agent from the game.
Arguments:
agent (Agent): the agent to remove.
"""
if agent not in self.agents:
raise AgentNotInGame(agent)
else:
self.agents.remove(agent)
if hasattr(agent, "name"):
self.agents_by_name.pop(agent.name)
def remove_agent_by_name(self, name):
"""Removes an agent from the game.
Arguments:
name (str): the agent's name.
"""
validate_agent_name(name)
if name not in self.agents_by_name:
raise AgentNotFoundByName(name)
agent = self.agents_by_name.pop(name)
self.agents.remove(agent)
def on_board(self, position):
"""Checks whether a position is on the game board.
Arguments:
position (int, int): The position to check
Returns:
A bool
"""
validate_position(position)
x, y = position
bx, by = self.board_size
return x >= 0 and x < bx and y >= 0 and y < by

View File

162
retro/graph.py Normal file
View File

@@ -0,0 +1,162 @@
from retro.errors import GraphError
class Graph:
def __init__(self, vertices=None, edges=None):
self.vertices = vertices or []
self.edges = edges or []
def __str__(self):
return '\n'.join(str(e) for e in self.edges)
def get_or_create_vertex(self, x, y):
for v in self.vertices:
if x == v.x and y == v.y:
return v
for e in self.edges:
if e.crosses(x, y):
return self.split_edge(e, x, y)
v = Vertex(x, y)
self.vertices.append(v)
return v
def get_or_create_edge(self, x0, y0, x1, y1):
v0 = self.get_or_create_vertex(x0, y0)
v1 = self.get_or_create_vertex(x1, y1)
new_edge = Edge(v0, v1)
for e in self.edges:
if e == new_edge:
new_edge.remove()
return e
return new_edge
def split_edge(self, edge, x, y):
"""
Splits an edge by inserting a new vertex along the edge.
"""
if not edge.crosses(x, y):
raise GraphError(f"Can't split edge {edge} at ({x}, {y})")
self.remove_edge(edge)
v = Vertex(x, y)
self.vertices.append(v)
self.edges.append(Edge(edge.begin, v))
self.edges.append(Edge(v, edge.end))
def remove_edge(self, edge):
if edge not in self.edges:
raise GraphError(f"Edge {edge} is not in the graph")
self.edges.remove(edge)
edge.begin.edges.remove(edge)
edge.end.edges.remove(edge)
def render(self, terminal):
for v in self.vertices:
v.render(terminal)
for e in self.edges:
e.render(terminal)
class Vertex:
CHARACTERS = {
"0000": " ",
"0001": "",
"0010": "",
"0011": "",
"0100": "",
"0101": "",
"0110": "",
"0111": "",
"1000": "",
"1001": "",
"1010": "",
"1011": "",
"1100": "",
"1101": "",
"1110": "",
"1111": "",
}
def __init__(self, x, y):
self.x = x
self.y = y
self.edges = []
def __str__(self):
return f"({self.x}, {self.y})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def neighbors(self):
vertices = []
for edge in self.edges:
if self == edge.begin:
vertices.append(edge.end)
else:
vertices.append(edge.begin)
return vertices
def render(self, terminal):
print(terminal.move_xy(self.x, self.y) + self.get_character())
def get_character(self):
u = self.has_up_edge()
r = self.has_right_edge()
d = self.has_down_edge()
l = self.has_left_edge()
code = ''.join([str(int(direction)) for direction in [u, r, d, l]])
return self.CHARACTERS[code]
def has_up_edge(self):
return any([v.x == self.x and v.y < self.y for v in self.neighbors()])
def has_right_edge(self):
return any([v.y == self.y and self.x < v.x for v in self.neighbors()])
def has_down_edge(self):
return any([v.x == self.x and self.y < v.y for v in self.neighbors()])
def has_left_edge(self):
return any([v.y == self.y and v.x < self.x for v in self.neighbors()])
class Edge:
def __init__(self, begin, end):
if not isinstance(begin, Vertex) or not isinstance(end, Vertex):
raise ValueError("Tried to initialize an Edge with a non-vertex")
if begin.x < end.x or begin.y < end.y:
self.begin = begin
self.end = end
else:
self.begin = end
self.end = begin
if not (self.is_horizontal() or self.is_vertical()):
raise ValueError("Edges must be horizontal or vertical.")
if self.is_horizontal() and self.is_vertical():
raise ValueError("Self-edges are not allowed.")
self.begin.edges.append(self)
self.end.edges.append(self)
def __str__(self):
return f"{self.begin} -> {self.end}"
def render(self, terminal):
if self.is_horizontal():
with terminal.location(self.begin.x + 1, self.begin.y):
line = "" * (self.end.x - self.begin.x - 1)
print(line)
else:
for y in range(self.begin.y + 1, self.end.y):
print(terminal.move_xy(self.begin.x, y) + "")
def is_horizontal(self):
return self.begin.y == self.end.y
def is_vertical(self):
return self.begin.x == self.end.x
def crosses(self, x, y):
if self.is_horizontal():
return self.begin.y == y and self.begin.x < x and x < self.end.x
else:
return self.begin.x == x and self.begin.y < y and y < self.end.y
def remove(self):
self.begin.edges.remove(self)
self.end.edges.remove(self)

View File

5
retro/grid.py Normal file
View File

@@ -0,0 +1,5 @@
from retro.graph import Vertex, Edge, Graph
class Grid:
def __init__(self):
self.graph = Graph

View File

44
retro/validation.py Normal file
View File

@@ -0,0 +1,44 @@
def validate_agent(agent):
if hasattr(agent, "name"):
validate_agent_name(agent.name)
if getattr(agent, 'display', True):
validate_position(agent.position)
if not hasattr(agent, "character"):
raise ValueError(f"Agent {agent.name} must have a character")
return agent
def validate_state(state):
if not isinstance(state, dict):
raise TypeError(f"State is {type(state)}, but must be a dict.")
for key, value in state.items():
if is_mutable(value):
raise ValueError(f"State must be immutable, but state[{key}] is {value}")
return state
def validate_agent_name(name):
if not isinstance(name, str):
raise TypeError(f"Agent names must be strings")
return name
def validate_position(position):
if not isinstance(position, tuple):
raise TypeError(f"Position is {type(position)}, but must be a tuple.")
if not len(position) == 2:
raise ValueError(f"Position is {position}. Must be a tuple of two integers.")
if not isinstance(position[0], int) and isinstance(position[1], int):
raise TypeError(f"Position is {position}. Must be a tuple of two integers.")
return position
def is_mutable(obj):
if isinstance(obj, (int, float, bool, str, None)):
return False
elif isinstance(obj, tuple):
return all(is_mutable(element) for element in obj)
else:
return True

View File

127
retro/view.py Normal file
View File

@@ -0,0 +1,127 @@
from retro.graph import Vertex, Edge, Graph
from retro.errors import TerminalTooSmall
class View:
BORDER_X = 2
BORDER_Y = 3
STATE_HEIGHT = 5
DEBUG_WIDTH = 60
def __init__(self, terminal, color='white_on_black'):
self.terminal = terminal
self.color = color
def render(self, game):
self.render_layout(game)
ox, oy = self.get_board_origin_coords(game)
self.render_state(game)
if game.debug:
self.render_debug_log(game)
for agent in sorted(game.agents, key=lambda a: getattr(a, 'z', 0)):
if getattr(agent, 'display', True):
ax, ay = agent.position
if hasattr(agent, 'color'):
color = self.get_color(agent.color)
print(self.terminal.move_xy(ox + ax, oy + ay) + color(agent.character))
else:
print(self.terminal.move_xy(ox + ax, oy + ay) + agent.character)
def render_layout(self, game):
bw, bh = game.board_size
self.check_terminal_size(game)
self.clear_screen()
layout_graph = self.get_layout_graph(game)
layout_graph.render(self.terminal)
def clear_screen(self):
print(self.terminal.home + self.get_color(self.color) + self.terminal.clear)
def get_color(self, color_string):
if not hasattr(self.terminal, color_string):
msg = (
f"{color_string} is not a supported color."
"See https://blessed.readthedocs.io/en/latest/colors.html"
)
raise ValueError(msg)
return getattr(self.terminal, color_string)
def render_state(self, game):
bw, bh = game.board_size
ox, oy = self.get_state_origin_coords(game)
for i, key in enumerate(sorted(game.state.keys())):
msg = f"{key}: {game.state[key]}"[:bw]
print(self.terminal.move_xy(ox, oy + i) + msg)
def render_debug_log(self, game):
bw, bh = game.board_size
debug_height = bh + self.STATE_HEIGHT
ox, oy = self.get_debug_origin_coords(game)
for i, (turn_number, message) in enumerate(game.log_messages[-debug_height:]):
msg = f"{turn_number}. {message}"[:self.DEBUG_WIDTH]
print(self.terminal.move_xy(ox, oy + i) + msg)
def get_layout_graph(self, game):
bw, bh = game.board_size
sh = self.STATE_HEIGHT
ox, oy = self.get_board_origin_coords(game)
vertices = [
Vertex(ox - 1, oy - 1),
Vertex(ox + bw, oy - 1),
Vertex(ox + bw, oy + bh),
Vertex(ox + bw, oy + bh + sh),
Vertex(ox - 1, oy + bh + sh),
Vertex(ox - 1, oy + bh)
]
edges = [
Edge(vertices[0], vertices[1]),
Edge(vertices[1], vertices[2]),
Edge(vertices[2], vertices[3]),
Edge(vertices[3], vertices[4]),
Edge(vertices[4], vertices[5]),
Edge(vertices[5], vertices[0]),
Edge(vertices[5], vertices[2]),
]
graph = Graph(vertices, edges)
if game.debug:
dw = self.DEBUG_WIDTH
graph.vertices.append(Vertex(ox + bw + dw, oy - 1))
graph.vertices.append(Vertex(ox + bw + dw, oy + bh + sh))
graph.edges.append(Edge(graph.vertices[1], graph.vertices[6]))
graph.edges.append(Edge(graph.vertices[6], graph.vertices[7]))
graph.edges.append(Edge(graph.vertices[3], graph.vertices[7]))
return graph
def check_terminal_size(self, game):
bw, bh = game.board_size
width_needed = bw + self.BORDER_X
height_needed = bh + self.BORDER_Y + self.STATE_HEIGHT
if self.terminal.width < width_needed:
raise TerminalTooSmall(width=self.terminal.width, width_needed=width_needed)
elif self.terminal.height < height_needed:
raise TerminalTooSmall(height=self.terminal.height, height_needed=height_needed)
def board_origin(self, game):
x, y = self.get_board_origin_coords(game)
return self.terminal.move_xy(x, y)
def get_board_origin_coords(self, game):
bw, bh = game.board_size
margin_top = (self.terminal.height - bh - self.BORDER_Y) // 2
if game.debug:
margin_left = (self.terminal.width - bw - self.DEBUG_WIDTH - self.BORDER_X) // 2
else:
margin_left = (self.terminal.width - bw - self.BORDER_X) // 2
return margin_left, margin_top
def get_state_origin_coords(self, game):
bw, bh = game.board_size
ox, oy = self.get_board_origin_coords(game)
return ox, oy + bh + 1
def get_debug_origin_coords(self, game):
bw, bh = game.board_size
ox, oy = self.get_board_origin_coords(game)
return ox + bw + 1, oy

View File