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, max_rotation_speed: float = 90.0, ): self.parent_map = parent_map self.child_map = child_map self.anchor = anchor self.rotation = rotation % 4 self.max_rotation_speed = max_rotation_speed # degrees/second - adjustable per ship self.desired_rotation = self.rotation # heading steering is trying to reach, see # resources/logic/steering.py::try_turn_ship / advance_ship_rotation self.rotation_ready_at = 0.0 # game-clock time the next quarter-turn step is allowed 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) *instantly*, bypassing max_rotation_speed - used internally by the rate-limited steering step (see resources/logic/steering.py::advance_ship_rotation) to apply one already-validated quarter-turn, and available directly for callers that don't care about rate limiting. """ 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, max_rotation_speed: float = 90.0 ) -> MapEmbedding: embedding = MapEmbedding(self, child, anchor, rotation, max_rotation_speed) 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]