167 lines
7.3 KiB
Python
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]
|