Source code for gale.tilemap.isometric

"""
This file contains the isometric counterpart of gale.tilemap.tilemap:
cartesian_to_isometric/isometric_to_cartesian (the cartesian <->
isometric screen-space conversion every diamond-grid project ends up
writing by hand — useful on their own for any isometric coordinate
math, not just tile maps) and IsometricTileMap, a TileMap subclass that
renders its grid as a 2:1 diamond projection instead of an
axis-aligned one.

Author: Alejandro Mujica (aledrums@gmail.com)
"""

import math
from typing import Any, Optional, Tuple

import pygame

from .tilemap import TileMap, Tileset


[docs] def cartesian_to_isometric( x: float, y: float, tile_width: int, tile_height: int ) -> Tuple[float, float]: """ Converts a cartesian grid-space position (in tile units — x is the column, y is the row, either can be fractional) into isometric screen-space pixel coordinates, using the standard 2:1 diamond projection. :param x: A grid-space x coordinate, in tile units (column). :param y: A grid-space y coordinate, in tile units (row). :param tile_width: The width, in pixels, of a diamond tile. :param tile_height: The height, in pixels, of a diamond tile. :returns: The equivalent (screen_x, screen_y) in pixels. """ screen_x = (x - y) * tile_width / 2 screen_y = (x + y) * tile_height / 2 return (screen_x, screen_y)
[docs] def isometric_to_cartesian( screen_x: float, screen_y: float, tile_width: int, tile_height: int ) -> Tuple[float, float]: """ The exact inverse of cartesian_to_isometric: turns an isometric screen-space pixel position back into a cartesian grid-space one (in tile units). :param screen_x: A screen-space x coordinate, in pixels. :param screen_y: A screen-space y coordinate, in pixels. :param tile_width: The width, in pixels, of a diamond tile. :param tile_height: The height, in pixels, of a diamond tile. :returns: The equivalent (x, y) in grid space (tile units). """ x = screen_x / tile_width + screen_y / tile_height y = screen_y / tile_height - screen_x / tile_width return (x, y)
[docs] class IsometricTileMap(TileMap): """ A TileMap rendered as a 2:1 isometric diamond grid rather than an axis-aligned one — same rows x cols grid(s) of gids, same tileset(s)/layers/object_layers API, only the projection from (row, col) to pixels (and back) changes. Conventions used throughout this class: - (row 0, col 0) sits at the map's top corner (the diamond's topmost point on screen), with row increasing toward the bottom-left and col increasing toward the bottom-right — the usual isometric "diamond" layout. - position_of(row, col) returns that cell's diamond's *top vertex* (not a bounding-box top-left corner), shifted right by origin_x = (rows - 1) * tile_width / 2 so the whole map's top vertex column never goes negative. - Tile images are anchored at their *top-center*: the pixel position_of returns is where the top-center point of the tile_width x tile_height source image should land. render() accounts for this by blitting at (position_of(row, col)[0] - tile_width / 2, position_of(row, col)[1]). Because row + col increases monotonically along a plain row-major (row ascending, then col ascending) iteration, that iteration order already paints back-to-front for a diamond grid — no extra sort is needed, unlike a general isometric scene with freely-placed entities. Usage example: tileset = Tileset(tiles_image, tile_width=64, tile_height=32, first_gid=1) tilemap = IsometricTileMap(tile_width=64, tile_height=32, cols=20, rows=20) tilemap.add_tileset(tileset) ground = tilemap.add_layer("ground") ground[5][10] = 1 # Every frame: tilemap.render(surface, camera) """ @property def pixel_width(self) -> int: return round((self.cols + self.rows) * self.tile_width / 2) @property def pixel_height(self) -> int: return round((self.cols + self.rows) * self.tile_height / 2)
[docs] def position_of(self, row: int, col: int) -> Tuple[float, float]: """ :param row: A tile row. :param col: A tile column. :returns: The screen/world pixel position of that cell's diamond's top vertex (see the class docstring for the anchor/origin convention). """ origin_x = (self.rows - 1) * self.tile_width / 2 screen_x, screen_y = cartesian_to_isometric( col, row, self.tile_width, self.tile_height ) return (screen_x + origin_x, screen_y)
[docs] def tile_at(self, x: float, y: float) -> Tuple[int, int]: """ :param x: A screen/world x position, in pixels. :param y: A screen/world y position, in pixels. :returns: The (row, col) of the tile whose diamond contains that point (not clamped to this map's bounds — check in_bounds if the point might fall outside it). """ origin_x = (self.rows - 1) * self.tile_width / 2 col_f, row_f = isometric_to_cartesian( x - origin_x, y, self.tile_width, self.tile_height ) return (int(math.floor(row_f)), int(math.floor(col_f)))
[docs] def render(self, surface: pygame.Surface, camera: Optional[Any] = None) -> None: """ :param surface: The surface to draw every layer onto. :param camera: A gale.camera.Camera to translate/scale tiles through and cull whatever falls outside its viewport. The default value is None, rendering the whole map at a 1:1 scale starting at (0, 0). """ viewport_rect: Optional[pygame.Rect] = None if camera is not None: viewport_rect = pygame.Rect( 0, 0, camera.viewport_width, camera.viewport_height ) for name in self.layer_names(): grid = self.get_layer(name) for row in range(self.rows): for col in range(self.cols): gid = grid[row][col] if gid == 0: continue tileset = self.tileset_for_gid(gid) if tileset is None: continue source_rect = tileset.rect_for(gid) top_x, top_y = self.position_of(row, col) dest_rect = pygame.Rect( round(top_x - self.tile_width / 2), round(top_y), self.tile_width, self.tile_height, ) if camera is None: surface.blit(tileset.image, dest_rect, source_rect) else: screen_rect = camera.apply(dest_rect) if viewport_rect is not None and not screen_rect.colliderect( viewport_rect ): continue surface.blit(tileset.image, screen_rect, source_rect)