193 lines
8.8 KiB
Python
193 lines
8.8 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
|
|
from engine.character import Character
|
|
from engine.entity import Entity, EntityPosition
|
|
from engine.map import Map
|
|
|
|
LEAP_DISTANCE = 2
|
|
|
|
|
|
@dataclass
|
|
class TimeWarpZone:
|
|
"""A timed spherical zone that slows everything inside it except a whitelist of entities.
|
|
|
|
Generic on purpose: size (radius), speed_factor, whitelist (immune_entity_ids), and time
|
|
(expires_at, an absolute GameClock.total_time) are all just constructor args - nothing here
|
|
assumes it was cast by a character ability vs. triggered by an item (see
|
|
resources/logic/abilities.py's cast_time_warp_sphere for both attachment points).
|
|
"""
|
|
|
|
map_id: str
|
|
center: tuple[float, float, float]
|
|
radius: float
|
|
speed_factor: float
|
|
expires_at: float
|
|
immune_entity_ids: set[str] = field(default_factory=set)
|
|
|
|
def contains(self, map_id: str, x: float, y: float, z: float) -> bool:
|
|
if map_id != self.map_id or math.floor(z) != math.floor(self.center[2]):
|
|
return False
|
|
dx, dy = x - self.center[0], y - self.center[1]
|
|
return math.hypot(dx, dy) <= self.radius
|
|
|
|
def affects(self, entity_id: str) -> bool:
|
|
return entity_id not in self.immune_entity_ids
|
|
|
|
|
|
class World:
|
|
"""Owns the map graph, all entities, and the def registry; drives movement."""
|
|
|
|
def __init__(self, registry=None):
|
|
self.registry = registry
|
|
self.maps: dict[str, Map] = {}
|
|
self.entities: dict[str, Entity] = {}
|
|
self.player: Entity | None = None
|
|
self.time_warp_zones: list[TimeWarpZone] = []
|
|
|
|
def speed_multiplier_at(self, entity_id: str, map_id: str, x: float, y: float, z: float, now: float) -> float:
|
|
"""Combined slow from every active, non-expired zone that affects this entity here."""
|
|
multiplier = 1.0
|
|
for zone in self.time_warp_zones:
|
|
if zone.expires_at > now and zone.affects(entity_id) and zone.contains(map_id, x, y, z):
|
|
multiplier *= zone.speed_factor
|
|
return multiplier
|
|
|
|
def expire_time_warp_zones(self, now: float) -> None:
|
|
self.time_warp_zones = [z for z in self.time_warp_zones if z.expires_at > now]
|
|
|
|
def add_map(self, map_: Map) -> None:
|
|
self.maps[map_.map_id] = map_
|
|
|
|
def add_entity(self, entity: Entity) -> None:
|
|
self.entities[entity.entity_id] = entity
|
|
|
|
def entities_on_map(self, map_id: str) -> list[Entity]:
|
|
return [e for e in self.entities.values() if e.position is not None and e.position.map_id == map_id]
|
|
|
|
def entity_at(self, map_id: str, x: int, y: int, z: int) -> Entity | None:
|
|
"""Tile-membership lookup: matches any entity whose (possibly continuous) position
|
|
falls in tile (x, y, z), i.e. floor(pos.x) == x etc. Floor of an int is itself, so this
|
|
is unchanged for entities that happen to sit exactly on a tile.
|
|
"""
|
|
for entity in self.entities.values():
|
|
pos = entity.position
|
|
if pos is None or pos.map_id != map_id:
|
|
continue
|
|
if (math.floor(pos.x), math.floor(pos.y), math.floor(pos.z)) == (x, y, z):
|
|
return entity
|
|
return None
|
|
|
|
def try_move(self, entity: Entity, dx: int, dy: int, dz: int) -> bool:
|
|
assert entity.position is not None
|
|
cur_map = self.maps[entity.position.map_id]
|
|
tx = entity.position.x + dx
|
|
ty = entity.position.y + dy
|
|
tz = entity.position.z + dz
|
|
moved = self._move_within(entity, cur_map, tx, ty, tz)
|
|
if moved:
|
|
self._apply_staircase(entity)
|
|
return moved
|
|
|
|
def try_leap(self, entity: Entity, dx: int, dy: int, dz: int, distance: int = LEAP_DISTANCE) -> bool:
|
|
"""A leap in the given unit direction, e.g. to clear a gap and land aboard a ship.
|
|
|
|
Reuses try_move's own semantics unchanged: it only ever validates the landing tile,
|
|
never anything in between, so scaling the delta is all a "jump over a gap" needs.
|
|
|
|
If the entity's species defines a "leap" ability (e.g. requiring both legs), it's
|
|
gated on that; species/entities with no such ability defined are left unrestricted.
|
|
"""
|
|
if isinstance(entity, Character) and entity.species_id is not None and self.registry is not None:
|
|
species = self.registry.get_species_def(entity.species_id)
|
|
if any(a.id == "leap" for a in species.abilities) and not entity.can_perform_ability("leap", self.registry):
|
|
return False
|
|
return self.try_move(entity, dx * distance, dy * distance, dz * distance)
|
|
|
|
def _move_within(self, entity: Entity, map_: Map, x: int, y: int, z: int) -> bool:
|
|
if map_.in_bounds(x, y, z):
|
|
embedding = map_.embedding_at(x, y, z)
|
|
if embedding is not None:
|
|
lx, ly, lz = embedding.parent_to_local(x, y, z)
|
|
if not embedding.child_map.get_tile(lx, ly, lz).is_walkable(self.registry):
|
|
return False
|
|
entity.position = EntityPosition(embedding.child_map.map_id, lx, ly, lz)
|
|
return True
|
|
if not map_.get_tile(x, y, z).is_walkable(self.registry):
|
|
return False
|
|
entity.position = EntityPosition(map_.map_id, x, y, z)
|
|
return True
|
|
|
|
if map_.parent_embedding is not None:
|
|
embedding = map_.parent_embedding
|
|
px, py, pz = embedding.local_to_parent(x, y, z)
|
|
return self._move_within(entity, embedding.parent_map, px, py, pz)
|
|
|
|
return False
|
|
|
|
def try_move_continuous(self, entity: Entity, dx: float, dy: float, dz: float, distance: float) -> bool:
|
|
"""True stepless movement: moves `entity` by `distance` along the (dx, dy, dz) direction
|
|
(need not be a unit vector - it's normalized here), landing at a fractional position
|
|
rather than snapping to a tile. Collision is checked against whichever tile the
|
|
destination falls in (floor(x), floor(y)) - same walkability/embedding rules as
|
|
try_move, just point-based rather than a full sweep, and the path between old and new
|
|
position isn't swept for obstacles (a fast-enough mover could tunnel a thin wall in one
|
|
frame - a known simplification, not a concern at ordinary per-frame speeds/dt).
|
|
"""
|
|
assert entity.position is not None
|
|
length = math.hypot(dx, dy)
|
|
if length == 0:
|
|
return False
|
|
ux, uy = dx / length, dy / length
|
|
nx = entity.position.x + ux * distance
|
|
ny = entity.position.y + uy * distance
|
|
nz = entity.position.z
|
|
moved = self._move_within_continuous(entity, self.maps[entity.position.map_id], nx, ny, nz)
|
|
if moved:
|
|
self._apply_staircase(entity)
|
|
return moved
|
|
|
|
def _apply_staircase(self, entity: Entity) -> None:
|
|
"""A single z-shift if the entity just landed on a staircase/ladder tile, provided the
|
|
target floor is in bounds and walkable at the same (x, y). Not recursive - a stack of
|
|
staircases each carries you up/down exactly one level per step onto them.
|
|
"""
|
|
assert entity.position is not None
|
|
map_ = self.maps[entity.position.map_id]
|
|
x, y, z = entity.position.x, entity.position.y, entity.position.z
|
|
direction = map_.get_tile(x, y, z).staircase_direction(self.registry)
|
|
if direction is None:
|
|
return
|
|
nz = math.floor(z) + (1 if direction == "up" else -1)
|
|
if not map_.in_bounds(x, y, nz):
|
|
return
|
|
if not map_.get_tile(x, y, nz).is_walkable(self.registry):
|
|
return
|
|
entity.position = EntityPosition(map_.map_id, x, y, nz)
|
|
|
|
def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool:
|
|
tile_x, tile_y, tile_z = math.floor(x), math.floor(y), math.floor(z)
|
|
if map_.in_bounds(tile_x, tile_y, tile_z):
|
|
embedding = map_.embedding_at(tile_x, tile_y, tile_z)
|
|
if embedding is not None:
|
|
local_tile = embedding.parent_to_local(tile_x, tile_y, tile_z)
|
|
assert local_tile is not None
|
|
if not embedding.child_map.get_tile(*local_tile).is_walkable(self.registry):
|
|
return False
|
|
lx, ly, lz = embedding.parent_to_local_pos(x, y, z)
|
|
entity.position = EntityPosition(embedding.child_map.map_id, lx, ly, lz)
|
|
return True
|
|
if not map_.get_tile(tile_x, tile_y, tile_z).is_walkable(self.registry):
|
|
return False
|
|
entity.position = EntityPosition(map_.map_id, x, y, z)
|
|
return True
|
|
|
|
if map_.parent_embedding is not None:
|
|
embedding = map_.parent_embedding
|
|
px, py, pz = embedding.local_to_parent_pos(x, y, z)
|
|
return self._move_within_continuous(entity, embedding.parent_map, px, py, pz)
|
|
|
|
return False
|