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:

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)
THIS_DIR: str = '/home/runner/work/pacai/pacai/pacai/core'
SPRITE_SHEETS_DIR: str = '/home/runner/work/pacai/pacai/pacai/core/../resources/spritesheets'
KEY_ACTIONS: str = 'actions'
KEY_ADJACENCY: str = 'adjacency'
KEY_BACKGROUND: str = 'background'
KEY_DEFAULT: str = 'default'
KEY_FILENAME: str = 'filename'
KEY_HEIGHT: str = 'height'
KEY_HIGHLIGHT: str = 'highlight'
KEY_MARKERS: str = 'markers'
KEY_TEXT: str = 'text'
KEY_WIDTH: str = 'width'
DEFAULT_BACKGROUND: tuple[int, int, int] = (0, 0, 0)
DEFAULT_HIGHLIGHT: tuple[int, int, int] = (255, 0, 0)
DEFAULT_TEXT: tuple[int, int, int] = (255, 255, 255)
class SpriteSheet:
 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.

SpriteSheet( height: int, width: int, background: tuple[int, int, int], highlight: tuple[int, int, int], text: tuple[int, int, int], default_sprite: PIL.Image.Image | None, default_marker_sprites: dict[pacai.core.board.Marker, PIL.Image.Image], action_sprites: dict[pacai.core.board.Marker, dict[pacai.core.action.Action, list[PIL.Image.Image]]], adjacency_sprites: dict[pacai.core.board.Marker, dict[pacai.core.board.AdjacencyString, PIL.Image.Image]])
 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        """
height: int

The height of a sprite.

width: int

The width of a sprite.

background: tuple[int, int, int]

The color (RGB) for the image's background.

highlight: tuple[int, int, int]

The color (RGB) for the image's highlight.

text: tuple[int, int, int]

The color (RGB) for the image's text color.

def get_sprite( self, marker: pacai.core.board.Marker | None = None, action: pacai.core.action.Action | None = None, adjacency: pacai.core.board.AdjacencyString | None = None, animation_key: str | None = None) -> PIL.Image.Image:
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.

def clear_animation_counts(self, animation_key: str | None = None) -> None:
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.

def position_to_pixels(self, position: pacai.core.board.Position) -> tuple[int, int]:
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)

Get the pixels (x, y) for this position.

def load(path: str) -> SpriteSheet:
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.