Mi2dRPGamEng/engine/map.py

167 lines
7.3 KiB
Python

from __future__ import annotations
import math
from engine.tile import Tile
def rotate_point(x: int, y: int, width: int, height: int, rotation: int) -> tuple[int, int]:
"""Rotates (x, y) within a width x height grid by `rotation` quarter turns (clockwise).
The result is expressed in the rotated bounding box's own coordinate space (still
starting at (0, 0)). Use rotated_size() for that box's new width/height.
"""
rotation %= 4
if rotation == 0:
return x, y
if rotation == 1:
return height - 1 - y, x
if rotation == 2:
return width - 1 - x, height - 1 - y
return y, width - 1 - x
def rotate_vector(dx: int, dy: int, quarter_turns: int) -> tuple[int, int]:
"""Rotates a free direction vector (not bound to a grid) by quarter turns (clockwise).
Unlike rotate_point, there's no bounding box to stay within - this is for things like a
projectile's ricochet direction, not repositioning a point inside a map's own grid.
"""
quarter_turns %= 4
if quarter_turns == 0:
return dx, dy
if quarter_turns == 1:
return -dy, dx
if quarter_turns == 2:
return -dx, -dy
return dy, -dx
def rotate_position(x: float, y: float, width: float, height: float, rotation: int) -> tuple[float, float]:
"""Continuous analogue of rotate_point, for entity positions (not tile indices) crossing a
rotated embedding boundary. Drops the "-1" rotate_point uses: that offset reflects a
*discrete* grid of {0, ..., width-1} cells, whereas a continuous position spans the
continuum [0, width], so the reflection point is width/height themselves, not width-1/height-1.
"""
rotation %= 4
if rotation == 0:
return x, y
if rotation == 1:
return height - y, x
if rotation == 2:
return width - x, height - y
return y, width - x
def rotated_size(width: int, height: int, rotation: int) -> tuple[int, int]:
return (height, width) if rotation % 2 == 1 else (width, height)
class MapEmbedding:
"""Joins a child Map into a parent Map via an anchor + quarter-turn rotation.
Both anchor and rotation are plain mutable attributes (see move_to/rotate_to) so an
embedded map (e.g. a ship) can move and turn live. Entities already aboard keep their
position expressed in the child map's own local coordinates, so moving/turning the ship
never needs to touch them - only the transform used to cross its boundary changes.
Translating an out-of-range local coordinate correctly yields the parent-space cell just
outside the child's (rotated) footprint, which is what lets World.try_move cross the
boundary in both directions with one code path, at any anchor/rotation.
"""
def __init__(self, parent_map: "Map", child_map: "Map", anchor: tuple[int, int, int], rotation: int = 0):
self.parent_map = parent_map
self.child_map = child_map
self.anchor = anchor
self.rotation = rotation % 4
def footprint_size(self) -> tuple[int, int]:
return rotated_size(self.child_map.width, self.child_map.height, self.rotation)
def local_to_parent(self, x: int, y: int, z: int) -> tuple[int, int, int]:
rx, ry = rotate_point(x, y, self.child_map.width, self.child_map.height, self.rotation)
ax, ay, az = self.anchor
return (ax + rx, ay + ry, az + z)
def parent_to_local(self, px: int, py: int, pz: int) -> tuple[int, int, int] | None:
ax, ay, az = self.anchor
rx, ry, rz = px - ax, py - ay, pz - az
fw, fh = self.footprint_size()
if not (0 <= rx < fw and 0 <= ry < fh and 0 <= rz < self.child_map.depth):
return None
x, y = rotate_point(rx, ry, fw, fh, (4 - self.rotation) % 4)
return (x, y, rz)
def local_to_parent_pos(self, x: float, y: float, z: float) -> tuple[float, float, float]:
"""Continuous-position counterpart of local_to_parent, for entities (not tile lookups)."""
rx, ry = rotate_position(x, y, self.child_map.width, self.child_map.height, self.rotation)
ax, ay, az = self.anchor
return (ax + rx, ay + ry, az + z)
def parent_to_local_pos(self, px: float, py: float, pz: float) -> tuple[float, float, float]:
"""Continuous-position counterpart of parent_to_local. Unlike parent_to_local, this
doesn't bounds-check (the caller already knows the tile is inside the footprint via
embedding_at) - it just carries the fractional offset through the rotation correctly.
"""
ax, ay, az = self.anchor
rx, ry, rz = px - ax, py - ay, pz - az
fw, fh = self.footprint_size()
x, y = rotate_position(rx, ry, fw, fh, (4 - self.rotation) % 4)
return (x, y, rz)
def move_to(self, anchor: tuple[int, int, int]) -> None:
"""Live-relocate the embedded map (e.g. a ship sailing). Aboard entities need no update."""
self.anchor = anchor
def rotate_to(self, rotation: int) -> None:
"""Turn the embedded map to a new quarter-turn orientation (0-3, clockwise)."""
self.rotation = rotation % 4
class Map:
def __init__(self, map_id: str, width: int, height: int, depth: int, source_def: str | None = None):
self.map_id = map_id
self.width = width
self.height = height
self.depth = depth
self.source_def = source_def or f"{map_id}.json"
self._tiles: list[Tile] = [Tile() for _ in range(width * height * depth)]
self.embeddings: list[MapEmbedding] = []
self.parent_embedding: MapEmbedding | None = None
def _index(self, x: int, y: int, z: int) -> int:
return x + y * self.width + z * self.width * self.height
def in_bounds(self, x: float, y: float, z: float) -> bool:
"""Accepts continuous coordinates too - which tile a position falls in is floor(x), floor(y)."""
x, y, z = math.floor(x), math.floor(y), math.floor(z)
return 0 <= x < self.width and 0 <= y < self.height and 0 <= z < self.depth
def get_tile(self, x: float, y: float, z: float) -> Tile:
fx, fy, fz = math.floor(x), math.floor(y), math.floor(z)
if not self.in_bounds(fx, fy, fz):
raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}")
return self._tiles[self._index(fx, fy, fz)]
def set_tile(self, x: float, y: float, z: float, tile: Tile) -> None:
fx, fy, fz = math.floor(x), math.floor(y), math.floor(z)
if not self.in_bounds(fx, fy, fz):
raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}")
self._tiles[self._index(fx, fy, fz)] = tile
def embed(self, child: "Map", anchor: tuple[int, int, int], rotation: int = 0) -> MapEmbedding:
embedding = MapEmbedding(self, child, anchor, rotation)
self.embeddings.append(embedding)
child.parent_embedding = embedding
return embedding
def embedding_at(self, x: float, y: float, z: float) -> MapEmbedding | None:
fx, fy, fz = math.floor(x), math.floor(y), math.floor(z)
for embedding in self.embeddings:
if embedding.parent_to_local(fx, fy, fz) is not None:
return embedding
return None
def embeddings_at_z(self, z: int) -> list[MapEmbedding]:
return [e for e in self.embeddings if e.anchor[2] <= z < e.anchor[2] + e.child_map.depth]