pacai.core.spritesheet
A sprite sheet is an image that contains images (or "sprites") for different components of the game/board. In pacai, a sprite sheet is composed of two files: a JSON config and a png/jpg image that contains the actual pixels.
The JSON config is a JSON object that contains the following key/values:
- filename: str
- height: int
- width: int
- background: Color
- highlight: Color
- default: Coordinate
- markers: dict[pacai.core.board.Marker, MarkerSprites]
Coordinates
represent locations within the sprite sheet,
it is an array of two integers representing the row and column.
The top left is the origin, and the coordinates already take the height/width into account
(so, [1, 1] has a top left corner at pixel (height, width)).
Color
is an RGB tuple of int (all between 0 and 255) that represents an RGB color.
Default background
is [0, 0, 0] (black).
Default highlight
is [255, 0, 0] (red).
Default text
is [255, 255, 255] (white).
MarkerSprites
represent the sprite information for a single marker.
They are JSON objects with the following key/values:
- default: Coordinate
- actions: dict[pacai.core.action.Action, list[Coordinate]]
- adjacency: dict[pacai.core.board.AdjacencyString, Coordinate]
The actions
value is a JSON object where the keys are actions (pacai.core.action.Action)
and the values are lists of coordinates.
Each coordinate represents one animation frame.
These animation frames can be cycled through to give the graphics some liveliness
(e.g. a pacman moving in the same direction can open and close their mouth).
The adjacency
object indicates sprites to use when there are objects that are adjacent to the context object.
They are keyed by and pacai.core.board.AdjacencyString and contain a single coordinate as the value
(these do not animate).
This key is typically used for walls so that they can come together in reasonable ways.
When used for walls, a T indicates that there should be an opening in the specified direction.
1""" 2A sprite sheet is an image that contains images (or "sprites") for different components of the game/board. 3In pacai, a sprite sheet is composed of two files: a JSON config and a png/jpg image that contains the actual pixels. 4 5The JSON config is a JSON object that contains the following key/values: 6 - filename: str 7 - height: int 8 - width: int 9 - background: Color 10 - highlight: Color 11 - default: Coordinate 12 - markers: dict[pacai.core.board.Marker, MarkerSprites] 13 14`Coordinates` represent locations within the sprite sheet, 15it is an array of two integers representing the row and column. 16The top left is the origin, and the coordinates already take the height/width into account 17(so, [1, 1] has a top left corner at pixel (height, width)). 18 19`Color` is an RGB tuple of int (all between 0 and 255) that represents an RGB color. 20 21Default `background` is [0, 0, 0] (black). 22Default `highlight` is [255, 0, 0] (red). 23Default `text` is [255, 255, 255] (white). 24 25`MarkerSprites` represent the sprite information for a single marker. 26They are JSON objects with the following key/values: 27 - default: Coordinate 28 - actions: dict[pacai.core.action.Action, list[Coordinate]] 29 - adjacency: dict[pacai.core.board.AdjacencyString, Coordinate] 30 31The `actions` value is a JSON object where the keys are actions (pacai.core.action.Action) 32and the values are lists of coordinates. 33Each coordinate represents one animation frame. 34These animation frames can be cycled through to give the graphics some liveliness 35(e.g. a pacman moving in the same direction can open and close their mouth). 36 37The `adjacency` object indicates sprites to use when there are objects that are adjacent to the context object. 38They are keyed by and pacai.core.board.AdjacencyString and contain a single coordinate as the value 39(these do not animate). 40This key is typically used for walls so that they can come together in reasonable ways. 41When used for walls, a T indicates that there should be an opening in the specified direction. 42""" 43 44import os 45import typing 46 47import PIL.Image 48import edq.util.json 49 50import pacai.core.board 51 52THIS_DIR: str = os.path.join(os.path.dirname(os.path.realpath(__file__))) 53SPRITE_SHEETS_DIR: str = os.path.join(THIS_DIR, '..', 'resources', 'spritesheets') 54 55KEY_ACTIONS: str = 'actions' 56KEY_ADJACENCY: str = 'adjacency' 57KEY_BACKGROUND: str = 'background' 58KEY_DEFAULT: str = 'default' 59KEY_FILENAME: str = 'filename' 60KEY_HEIGHT: str = 'height' 61KEY_HIGHLIGHT: str = 'highlight' 62KEY_MARKERS: str = 'markers' 63KEY_TEXT: str = 'text' 64KEY_WIDTH: str = 'width' 65 66DEFAULT_BACKGROUND: tuple[int, int, int] = (0, 0, 0) 67DEFAULT_HIGHLIGHT: tuple[int, int, int] = (255, 0, 0) 68DEFAULT_TEXT: tuple[int, int, int] = (255, 255, 255) 69 70class SpriteSheet: 71 """ 72 Sprite sheets contain all the graphics for the markers on a board. 73 They are typically loaded from JSON files which define 74 what each sprite represents. 75 """ 76 77 def __init__(self, 78 height: int, width: int, 79 background: tuple[int, int, int], 80 highlight: tuple[int, int, int], 81 text: tuple[int, int, int], 82 default_sprite: PIL.Image.Image | None, 83 default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image], 84 action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]], 85 adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]], 86 ) -> None: 87 self.height: int = height 88 """ The height of a sprite. """ 89 90 self.width: int = width 91 """ The width of a sprite. """ 92 93 self.background: tuple[int, int, int] = background 94 """ The color (RGB) for the image's background. """ 95 96 self.highlight: tuple[int, int, int] = highlight 97 """ The color (RGB) for the image's highlight. """ 98 99 self.text: tuple[int, int, int] = text 100 """ The color (RGB) for the image's text color. """ 101 102 self._default_sprite: PIL.Image.Image | None = default_sprite 103 """ The fallback sprite to use for the entire sprite sheet. """ 104 105 self._default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image] = default_marker_sprites 106 """ The fallback sprite to use for each marker. """ 107 108 self._action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]] = action_sprites 109 """ 110 Sprites that represent a marker taking a specific action. 111 Each list of sprites represents a animation. 112 Each animation must have at least one image 113 (which visually makes it not an animation, 114 but in our terminology an animation is just an ordered list of images). 115 """ 116 117 self._adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]] = adjacency_sprites 118 """ 119 Sprites that represent different versions when there are objects in specific directions. 120 """ 121 122 self._animation_counts: dict[str, dict[pacai.core.board.Marker, tuple[pacai.core.action.Action, int]]] = {} 123 """ 124 Keep track of the number of times a marker/action pair has been called for, 125 so we can keep track of animations. 126 The first key is the "animation key" that will allow multiple sources to use the same SpriteSheet 127 and still keep their animations separate. 128 Note that any action subsequent calls with different actions will reset the count 129 (only repeated actions will result in an incrementing count). 130 """ 131 132 def get_sprite(self, 133 marker: pacai.core.board.Marker | None = None, 134 action: pacai.core.action.Action | None = None, 135 adjacency: pacai.core.board.AdjacencyString | None = None, 136 animation_key: str | None = None, 137 ) -> PIL.Image.Image: 138 """ 139 Get the sprite for the requested marker/action pair. 140 If there is no sprite for that specific pairing, fall back to the default for the marker. 141 If the marker has no default, fall back to the sheet's default. 142 If the sheet also has no default, raise an error. 143 144 Both `action` and `adjacency` should not be provided, 145 but they can both be missing (a default sprite will be returned). 146 147 This method will keep track of animations and return the next animation frame during subsequent calls. 148 Each unique `animation_key` will have its own set of tracking of animations. 149 This allows different components to share a SpriteSheet without stepping on each others animations. 150 A value of None will always result in fetching the first animation frame. 151 """ 152 153 if ((action is not None) and (adjacency is not None)): 154 raise ValueError("Both adjacency and action cannot be non-None.") 155 156 # Start with the default sprite. 157 sprite = self._default_sprite 158 159 if (marker is not None): 160 # Check for a marker default sprite. 161 sprite = self._default_marker_sprites.get(marker, sprite) 162 163 # Check the actions or adjacency for a more specific sprite. 164 if (action is not None): 165 animation_count = self._next_animation_count(marker, action, animation_key) 166 animation_frames = self._action_sprites.get(marker, {}).get(action, []) 167 if (len(animation_frames) > 0): 168 sprite = animation_frames[animation_count % len(animation_frames)] 169 elif (adjacency is not None): 170 # Pull from the adjacency sprites, but fallback to the current sprite. 171 sprite = self._adjacency_sprites.get(marker, {}).get(adjacency, sprite) 172 173 if (sprite is None): 174 raise ValueError(f"Could not find a matching sprite for action = '{action}', adjacency = '{adjacency}', key = '{animation_key}').") 175 176 return sprite 177 178 def _next_animation_count(self, marker: pacai.core.board.Marker, action: pacai.core.action.Action, animation_key: str | None = None) -> int: 179 """ 180 Get the next animation count for the requested resource, 181 and manage the existing count 182 (increment for repeated actions, and reset for different actions). 183 """ 184 185 if (animation_key is None): 186 return 0 187 188 if (animation_key not in self._animation_counts): 189 self._animation_counts[animation_key] = {} 190 191 animation_keys = self._animation_counts[animation_key] 192 193 if (marker not in animation_keys): 194 animation_keys[marker] = (action, -1) 195 196 info = animation_keys[marker] 197 198 # If we got the same action, increment the animation count. 199 # If we got a different action, reset the count to zero for this new action. 200 if (action == info[0]): 201 info = (action, info[1] + 1) 202 else: 203 info = (action, 0) 204 205 # Update the count. 206 animation_keys[marker] = info 207 208 return info[1] 209 210 def clear_animation_counts(self, animation_key: str | None = None) -> None: 211 """ 212 Clear the animation counts (position of the animation) for the given key, 213 or all counts if no key is provided. 214 """ 215 216 if (animation_key is None): 217 self._animation_counts = {} 218 else: 219 self._animation_counts[animation_key] = {} 220 221 def position_to_pixels(self, position: pacai.core.board.Position) -> tuple[int, int]: 222 """ Get the pixels (x, y) for this position. """ 223 224 return (position.col * self.width, position.row * self.height) 225 226def load(path: str) -> SpriteSheet: 227 """ 228 Load a sprite sheet from a file. 229 If the given path does not exist, 230 try to prefix the path with the standard sprite sheet directory and suffix with the standard extension. 231 """ 232 233 raw_path = path 234 235 # If the path does not exist, try the sprite sheets directory. 236 if (not os.path.exists(path)): 237 path = os.path.join(SPRITE_SHEETS_DIR, path) 238 239 # If this path does not have a good extension, add one. 240 if (os.path.splitext(path)[-1] != '.json'): 241 path = path + '.json' 242 243 if (not os.path.exists(path)): 244 raise ValueError(f"Could not find sprite sheet, path does not exist: '{raw_path}'.") 245 246 try: 247 return _load(path) 248 except Exception as ex: 249 raise ValueError(f"Error loading sprite sheet config: '{path}'.") from ex 250 251def _load(config_path: str) -> SpriteSheet: 252 config = edq.util.json.load_path(config_path) 253 height, width, sprites = _load_sprites(config, config_path) 254 255 background: tuple[int, int, int] = _parse_color(config, KEY_BACKGROUND, DEFAULT_BACKGROUND) 256 highlight: tuple[int, int, int] = _parse_color(config, KEY_HIGHLIGHT, DEFAULT_HIGHLIGHT) 257 text: tuple[int, int, int] = _parse_color(config, KEY_TEXT, DEFAULT_TEXT) 258 259 default_sprite: PIL.Image.Image | None = None 260 default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image] = {} 261 action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]] = {} 262 adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]] = {} 263 264 default_coordinate = config.get(KEY_DEFAULT, None) 265 if (default_coordinate is not None): 266 default_sprite = _fetch_coordinate(sprites, default_coordinate) 267 268 for (marker, marker_sprites) in config.get(KEY_MARKERS, {}).items(): 269 default, action, adjacency = _fetch_marker_sprites(sprites, marker_sprites) 270 271 if (default is not None): 272 default_marker_sprites[marker] = default 273 274 if (len(action) > 0): 275 action_sprites[marker] = action 276 277 if (len(adjacency) > 0): 278 adjacency_sprites[marker] = adjacency 279 280 return SpriteSheet(height, width, background, highlight, text, default_sprite, default_marker_sprites, action_sprites, adjacency_sprites) 281 282def _fetch_marker_sprites( 283 sprites: list[list[PIL.Image.Image]], 284 marker_sprites: dict[str, typing.Any] 285 ) -> tuple[ 286 PIL.Image.Image | None, 287 dict[pacai.core.action.Action, list[PIL.Image.Image]], 288 dict[pacai.core.board.AdjacencyString, PIL.Image.Image]]: 289 default_sprite: PIL.Image.Image | None = None 290 action_sprites: dict[pacai.core.action.Action, list[PIL.Image.Image]] = {} 291 adjacency_sprites: dict[pacai.core.board.AdjacencyString, PIL.Image.Image] = {} 292 293 default_coordinate = marker_sprites.get(KEY_DEFAULT, None) 294 if (default_coordinate is not None): 295 default_sprite = _fetch_coordinate(sprites, default_coordinate) 296 297 for (action, coordinates) in marker_sprites.get(KEY_ACTIONS, {}).items(): 298 if (len(coordinates) == 0): 299 continue 300 301 action_sprites[action] = [] 302 for coordinate in coordinates: 303 action_sprites[action].append(_fetch_coordinate(sprites, coordinate)) 304 305 for (adjacency, coordinate) in marker_sprites.get(KEY_ADJACENCY, {}).items(): 306 adjacency_sprites[adjacency] = _fetch_coordinate(sprites, coordinate) 307 308 return (default_sprite, action_sprites, adjacency_sprites) 309 310def _fetch_coordinate(sprites: list[list[PIL.Image.Image]], raw_coordinate: typing.Any) -> PIL.Image.Image: 311 (row, col) = _parse_coordinate(raw_coordinate) 312 return sprites[row][col] 313 314def _parse_color(config: dict[str, typing.Any], key: str, default: tuple[int, int, int]) -> tuple[int, int, int]: 315 if (key not in config): 316 return default 317 318 color = config.get(key) 319 if ((not isinstance(color, list)) or (len(color) != 3)): 320 raise ValueError(f"Color must be a list of three ints, found: '{str(color)}'.") 321 322 color = ( 323 _parse_int(color[0]), 324 _parse_int(color[1]), 325 _parse_int(color[2]), 326 ) 327 328 for (i, component) in enumerate(color): 329 if ((component < 0) or (component > 255)): 330 raise ValueError(f"Color component {i} is out of bounds, must be between 0 and 255. Found: '{color}'.") 331 332 return color 333 334def _parse_coordinate(raw_coordinate: typing.Any) -> tuple[int, int]: 335 if (not isinstance(raw_coordinate, list)): 336 raise ValueError(f"Coordinate is not a list/array: '{str(raw_coordinate)}'.") 337 338 if (len(raw_coordinate) != 2): 339 raise ValueError(f"Did not find exactly two items in a coordinate, found {len(raw_coordinate)}.") 340 341 row = _parse_int(raw_coordinate[0]) 342 col = _parse_int(raw_coordinate[1]) 343 344 return (row, col) 345 346def _load_sprites(config: dict[str, typing.Any], config_path: str) -> tuple[int, int, list[list[PIL.Image.Image]]]: 347 path = str(config.get(KEY_FILENAME, '')).strip() 348 if (len(path) == 0): 349 raise ValueError(f"Invalid or missing '{KEY_FILENAME}' field.") 350 351 # If the path is not absolute, make it relative to the config path. 352 if (not os.path.isabs(path)): 353 path = os.path.join(os.path.dirname(config_path), path) 354 355 try: 356 height = _parse_int(config.get(KEY_HEIGHT)) 357 except Exception as ex: 358 raise ValueError(f"Invalid or missing '{KEY_HEIGHT}' field.") from ex 359 360 try: 361 width = _parse_int(config.get(KEY_WIDTH)) 362 except Exception as ex: 363 raise ValueError(f"Invalid or missing '{KEY_WIDTH}' field.") from ex 364 365 raw_sheet = PIL.Image.open(path) 366 367 if (raw_sheet.height % height != 0): 368 raise ValueError(f"Sprite sheet height ({raw_sheet.height}) is not a multiple of the sprite height ({height}).") 369 370 if (raw_sheet.width % width != 0): 371 raise ValueError(f"Sprite sheet width ({raw_sheet.width}) is not a multiple of the sprite width ({width}).") 372 373 sprites: list[list[PIL.Image.Image]] = [] 374 375 for row in range(raw_sheet.height // height): 376 sprites.append([]) 377 378 for col in range(raw_sheet.width // width): 379 sprites[row].append(_crop(raw_sheet, row, col, height, width)) 380 381 return height, width, sprites 382 383def _parse_int(raw_value: typing.Any) -> int: 384 if (isinstance(raw_value, int)): 385 return raw_value 386 387 if (isinstance(raw_value, float)): 388 return int(raw_value) 389 390 return int(str(raw_value)) 391 392def _crop(source: PIL.Image.Image, row: int, col: int, height: int, width: int) -> PIL.Image.Image: 393 # (left, upper, right, lower) 394 rectangle = ( 395 col * width, 396 row * height, 397 (col + 1) * width, 398 (row + 1) * height, 399 ) 400 401 return source.crop(rectangle)
71class SpriteSheet: 72 """ 73 Sprite sheets contain all the graphics for the markers on a board. 74 They are typically loaded from JSON files which define 75 what each sprite represents. 76 """ 77 78 def __init__(self, 79 height: int, width: int, 80 background: tuple[int, int, int], 81 highlight: tuple[int, int, int], 82 text: tuple[int, int, int], 83 default_sprite: PIL.Image.Image | None, 84 default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image], 85 action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]], 86 adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]], 87 ) -> None: 88 self.height: int = height 89 """ The height of a sprite. """ 90 91 self.width: int = width 92 """ The width of a sprite. """ 93 94 self.background: tuple[int, int, int] = background 95 """ The color (RGB) for the image's background. """ 96 97 self.highlight: tuple[int, int, int] = highlight 98 """ The color (RGB) for the image's highlight. """ 99 100 self.text: tuple[int, int, int] = text 101 """ The color (RGB) for the image's text color. """ 102 103 self._default_sprite: PIL.Image.Image | None = default_sprite 104 """ The fallback sprite to use for the entire sprite sheet. """ 105 106 self._default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image] = default_marker_sprites 107 """ The fallback sprite to use for each marker. """ 108 109 self._action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]] = action_sprites 110 """ 111 Sprites that represent a marker taking a specific action. 112 Each list of sprites represents a animation. 113 Each animation must have at least one image 114 (which visually makes it not an animation, 115 but in our terminology an animation is just an ordered list of images). 116 """ 117 118 self._adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]] = adjacency_sprites 119 """ 120 Sprites that represent different versions when there are objects in specific directions. 121 """ 122 123 self._animation_counts: dict[str, dict[pacai.core.board.Marker, tuple[pacai.core.action.Action, int]]] = {} 124 """ 125 Keep track of the number of times a marker/action pair has been called for, 126 so we can keep track of animations. 127 The first key is the "animation key" that will allow multiple sources to use the same SpriteSheet 128 and still keep their animations separate. 129 Note that any action subsequent calls with different actions will reset the count 130 (only repeated actions will result in an incrementing count). 131 """ 132 133 def get_sprite(self, 134 marker: pacai.core.board.Marker | None = None, 135 action: pacai.core.action.Action | None = None, 136 adjacency: pacai.core.board.AdjacencyString | None = None, 137 animation_key: str | None = None, 138 ) -> PIL.Image.Image: 139 """ 140 Get the sprite for the requested marker/action pair. 141 If there is no sprite for that specific pairing, fall back to the default for the marker. 142 If the marker has no default, fall back to the sheet's default. 143 If the sheet also has no default, raise an error. 144 145 Both `action` and `adjacency` should not be provided, 146 but they can both be missing (a default sprite will be returned). 147 148 This method will keep track of animations and return the next animation frame during subsequent calls. 149 Each unique `animation_key` will have its own set of tracking of animations. 150 This allows different components to share a SpriteSheet without stepping on each others animations. 151 A value of None will always result in fetching the first animation frame. 152 """ 153 154 if ((action is not None) and (adjacency is not None)): 155 raise ValueError("Both adjacency and action cannot be non-None.") 156 157 # Start with the default sprite. 158 sprite = self._default_sprite 159 160 if (marker is not None): 161 # Check for a marker default sprite. 162 sprite = self._default_marker_sprites.get(marker, sprite) 163 164 # Check the actions or adjacency for a more specific sprite. 165 if (action is not None): 166 animation_count = self._next_animation_count(marker, action, animation_key) 167 animation_frames = self._action_sprites.get(marker, {}).get(action, []) 168 if (len(animation_frames) > 0): 169 sprite = animation_frames[animation_count % len(animation_frames)] 170 elif (adjacency is not None): 171 # Pull from the adjacency sprites, but fallback to the current sprite. 172 sprite = self._adjacency_sprites.get(marker, {}).get(adjacency, sprite) 173 174 if (sprite is None): 175 raise ValueError(f"Could not find a matching sprite for action = '{action}', adjacency = '{adjacency}', key = '{animation_key}').") 176 177 return sprite 178 179 def _next_animation_count(self, marker: pacai.core.board.Marker, action: pacai.core.action.Action, animation_key: str | None = None) -> int: 180 """ 181 Get the next animation count for the requested resource, 182 and manage the existing count 183 (increment for repeated actions, and reset for different actions). 184 """ 185 186 if (animation_key is None): 187 return 0 188 189 if (animation_key not in self._animation_counts): 190 self._animation_counts[animation_key] = {} 191 192 animation_keys = self._animation_counts[animation_key] 193 194 if (marker not in animation_keys): 195 animation_keys[marker] = (action, -1) 196 197 info = animation_keys[marker] 198 199 # If we got the same action, increment the animation count. 200 # If we got a different action, reset the count to zero for this new action. 201 if (action == info[0]): 202 info = (action, info[1] + 1) 203 else: 204 info = (action, 0) 205 206 # Update the count. 207 animation_keys[marker] = info 208 209 return info[1] 210 211 def clear_animation_counts(self, animation_key: str | None = None) -> None: 212 """ 213 Clear the animation counts (position of the animation) for the given key, 214 or all counts if no key is provided. 215 """ 216 217 if (animation_key is None): 218 self._animation_counts = {} 219 else: 220 self._animation_counts[animation_key] = {} 221 222 def position_to_pixels(self, position: pacai.core.board.Position) -> tuple[int, int]: 223 """ Get the pixels (x, y) for this position. """ 224 225 return (position.col * self.width, position.row * self.height)
Sprite sheets contain all the graphics for the markers on a board. They are typically loaded from JSON files which define what each sprite represents.
78 def __init__(self, 79 height: int, width: int, 80 background: tuple[int, int, int], 81 highlight: tuple[int, int, int], 82 text: tuple[int, int, int], 83 default_sprite: PIL.Image.Image | None, 84 default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image], 85 action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]], 86 adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]], 87 ) -> None: 88 self.height: int = height 89 """ The height of a sprite. """ 90 91 self.width: int = width 92 """ The width of a sprite. """ 93 94 self.background: tuple[int, int, int] = background 95 """ The color (RGB) for the image's background. """ 96 97 self.highlight: tuple[int, int, int] = highlight 98 """ The color (RGB) for the image's highlight. """ 99 100 self.text: tuple[int, int, int] = text 101 """ The color (RGB) for the image's text color. """ 102 103 self._default_sprite: PIL.Image.Image | None = default_sprite 104 """ The fallback sprite to use for the entire sprite sheet. """ 105 106 self._default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image] = default_marker_sprites 107 """ The fallback sprite to use for each marker. """ 108 109 self._action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]] = action_sprites 110 """ 111 Sprites that represent a marker taking a specific action. 112 Each list of sprites represents a animation. 113 Each animation must have at least one image 114 (which visually makes it not an animation, 115 but in our terminology an animation is just an ordered list of images). 116 """ 117 118 self._adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]] = adjacency_sprites 119 """ 120 Sprites that represent different versions when there are objects in specific directions. 121 """ 122 123 self._animation_counts: dict[str, dict[pacai.core.board.Marker, tuple[pacai.core.action.Action, int]]] = {} 124 """ 125 Keep track of the number of times a marker/action pair has been called for, 126 so we can keep track of animations. 127 The first key is the "animation key" that will allow multiple sources to use the same SpriteSheet 128 and still keep their animations separate. 129 Note that any action subsequent calls with different actions will reset the count 130 (only repeated actions will result in an incrementing count). 131 """
133 def get_sprite(self, 134 marker: pacai.core.board.Marker | None = None, 135 action: pacai.core.action.Action | None = None, 136 adjacency: pacai.core.board.AdjacencyString | None = None, 137 animation_key: str | None = None, 138 ) -> PIL.Image.Image: 139 """ 140 Get the sprite for the requested marker/action pair. 141 If there is no sprite for that specific pairing, fall back to the default for the marker. 142 If the marker has no default, fall back to the sheet's default. 143 If the sheet also has no default, raise an error. 144 145 Both `action` and `adjacency` should not be provided, 146 but they can both be missing (a default sprite will be returned). 147 148 This method will keep track of animations and return the next animation frame during subsequent calls. 149 Each unique `animation_key` will have its own set of tracking of animations. 150 This allows different components to share a SpriteSheet without stepping on each others animations. 151 A value of None will always result in fetching the first animation frame. 152 """ 153 154 if ((action is not None) and (adjacency is not None)): 155 raise ValueError("Both adjacency and action cannot be non-None.") 156 157 # Start with the default sprite. 158 sprite = self._default_sprite 159 160 if (marker is not None): 161 # Check for a marker default sprite. 162 sprite = self._default_marker_sprites.get(marker, sprite) 163 164 # Check the actions or adjacency for a more specific sprite. 165 if (action is not None): 166 animation_count = self._next_animation_count(marker, action, animation_key) 167 animation_frames = self._action_sprites.get(marker, {}).get(action, []) 168 if (len(animation_frames) > 0): 169 sprite = animation_frames[animation_count % len(animation_frames)] 170 elif (adjacency is not None): 171 # Pull from the adjacency sprites, but fallback to the current sprite. 172 sprite = self._adjacency_sprites.get(marker, {}).get(adjacency, sprite) 173 174 if (sprite is None): 175 raise ValueError(f"Could not find a matching sprite for action = '{action}', adjacency = '{adjacency}', key = '{animation_key}').") 176 177 return sprite
Get the sprite for the requested marker/action pair. If there is no sprite for that specific pairing, fall back to the default for the marker. If the marker has no default, fall back to the sheet's default. If the sheet also has no default, raise an error.
Both action
and adjacency
should not be provided,
but they can both be missing (a default sprite will be returned).
This method will keep track of animations and return the next animation frame during subsequent calls.
Each unique animation_key
will have its own set of tracking of animations.
This allows different components to share a SpriteSheet without stepping on each others animations.
A value of None will always result in fetching the first animation frame.
211 def clear_animation_counts(self, animation_key: str | None = None) -> None: 212 """ 213 Clear the animation counts (position of the animation) for the given key, 214 or all counts if no key is provided. 215 """ 216 217 if (animation_key is None): 218 self._animation_counts = {} 219 else: 220 self._animation_counts[animation_key] = {}
Clear the animation counts (position of the animation) for the given key, or all counts if no key is provided.
227def load(path: str) -> SpriteSheet: 228 """ 229 Load a sprite sheet from a file. 230 If the given path does not exist, 231 try to prefix the path with the standard sprite sheet directory and suffix with the standard extension. 232 """ 233 234 raw_path = path 235 236 # If the path does not exist, try the sprite sheets directory. 237 if (not os.path.exists(path)): 238 path = os.path.join(SPRITE_SHEETS_DIR, path) 239 240 # If this path does not have a good extension, add one. 241 if (os.path.splitext(path)[-1] != '.json'): 242 path = path + '.json' 243 244 if (not os.path.exists(path)): 245 raise ValueError(f"Could not find sprite sheet, path does not exist: '{raw_path}'.") 246 247 try: 248 return _load(path) 249 except Exception as ex: 250 raise ValueError(f"Error loading sprite sheet config: '{path}'.") from ex
Load a sprite sheet from a file. If the given path does not exist, try to prefix the path with the standard sprite sheet directory and suffix with the standard extension.