##########################################################################################
########################################## INFO ##########################################
##########################################################################################
# This file is part of the PyRat library.
# It is meant to be used as a library, and not to be executed directly.
# Please import necessary elements using the following syntax:
# from pyrat import <element_name>
"""
This module provides a rendering engine using the shell.
It will print the game state to the console using ASCII characters and ANSI escape codes for colors.
"""
##########################################################################################
######################################### IMPORTS ########################################
##########################################################################################
# External imports
import colored
import re
import math
import sys
import platform
import os
import time
# PyRat imports
from pyrat.src.RenderingEngine import RenderingEngine
from pyrat.src.Player import Player
from pyrat.src.Maze import Maze
from pyrat.src.GameState import GameState
##########################################################################################
######################################### CLASSES ########################################
##########################################################################################
[docs]
class ShellRenderingEngine (RenderingEngine):
"""
*(This class inherits from* ``RenderingEngine`` *).*
An ASCII rendering engine is a rendering engine that can render a PyRat game in ASCII.
It also supports ANSI escape codes to colorize the rendering.
"""
##################################################################################
# CONSTRUCTOR #
##################################################################################
[docs]
def __init__ ( self,
use_colors: bool = True,
clear_each_turn: bool = True,
*args: object,
**kwargs: object
) -> None:
"""
Initializes a new instance of the class.
Args:
use_colors: Boolean indicating whether the rendering engine should use colors or not.
clear_each_turn: Boolean indicating whether the rendering engine should clear the screen each turn.
args: Arguments to pass to the parent constructor.
kwargs: Keyword arguments to pass to the parent constructor.
"""
# Inherit from parent class
super().__init__(*args, **kwargs)
# Debug
assert isinstance(use_colors, bool), "Argument 'use_colors' must be a boolean"
assert isinstance(clear_each_turn, bool), "Argument 'clear_each_turn' must be a boolean"
# Private attributes
self.__use_colors = use_colors
self.__clear_each_turn = clear_each_turn
##################################################################################
# PUBLIC METHODS #
##################################################################################
[docs]
def render ( self,
players: list[Player],
maze: Maze,
game_state: GameState,
) -> None:
"""
*(This method redefines the method of the parent class with the same name).*
This function renders the game to show its current state.
It does so by creating a string representing the game state and printing it.
Args:
players: Players of the game.
maze: Maze of the game.
game_state: State of the game.
"""
# Debug
assert isinstance(players, list), "Argument 'players' must be a list"
assert all(isinstance(player, Player) for player in players), "All elements of 'players' must be of type 'pyrat.Player'"
assert isinstance(maze, Maze), "Argument 'maze' must be of type 'pyrat.Maze'"
assert isinstance(game_state, GameState), "Argument 'game_state' must be of type 'pyrat.GameState'"
# Dimensions
max_weight = max([maze.get_weight(*edge) for edge in maze.get_edges()])
max_weight_len = len(str(max_weight))
max_player_name_len = max([len(player.get_name()) for player in players]) + (max_weight_len + 5 if max_weight > 1 else 0)
max_cell_number_len = len(str(maze.get_width() * maze.get_height() - 1))
cell_width = max(max_player_name_len, max_weight_len, max_cell_number_len + 1) + 2
# Colors
wall_color = "white"
ground_color = "grey_23"
cheese_color = "yellow_1"
mud_color = "orange_1"
path_color = "white"
number_color = "magenta"
# Game elements
ground = self.__colorize(" ", colored.bg(ground_color))
wall = self.__colorize(" ", colored.bg(wall_color) + colored.fg(wall_color), "▉")
cheese = self.__colorize("▲", colored.bg(ground_color) + colored.fg(cheese_color))
mud_horizontal = self.__colorize("━", colored.bg(ground_color) + colored.fg(mud_color))
mud_vertical = self.__colorize("┃", colored.bg(ground_color) + colored.fg(mud_color))
mud_value = lambda number: self.__colorize(str(number), colored.bg(ground_color) + colored.fg(mud_color))
path_horizontal = self.__colorize("╌", colored.bg(ground_color) + colored.fg(path_color))
path_vertical = self.__colorize("┆", colored.bg(ground_color) + colored.fg(path_color))
cell_number = lambda number: self.__colorize(str(number), colored.bg(ground_color) + colored.fg(number_color))
score_cheese = self.__colorize("▲ ", colored.fg(cheese_color))
score_half_cheese = self.__colorize("△ ", colored.fg(cheese_color))
# Player/team elements
teams = {team: self.__colorize(team, colored.fg(9 + list(game_state.teams.keys()).index(team))) for team in game_state.teams}
mud_indicator = lambda player_name: " (" + ("⬇" if maze.coords_difference(game_state.muds[player_name]["target"], game_state.player_locations[player_name]) == (-1, 0) else "⬆" if maze.coords_difference(game_state.muds[player_name]["target"], game_state.player_locations[player_name]) == (1, 0) else "➡" if maze.coords_difference(game_state.muds[player_name]["target"], game_state.player_locations[player_name]) == (0, 1) else "⬅") + " " + str(game_state.muds[player_name]["count"]) + ")" if game_state.muds[player_name]["count"] > 0 else ""
player_names = {player.get_name(): self.__colorize(player.get_name() + mud_indicator(player.get_name()), colored.bg(ground_color) + ("" if len(teams) == 1 else colored.fg(9 + ["team" if player.get_name() in team else 0 for team in game_state.teams.values()].index("team")))) for player in players}
# Game info
environment_str = "Game over" if game_state.game_over() else "Starting turn %d" % game_state.turn if game_state.turn > 0 else "Initial configuration"
team_scores = game_state.get_score_per_team()
for team in game_state.teams:
environment_str += "\n" + score_cheese * int(team_scores[team]) + score_half_cheese * math.ceil(team_scores[team] - int(team_scores[team]))
environment_str += "[" + teams[team] + "] " if len(teams) > 1 or len(team) > 0 else ""
environment_str += " + ".join(["%s (%s)" % (player_in_team, str(round(game_state.score_per_player[player_in_team], 3)).rstrip('0').rstrip('.') if game_state.score_per_player[player_in_team] > 0 else "0") for player_in_team in game_state.teams[team]])
# Consider cells in lexicographic order
environment_str += "\n" + wall * (maze.get_width() * (cell_width + 1) + 1)
for row in range(maze.get_height()):
players_in_row = [game_state.player_locations[player.get_name()] for player in players if maze.i_to_rc(game_state.player_locations[player.get_name()])[0] == row]
cell_height = max([players_in_row.count(cell) for cell in players_in_row] + [max_weight_len]) + 2
environment_str += "\n"
for subrow in range(cell_height):
environment_str += wall
for col in range(maze.get_width()):
# Check cell contents
players_in_cell = [player.get_name() for player in players if game_state.player_locations[player.get_name()] == maze.rc_to_i(row, col)]
cheese_in_cell = maze.rc_to_i(row, col) in game_state.cheese
# Find subrow contents (nothing, cell number, cheese, trace, player)
background = wall if not maze.rc_exists(row, col) else ground
cell_contents = ""
if subrow == 0:
if background != wall and not self._render_simplified:
cell_contents += background
cell_contents += cell_number(maze.rc_to_i(row, col))
elif cheese_in_cell:
if subrow == (cell_height - 1) // 2:
cell_contents = background * ((cell_width - self.__colored_len(cheese)) // 2)
cell_contents += cheese
else:
cell_contents = background * cell_width
else:
first_player_index = (cell_height - len(players_in_cell)) // 2
if first_player_index <= subrow < first_player_index + len(players_in_cell):
cell_contents = background * ((cell_width - self.__colored_len(player_names[players_in_cell[subrow - first_player_index]])) // 2)
cell_contents += player_names[players_in_cell[subrow - first_player_index]]
else:
cell_contents = background * cell_width
environment_str += cell_contents
environment_str += background * (cell_width - self.__colored_len(cell_contents))
# Right separation
right_weight = "0" if not maze.rc_exists(row, col) or not maze.rc_exists(row, col + 1) or not maze.has_edge(maze.rc_to_i(row, col), maze.rc_to_i(row, col + 1)) else str(maze.get_weight(maze.rc_to_i(row, col), maze.rc_to_i(row, col + 1)))
if col == maze.get_width() - 1 or right_weight == "0":
environment_str += wall
else:
if right_weight == "1":
environment_str += path_vertical
elif not self._render_simplified and math.ceil((cell_height - len(right_weight)) / 2) <= subrow < math.ceil((cell_height - len(right_weight)) / 2) + len(right_weight):
digit_number = subrow - math.ceil((cell_height - len(right_weight)) / 2)
environment_str += mud_value(right_weight[digit_number])
else:
environment_str += mud_vertical
environment_str += "\n"
environment_str += wall
# Bottom separation
for col in range(maze.get_width()):
bottom_weight = "0" if not maze.rc_exists(row, col) or not maze.rc_exists(row + 1, col) or not maze.has_edge(maze.rc_to_i(row, col), maze.rc_to_i(row + 1, col)) else str(maze.get_weight(maze.rc_to_i(row, col), maze.rc_to_i(row + 1, col)))
if bottom_weight == "0":
environment_str += wall * (cell_width + 1)
elif bottom_weight == "1":
environment_str += path_horizontal * cell_width + wall
else:
cell_contents = mud_horizontal * ((cell_width - self.__colored_len(bottom_weight)) // 2) + mud_value(bottom_weight) if not self._render_simplified else ""
environment_str += cell_contents
environment_str += mud_horizontal * (cell_width - self.__colored_len(cell_contents)) + wall
# Render
if self.__clear_each_turn:
self.__clear_output()
print(environment_str, file=sys.stderr, flush=True)
# Wait a bit
sleep_time = 1.0 / self._rendering_speed
time.sleep(sleep_time)
##################################################################################
# PRIVATE METHODS #
##################################################################################
def __clear_output (self) -> None:
"""
This method clears the output of the console.
It works in both Jupyter and standard environments.
"""
# If in a Jupyter environment
if "ipykernel" in sys.modules:
from IPython.display import clear_output
clear_output(wait=True)
# If in a standard environment
else:
os.system("cls" if platform.system() == "Windows" else "clear")
##################################################################################
def __colored_len ( self,
text: str
) -> int:
"""
This method returns the true ``len`` of a color-formated string.
Args:
text: Text to measure.
Returns:
The length of the text.
"""
# Debug
assert isinstance(text, str), "Argument 'text' must be a string"
# Return the length of the text without the colorization
text_length = len(re.sub(r"[\u001B\u009B][\[\]()#;?]*((([a-zA-Z\d]*(;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|((\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))", "", text))
return text_length
##################################################################################
def __colorize ( self,
text: str,
colorization: str,
alternate_text: str | None = None
) -> str:
"""
This method colorizes a text.
It does so by adding the colorization to the text and resetting the colorization at the end of the text.
Args:
text: Text to colorize.
colorization: Colorization to use.
alternate_text: Alternate text to use if we don't use colors and the provided text does not fit.
Returns:
The colorized text.
"""
# Debug
assert isinstance(text, str), "Argument 'text' must be a string"
assert isinstance(colorization, str), "Argument 'colorization' must be a string"
assert isinstance(alternate_text, (str, type(None))), "Argument 'alternate_text' must be a string or None"
# If we don't use colors, we return the correct text
if not self.__use_colors:
if alternate_text is None:
colorized_text = str(text)
else:
colorized_text = str(alternate_text)
# If using colors, we return the colorized text
else:
colorized_text = colorization + str(text) + colored.attr(0)
# Return the colorized (or not) text
return colorized_text
##########################################################################################
##########################################################################################