diff --git a/rewrite/config/colors.py b/rewrite/config/colors.py index 851e448d..5f5de96f 100644 --- a/rewrite/config/colors.py +++ b/rewrite/config/colors.py @@ -1,7 +1,7 @@ import os, sys from utils import logger as ScoreboardLogger -from utils import deep_update, read_json +from utils import value_at_keypath, deep_update, read_json class Colors: @@ -10,6 +10,8 @@ class Colors: TEAM_COLORS_REFERENCE_FILENAME = "teams.json.example" SCOREBOARD_COLORS_REFERENCE_FILENAME = "scoreboard.json.example" + DEFAULT_COLOR = (0, 0, 0) + def __init__(self): self._team_json = self.__fetch_colors(Colors.TEAM_COLORS_REFERENCE_FILENAME) self._scoreboard_json = self.__fetch_colors(Colors.SCOREBOARD_COLORS_REFERENCE_FILENAME) @@ -31,3 +33,17 @@ def __fetch_colors(self, reference_filename): return colors return reference_colors + + def team_graphics_color(self, keypath): + return self.__fetch_color(self._team_json, keypath) + + def graphics_color(self, keypath): + return self.__fetch_color(self._scoreboard_json, keypath) + + def __fetch_color(self, config, keypath): + color = value_at_keypath(config, keypath) + + if color: + return (color["r"], color["g"], color["b"]) + + return Colors.DEFAULT_COLOR diff --git a/rewrite/data/game.py b/rewrite/data/game.py index 9c0e0c6f..04d2858d 100644 --- a/rewrite/data/game.py +++ b/rewrite/data/game.py @@ -117,3 +117,39 @@ def datetime(self): time_utc = self.data["gameData"]["datetime"]["dateTime"] return dt.fromisoformat(time_utc.replace("Z", "+00:00")) + + def home_score(self): + return self.data["liveData"]["linescore"]["teams"]["home"].get("runs", 0) + + def away_score(self): + return self.data["liveData"]["linescore"]["teams"]["away"].get("runs", 0) + + def home_hits(self): + return self.data["liveData"]["linescore"]["teams"]["home"].get("hits", 0) + + def away_hits(self): + return self.data["liveData"]["linescore"]["teams"]["away"].get("hits", 0) + + def home_errors(self): + return self.data["liveData"]["linescore"]["teams"]["home"].get("errors", 0) + + def away_errors(self): + return self.data["liveData"]["linescore"]["teams"]["away"].get("errors", 0) + + def winning_team(self): + if self.status == GameState.FINAL: + if self.home_score() > self.away_score(): + return "home" + if self.home_score() < self.away_score(): + return "away" + + return None + + def losing_team(self): + opposite = {"home": "away", "away": "home"} + + return opposite.get(self.winning_team(), None) + + def series_status(self): + # TODO: Reimplement series status + return "0-0" diff --git a/rewrite/presenters/postgame.py b/rewrite/presenters/postgame.py new file mode 100644 index 00000000..61b7289b --- /dev/null +++ b/rewrite/presenters/postgame.py @@ -0,0 +1,49 @@ +class PostgamePresenter: + PITCHER_UNKNOWN = "Unknown" + + def __init__(self, game): + winner_side = game.winning_team() + + # Defaults + self.winning_pitcher = PostgamePresenter.PITCHER_UNKNOWN + self.winning_pitcher_wins = 0 + self.winning_pitcher_losses = 0 + self.losing_pitcher = PostgamePresenter.PITCHER_UNKNOWN + self.losing_pitcher_wins = 0 + self.losing_pitcher_losses = 0 + self.save_pitcher = None + self.save_pitcher_saves = None + + # winner = game.decision_pitcher_id("winner") + # if winner is not None: + # self.winning_pitcher = game.full_name(winner) + # self.winning_pitcher_wins = game.pitcher_stat(winner, "wins", winner_side) + # self.winning_pitcher_losses = game.pitcher_stat(winner, "losses", winner_side) + + # save = game.decision_pitcher_id("save") + # if save is not None: + # self.save_pitcher = game.full_name(save) + # self.save_pitcher_saves = game.pitcher_stat(save, "saves", winner_side) + + # loser = game.decision_pitcher_id("loser") + # if loser is not None: + # loser_side = game.losing_team() + # self.losing_pitcher = game.full_name(loser) + # self.losing_pitcher_wins = game.pitcher_stat(loser, "wins", loser_side) + # self.losing_pitcher_losses = game.pitcher_stat(loser, "losses", loser_side) + + self.series_status = game.series_status() + + def __str__(self): + return "<{} {}> W: {} {}-{}; L: {} {}-{}; S: {} ({})".format( + self.__class__.__name__, + hex(id(self)), + self.winning_pitcher, + self.winning_pitcher_wins, + self.winning_pitcher_losses, + self.losing_pitcher, + self.losing_pitcher_wins, + self.losing_pitcher_losses, + self.save_pitcher, + self.save_pitcher_saves, + ) diff --git a/rewrite/screens/base.py b/rewrite/screens/base.py index 8fd6b44f..ba80fe94 100644 --- a/rewrite/screens/base.py +++ b/rewrite/screens/base.py @@ -32,8 +32,25 @@ def ready_to_transition(self): """ return True + def create_cached_object(self, name, klass, *args, **kwargs): + """ + Creates an object and caches it between render cycles. The arguments and keyword arguments are passed to the class's constructor and stored in cache under + the configured name. Repeated calls to this function fetch from the cache instead of re-initializing. + """ + if not hasattr(self, "_object_cache"): + self._object_cache = {} + + if name in self._object_cache: + return self._object_cache.get(name) + + cached_object = klass(*args, **kwargs) + self._object_cache[name] = cached_object + + return cached_object + @track_duration def _render(self): + self.canvas.Fill(*self.background_color) self.render() @property @@ -55,3 +72,18 @@ def config(self): @property def layout(self): return self.config.layout + + @property + def colors(self): + return self.config.colors + + @property + def background_color(self): + """ + The default background can be overridden in child classes. + """ + return self.colors.graphics_color("default.background") + + @property + def default_text_color(self): + return self.colors.graphics_color("default.text") diff --git a/rewrite/screens/games/postgame.py b/rewrite/screens/games/postgame.py index f6d22239..5506cb99 100644 --- a/rewrite/screens/games/postgame.py +++ b/rewrite/screens/games/postgame.py @@ -1,40 +1,61 @@ -import os, time - from driver import graphics from screens.games.base import GameScreen +from presenters.postgame import PostgamePresenter + +from utils.text import ScrollingText, CenteredText -class PostGameScreen(GameScreen): + +class PostgameScreen(GameScreen): MAX_DURATION_SECONDS = 5 def render(self): - game_text = "It's a post-game!" + presenter = self.create_cached_object("postgame_presenter", PostgamePresenter, self.game) + + self.__render_final_inning(presenter) + self.__render_decision_scroll(presenter) - font, font_size = self.config.layout.font("4x6") + def __render_final_inning(self, presenter): + text = "FINAL" + color = self.colors.graphics_color("final.inning") + coords = self.layout.coords("final.inning") + font, font_size = self.layout.font_for("final.inning") - graphics.DrawText(self.canvas, font, 0, 10, (255, 255, 255), game_text) + # TODO: No concept of a "scoreboard" yet + # if scoreboard.inning.number != NORMAL_GAME_LENGTH: + # text += " " + str(scoreboard.inning.number) - def _render_decision_scroll(self): - coords = self.manager.layout.coords("final.scrolling_text") - font, font_size = self.manager.layout.font("final.scrolling_text") + center_text = self.create_cached_object( + "postgame_center_text", CenteredText, self.canvas, coords.x, coords.y, font, font_size, color, text + ) + center_text.render_text() + + # TODO: Handle no-hitters + # if layout.state_is_nohitter(): + # nohit_text = nohitter._get_nohitter_text(layout) + # nohit_coords = layout.coords("final.nohit_text") + # nohit_color = colors.graphics_color("final.nohit_text") + # nohit_font = layout.font("final.nohit_text") + # graphics.DrawText(canvas, nohit_font["font"], nohit_coords["x"], nohit_coords["y"], nohit_color, nohit_text) - # color = colors.graphics_color("final.scrolling_text") - # bgcolor = colors.graphics_color("default.background") + def __render_decision_scroll(self, presenter): + coords = self.layout.coords("final.scrolling_text") + font, font_size = self.layout.font_for("final.scrolling_text") - color = (255, 255, 255) - bgcolor = (0, 0, 0) + color = self.colors.graphics_color("final.scrolling_text") + bgcolor = self.colors.graphics_color("default.background") - scroll_text = "W: {} {}-{} L: {} {}-{}".format( - self.game.winning_pitcher, - self.game.winning_pitcher_wins, - self.game.winning_pitcher_losses, - self.game.losing_pitcher, - self.game.losing_pitcher_wins, - self.game.losing_pitcher_losses, + text = "W: {} {}-{} L: {} {}-{}".format( + presenter.winning_pitcher, + presenter.winning_pitcher_wins, + presenter.winning_pitcher_losses, + presenter.losing_pitcher, + presenter.losing_pitcher_wins, + presenter.losing_pitcher_losses, ) - if False and self.game.save_pitcher: + if presenter.save_pitcher: scroll_text += " SV: {} ({})".format(self.game.save_pitcher, self.game.save_pitcher_saves) # TODO: Playoffs @@ -44,3 +65,19 @@ def _render_decision_scroll(self): # return scrollingtext.render_text( # canvas, coords["x"], coords["y"], coords["width"], font, color, bgcolor, scroll_text, text_pos # ) + + scroller = self.create_cached_object( + "postgame_scroller", + ScrollingText, + self.canvas, + coords.x, + coords.y, + coords.width, + coords.width, + font, + font_size, + color, + bgcolor, + text, + ) + scroller.render_text() diff --git a/rewrite/screens/screen_manager.py b/rewrite/screens/screen_manager.py index f614cf2d..ad1e5df9 100644 --- a/rewrite/screens/screen_manager.py +++ b/rewrite/screens/screen_manager.py @@ -8,7 +8,7 @@ from screens.weather import WeatherScreen from screens.games.pregame import PregameScreen from screens.games.live_game import LiveGameScreen -from screens.games.postgame import PostGameScreen +from screens.games.postgame import PostgameScreen class ScreenManager: @@ -18,7 +18,7 @@ class ScreenManager: Screen.WEATHER: WeatherScreen, Screen.PREGAME: PregameScreen, Screen.LIVE_GAME: LiveGameScreen, - Screen.POSTGAME: PostGameScreen, + Screen.POSTGAME: PostgameScreen, } def __init__(self, matrix, canvas, config, queue): diff --git a/rewrite/screens/static.py b/rewrite/screens/static.py index f9ee2ff5..72364053 100644 --- a/rewrite/screens/static.py +++ b/rewrite/screens/static.py @@ -27,6 +27,7 @@ def __init__(self, *args, **kwargs): self.manager.matrix.SetImage(logo.convert("RGB")) def render(self): + # TODO: Clearing screen resets this image pass def ready_to_transition(self): diff --git a/rewrite/utils/text.py b/rewrite/utils/text.py new file mode 100644 index 00000000..64d07a2f --- /dev/null +++ b/rewrite/utils/text.py @@ -0,0 +1,143 @@ +from driver import graphics + +from dataclasses import dataclass + + +@dataclass +class TextPosition: + x: int = 0 + y: int = 0 + + +class ScrollingText: + def __init__(self, canvas, x, y, width, start_x, font, font_size, text_color, bg_color, text, center=True): + # Matrix + self.canvas = canvas + + # Start X and Y + self.end_position = TextPosition(x=x, y=y) + + # Font options + self.font = font + self.font_size = font_size + self.text_color = text_color + self.bg_color = bg_color + + # Text + self.text = text + self._centered_text = None + + # Scroll options + self.width = width + self.center = center + + # Current position + self.position = TextPosition(x=start_x, y=y) + self.finished = False + + def __should_scroll(self): + return len(self.text) * self.font_size[0] > self.width + + def render_text(self): + if self.finished: + return + + if self.__should_scroll(): + self.__render_scroll_text() + else: + self.__render_static_text(self.center) + self.finished = True + + def __render_scroll_text(self): + # This is done so that we don't need to draw a huge bar to the left of the text. + # Used primarily in the 128x32 layout. + # See similar work done here https://github.com/ty-porter/RGBMatrixEmulator/pull/3 + font_w, font_h = self.font_size + total_width = font_w * len(self.text) + + text, offset = self.__truncate_text(self.text, *self.font_size) + + if len(text) == 0: + self.finished = True + + return + + for y_offset in range(0, font_h): + graphics.DrawLine( + self.canvas, + min(0, self.end_position.x - font_w), + self.end_position.y + 1 - font_h + y_offset, + min(self.width, self.canvas.width), + self.end_position.y + 1 - font_h + y_offset, + self.bg_color, + ) + + graphics.DrawText(self.canvas, self.font, self.position.x + offset, self.position.y, self.text_color, text) + + self.__perform_scroll(total_width) + + def __render_static_text(self, center): + if center: + if self._centered_text is None: + self._centered_text = CenteredText( + self.canvas, + self.position.x, + self.position.y, + self.font, + self.font_size, + self.text_color, + self.text, + bg_color=None, + ) + + self._centered_text.render_text() + else: + graphics.DrawText(self.canvas, self.font, self.position.x, self.position.y, self.text_color, self.text) + + def __truncate_text(self, text, font_w, font_h): + text = self.text + + # Offscreen to the left, adjust by first character width + if self.position.x - self.end_position.x < 0: + adjustment = abs(self.position.x - self.end_position.x) // font_w + text = text[adjustment:] + + return (text, adjustment * font_w) + + return (text, 0) + + def __perform_scroll(self, text_width): + next_x = self.position.x - 1 + + if text_width + next_x < 0: + self.finished = True + + return + + self.position.x = next_x + + +class CenteredText: + def __init__(self, canvas, x, y, font, font_size, text_color, text, bg_color=None): + # Matrix + self.canvas = canvas + + # Font options + self.font = font + self.font_size = font_size + self.text_color = text_color + self.bg_color = bg_color + + # Text + self.text = text + + # Current position + self._position = TextPosition(x=x, y=y) + self.position = TextPosition(x=self.__calculate_center(), y=y) + + def render_text(self): + # TODO: Add background optional + graphics.DrawText(self.canvas, self.font, self.position.x, self.position.y, self.text_color, self.text) + + def __calculate_center(self): + return abs(self._position.x - ((len(self.text) * self.font_size[0]) // 2))