diff --git a/arrowkeyagent.py b/arrowkeyagent.py new file mode 100644 index 0000000..294b686 --- /dev/null +++ b/arrowkeyagent.py @@ -0,0 +1,7 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +agent = ArrowKeyAgent() +state = {} +game = Game([agent], state) +game.play() \ No newline at end of file diff --git a/class Agent source.py b/class Agent source.py new file mode 100644 index 0000000..2df58a3 --- /dev/null +++ b/class Agent source.py @@ -0,0 +1,106 @@ +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 `_. + 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. + """ + RIGHT = (1, 0) + UP = (0, -1) + LEFT = (-1, 0) + DOWN = (0, 1) + 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}") \ No newline at end of file diff --git a/class Game source.py b/class Game source.py new file mode 100644 index 0000000..6e311bf --- /dev/null +++ b/class Game source.py @@ -0,0 +1,222 @@ +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 `_. + + :: + + # 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 \ No newline at end of file diff --git a/game.py b/game.py new file mode 100644 index 0000000..eacd2ab --- /dev/null +++ b/game.py @@ -0,0 +1,32 @@ +from random import randint +from retro.game import Game + +class Agent: + RIGHT = (1, 0) + UP = (0, -1) + LEFT = (-1, 0) + DOWN = (0, 1) + character = ">" + direction = RIGHT + position = (15, 0) + name = "agent" + color = "white_on_black" + display = True + + def handle_keystroke(self, keystroke, game): + x, y = self.position + if keystroke.name == "M": + self.direction = self.RIGHT + self.character = '>' + elif keystroke.name == "I": + self.direction = self.LEFT + self.character = '<' + + def can_move(self, position, game): + on_board = game.on_board(position) + empty = game.is_empty(position) + +if __name__ == '__main__': + player = Agent() + game = Game([Agent], {'score': 0}, board_size:=(24, 20), framerate:=12) + game.play() \ No newline at end of file diff --git a/nav.py b/nav.py new file mode 100644 index 0000000..9ccab28 --- /dev/null +++ b/nav.py @@ -0,0 +1,144 @@ +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 `_) + """ + 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() \ No newline at end of file diff --git a/snake.py b/snake.py new file mode 100644 index 0000000..7440fd7 --- /dev/null +++ b/snake.py @@ -0,0 +1,203 @@ +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 `_ + 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() \ No newline at end of file