From 42085ee15cbbdbe9ae989c72a4552531fac4c24b Mon Sep 17 00:00:00 2001 From: Tyler Porter Date: Sun, 17 Mar 2024 14:36:15 -0400 Subject: [PATCH] Begin rendering live games --- rewrite/data/game.py | 28 +++--- rewrite/data/pitches.py | 134 +++++++++++++++++++++++++++++ rewrite/data/schedule.py | 7 +- rewrite/presenters/live_game.py | 7 ++ rewrite/screens/components/base.py | 53 ++++++++++++ rewrite/screens/components/out.py | 48 +++++++++++ rewrite/screens/games/live_game.py | 44 +++++++++- rewrite/utils/__init__.py | 6 ++ rewrite/utils/graphics.py | 19 +++- 9 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 rewrite/data/pitches.py create mode 100644 rewrite/presenters/live_game.py create mode 100644 rewrite/screens/components/base.py create mode 100644 rewrite/screens/components/out.py diff --git a/rewrite/data/game.py b/rewrite/data/game.py index 09b044e8..611d5d4a 100644 --- a/rewrite/data/game.py +++ b/rewrite/data/game.py @@ -5,9 +5,10 @@ from data.update_status import UpdateStatus from data import status as GameState from data.team import TeamType +from data.pitches import Pitches from utils import logger as ScoreboardLogger -from utils import value_at_keypath +from utils import format_id, value_at_keypath class Game: @@ -146,12 +147,12 @@ def decision_pitcher_id(self, decision): return value_at_keypath(self.data, f"liveData.decisions.{decision}").get("id", None) def full_name(self, player): - ID = Game.format_id(player) + ID = format_id(player) return value_at_keypath(self.data, f"gameData.players.{ID}").get("fullName", "") def pitcher_stat(self, player, stat, team=None): - ID = Game.format_id(player) + ID = format_id(player) keypath = lambda team, ID: value_at_keypath( self.data, f"liveData.boxscore.teams.{team}.players.{ID}.seasonStats" @@ -163,6 +164,20 @@ def pitcher_stat(self, player, stat, team=None): stats = keypath(team, ID).get("pitching", None) or keypath(team, ID).get("pitching", {}) return stats[stat] + + def man_on(self, base_number): + base = { 1: "first", 2: "second", 3: "third" }.get(base_number, None) + + if not base: + return None + + return value_at_keypath(self.data, f"liveData.linescore.offense.{base}").get("id", None) + + def pitches(self): + return Pitches(self) + + def outs(self): + return value_at_keypath(self.data, "liveData.linescore").get("outs", 0) def pregame_weather(self): return value_at_keypath(self.data, "gameData.weather") @@ -174,13 +189,6 @@ def series_status(self): # TODO: Reimplement series status return "0-0" - @staticmethod - def format_id(ID): - if "ID" in str(ID): - return ID - - return "ID" + str(ID) - """ Home / Away data accessors. diff --git a/rewrite/data/pitches.py b/rewrite/data/pitches.py new file mode 100644 index 00000000..9d080f47 --- /dev/null +++ b/rewrite/data/pitches.py @@ -0,0 +1,134 @@ +from utils import format_id, value_at_keypath + +class Pitches: + + # A list of mlb pitch types appearing in statcast + # from statsapi.meta("pitchTypes") + # Dont change the index, but feel free to change + # the descriptions + + PITCH_LONG = { + "AB": "Auto Ball", # MLB default is "Automatic Ball" + "AS": "Auto Strike", # MLB default is "Automatic Strike" + "CH": "Change-up", + "CU": "Curveball", + "CS": "Slow Curve", + "EP": "Eephus", + "FC": "Cutter", + "FA": "Fastball", + "FF": "Fastball", # MLB default is "Four-Seam Fastball" + "FO": "Forkball", + "FS": "Splitter", + "FT": "2 Seamer", # MLB default is "Two-Seam Fastball" + "GY": "Gyroball", + "IN": "Int Ball", # MLB default is "Intentional Ball" + "KC": "Knuckle Curve", + "KN": "Knuckleball", + "NP": "No Pitch", + "PO": "Pitchout", + "SC": "Screwball", + "SI": "Sinker", + "SL": "Slider", + "ST": "Sweeper", + "SV": "Slurve", + "UN": "Unknown", + } + + PITCH_SHORT = { + "AB": "AB", + "AS": "AS", + "CH": "CH", + "CU": "CU", + "CS": "CS", + "EP": "EP", + "FC": "FC", + "FA": "FA", + "FF": "FF", + "FO": "FO", + "FS": "FS", + "FT": "FT", + "GY": "GY", + "IN": "IN", + "KC": "KC", + "KN": "KN", + "NP": "NP", + "PO": "PO", + "SC": "SC", + "SI": "SI", + "SL": "SL", + "SV": "SV", + "ST": "SW", # MLB default is "ST" + "UN": "UN", + } + + def __init__(self, game): + self.game = game + + self.balls = self.balls() + self.strikes = self.strikes() + self.pitch_count = self.current_pitcher_pitch_count() + + last_pitch = self.last_pitch() + self.last_pitch_speed = "0" + self.last_pitch_type = Pitches.PITCH_SHORT["UN"] + self.last_pitch_type_long = Pitches.PITCH_LONG["UN"] + + if last_pitch: + self.last_pitch_speed = f"{round(last_pitch[0])}" + self.last_pitch_type = Pitches.fetch_short(last_pitch[1]) + self.last_pitch_type_long = Pitches.fetch_long(last_pitch[1]) + + def balls(self): + return self.__fetch_count_part("balls") + + def strikes(self): + return self.__fetch_count_part("strikes") + + def last_pitch(self): + # TODO: Clean this up. + try: + play = self.game.data["liveData"]["plays"].get("currentPlay", {}).get("playEvents", [{}])[-1] + if play.get("isPitch", False): + return ( + play["pitchData"].get("startSpeed", 0), + play["details"]["type"]["code"], + play["details"]["type"]["description"], + ) + except: + return None + + def current_pitcher_pitch_count(self): + # TODO: Clean this up + try: + pitcher_id = self.game.data["liveData"]["linescore"]["defense"]["pitcher"]["id"] + + # TODO: ID formatting probably doesn't belong on Game object if it's being used here + ID = format_id(pitcher_id) + try: + return self.game.data["liveData"]["boxscore"]["teams"]["away"]["players"][ID]["stats"]["pitching"][ + "numberOfPitches" + ] + except: + return self.game.data["liveData"]["boxscore"]["teams"]["home"]["players"][ID]["stats"]["pitching"][ + "numberOfPitches" + ] + except: + return 0 + + def __fetch_count_part(self, part): + return value_at_keypath(self.game.data, "liveData.linescore").get(part, 0) + + @staticmethod + def fetch_long(value): + return Pitches.PITCH_LONG.get(value, Pitches.PITCH_LONG["UN"]) + + @staticmethod + def fetch_short(value): + return Pitches.PITCH_SHORT.get(value, Pitches.PITCH_SHORT["UN"]) + + def __str__(self) -> str: + return ( + f"Count: {self.balls} - {self.strikes}. " + + f"Last pitch: {self.last_pitch_speed}mph {self.last_pitch_type} {self.last_pitch_type_long} " + + f" Total pitches: {self.pitch_count}" + ) diff --git a/rewrite/data/schedule.py b/rewrite/data/schedule.py index 0a7b60af..21d18906 100644 --- a/rewrite/data/schedule.py +++ b/rewrite/data/schedule.py @@ -36,7 +36,12 @@ def update(self): return UpdateStatus.FAIL - self.game = self.games[self.__next_game_index()] + # TODO: remove debug + # self.game = self.games[self.__next_game_index()] + for game in self.games: + if game.is_live(): + self.game = game + break self.__request_transition_to_game(self.game) diff --git a/rewrite/presenters/live_game.py b/rewrite/presenters/live_game.py new file mode 100644 index 00000000..7d7af47d --- /dev/null +++ b/rewrite/presenters/live_game.py @@ -0,0 +1,7 @@ +class LiveGamePresenter: + def __init__(self, game, config): + self.game = game + self.config = config + + def batter_count_text(self): + return "{}-{}".format(self.game.pitches().balls, self.game.pitches().strikes) \ No newline at end of file diff --git a/rewrite/screens/components/base.py b/rewrite/screens/components/base.py new file mode 100644 index 00000000..978496ad --- /dev/null +++ b/rewrite/screens/components/base.py @@ -0,0 +1,53 @@ +from driver import graphics + +class Base: + def __init__(self, number, screen): + self.number = number + self.screen = screen + + def render(self): + color = self.colors.graphics_color(f"bases.{self.number}B") + coords = self.layout.coords(f"bases.{self.number}B") + + self.__render_outline(coords, color) + + if self.game.man_on(self.number): + self.__render_runner(coords, color) + + def __render_outline(self, coords, color): + x, y = coords.x, coords.y + size = coords.size + half = abs(size // 2) + + graphics.DrawLine(self.canvas, x + half, y, x, y + half, color) + graphics.DrawLine(self.canvas, x + half, y, x + size, y + half, color) + graphics.DrawLine(self.canvas, x + half, y + size, x, y + half, color) + graphics.DrawLine(self.canvas, x + half, y + size, x + size, y + half, color) + + def __render_runner(self, coords, color): + x, y = coords.x, coords.y + size = coords.size + half = abs(size // 2) + for offset in range(1, half + 1): + graphics.DrawLine(self.canvas, x + half - offset, y + size - offset, x + half + offset, y + size - offset, color) + graphics.DrawLine(self.canvas, x + half - offset, y + offset, x + half + offset, y + offset, color) + + @property + def game(self): + return self.screen.game + + @property + def canvas(self): + return self.screen.canvas + + @property + def config(self): + return self.screen.config + + @property + def colors(self): + return self.screen.colors + + @property + def layout(self): + return self.screen.layout \ No newline at end of file diff --git a/rewrite/screens/components/out.py b/rewrite/screens/components/out.py new file mode 100644 index 00000000..6975db33 --- /dev/null +++ b/rewrite/screens/components/out.py @@ -0,0 +1,48 @@ +from driver import graphics + +from utils.graphics import DrawRect + +class Out: + def __init__(self, number, screen): + self.number = number + self.screen = screen + + def render(self): + color = self.colors.graphics_color(f"outs.{self.number}") + coords = self.layout.coords(f"outs.{self.number}") + + if self.game.outs() >= self.number: + self.__render_out(coords, color) + else: + self.__render_outline(coords, color) + + + def __render_outline(self, coords, color): + x, y, size = coords.x, coords.y, coords.size + + DrawRect(self.canvas, x, y, size, size, color, filled=False) + + def __render_out(self, coords, color): + x, y, size = coords.x, coords.y, coords.size + + DrawRect(self.canvas, x, y, size, size, color, filled=True) + + @property + def game(self): + return self.screen.game + + @property + def canvas(self): + return self.screen.canvas + + @property + def config(self): + return self.screen.config + + @property + def colors(self): + return self.screen.colors + + @property + def layout(self): + return self.screen.layout \ No newline at end of file diff --git a/rewrite/screens/games/live_game.py b/rewrite/screens/games/live_game.py index f557c449..c66f565c 100644 --- a/rewrite/screens/games/live_game.py +++ b/rewrite/screens/games/live_game.py @@ -1,14 +1,52 @@ from driver import graphics from screens.games.base import GameScreen +from screens.components.base import Base +from screens.components.out import Out +from presenters.live_game import LiveGamePresenter class LiveGameScreen(GameScreen): MAX_DURATION_SECONDS = 5 + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.bases = [ + Base(1, self), + Base(2, self), + Base(3, self) + ] + + self.outs = [ + Out(1, self), + Out(2, self), + Out(3, self), + ] + def render(self): - game_text = "It's a game!" + presenter = self.create_cached_object("live_game_presenter", LiveGamePresenter, self.game, self.config) + + self.__render_bases() + self.__render_outs() + self.__render_count(presenter) + + # Overlay banners + self.away_team_banner.render() + self.home_team_banner.render() + + def __render_bases(self): + for base in self.bases: + base.render() - font, font_size = self.config.layout.font("4x6") + def __render_outs(self): + for out in self.outs: + out.render() - graphics.DrawText(self.canvas, font, 0, 10, (255, 255, 255), game_text) + def __render_count(self, presenter): + text = presenter.batter_count_text() + font, font_size = self.layout.font_for("batter_count") + coords = self.layout.coords("batter_count") + color = self.colors.graphics_color("batter_count") + + graphics.DrawText(self.canvas, font, coords.x, coords.y, color, text) diff --git a/rewrite/utils/__init__.py b/rewrite/utils/__init__.py index e0ddcaa5..4946efce 100644 --- a/rewrite/utils/__init__.py +++ b/rewrite/utils/__init__.py @@ -210,3 +210,9 @@ def value_at_keypath(current, keypath): current = current.get(key, {}) return current + +def format_id(ID): + if "ID" in str(ID): + return ID + + return "ID" + str(ID) diff --git a/rewrite/utils/graphics.py b/rewrite/utils/graphics.py index 20f33dc4..ab81ec3d 100644 --- a/rewrite/utils/graphics.py +++ b/rewrite/utils/graphics.py @@ -1,7 +1,13 @@ from driver import graphics -def DrawRect(canvas, x, y, width, height, color): +def DrawRect(canvas, x, y, width, height, color, filled=True): + if filled: + _DrawFilledRect(canvas, x, y, width, height, color) + else: + _DrawUnfilledRect(canvas, x, y, width, height, color) + +def _DrawFilledRect(canvas, x, y, width, height, color): """ Draws a rectangle on screen with (X, Y) given as screen coordinates where (0, 0) is top left. @@ -13,3 +19,14 @@ def DrawRect(canvas, x, y, width, height, color): else: for offset in range(0, width): graphics.DrawLine(canvas, x + offset, y, x + offset, y + height - 1, color) + +def _DrawUnfilledRect(canvas, x, y, width, height, color): + # Top horizontal + graphics.DrawLine(canvas, x, y, x + width, y, color) + # Bottom horizontal + graphics.DrawLine(canvas, x, y + height, x + width, y + height, color) + # Left vertical + graphics.DrawLine(canvas, x, y, x, y + height, color) + # Right vertical + graphics.DrawLine(canvas, x + width, y, x + width, y + height, color) + \ No newline at end of file