Source code for bascenev1._map

# Released under the MIT License. See LICENSE for details.
#
"""Map related functionality."""
from __future__ import annotations

import random
from typing import TYPE_CHECKING, override

import babase

import _bascenev1
from bascenev1._actor import Actor

if TYPE_CHECKING:
    from typing import Sequence, Any

    import bascenev1


def get_filtered_map_name(name: str) -> str:
    """Filter a map name to account for name changes, etc.

    Category: **Asset Functions**

    This can be used to support old playlists, etc.
    """
    # Some legacy name fallbacks... can remove these eventually.
    if name in ('AlwaysLand', 'Happy Land'):
        name = 'Happy Thoughts'
    if name == 'Hockey Arena':
        name = 'Hockey Stadium'
    return name


def get_map_display_string(name: str) -> babase.Lstr:
    """Return a babase.Lstr for displaying a given map\'s name.

    Category: **Asset Functions**
    """
    return babase.Lstr(translate=('mapsNames', name))


def get_map_class(name: str) -> type[Map]:
    """Return a map type given a name.

    Category: **Asset Functions**
    """
    assert babase.app.classic is not None
    name = get_filtered_map_name(name)
    try:
        mapclass: type[Map] = babase.app.classic.maps[name]
        return mapclass
    except KeyError:
        raise babase.NotFoundError(f"Map not found: '{name}'") from None


class Map(Actor):
    """A game map.

    Category: **Gameplay Classes**

    Consists of a collection of terrain nodes, metadata, and other
    functionality comprising a game map.
    """

    defs: Any = None
    name = 'Map'
    _playtypes: list[str] = []

[docs] @classmethod def preload(cls) -> None: """Preload map media. This runs the class's on_preload() method as needed to prep it to run. Preloading should generally be done in a bascenev1.Activity's __init__ method. Note that this is a classmethod since it is not operate on map instances but rather on the class itself before instances are made """ activity = _bascenev1.getactivity() if cls not in activity.preloads: activity.preloads[cls] = cls.on_preload()
[docs] @classmethod def get_play_types(cls) -> list[str]: """Return valid play types for this map.""" return []
[docs] @classmethod def get_preview_texture_name(cls) -> str | None: """Return the name of the preview texture for this map.""" return None
[docs] @classmethod def on_preload(cls) -> Any: """Called when the map is being preloaded. It should return any media/data it requires to operate """ return None
[docs] @classmethod def getname(cls) -> str: """Return the unique name of this map, in English.""" return cls.name
[docs] @classmethod def get_music_type(cls) -> bascenev1.MusicType | None: """Return a music-type string that should be played on this map. If None is returned, default music will be used. """ return None
def __init__( self, vr_overlay_offset: Sequence[float] | None = None ) -> None: """Instantiate a map.""" super().__init__() # This is expected to always be a bascenev1.Node object # (whether valid or not) should be set to something meaningful # by child classes. self.node: _bascenev1.Node | None = None # Make our class' preload-data available to us # (and instruct the user if we weren't preloaded properly). try: self.preloaddata = _bascenev1.getactivity().preloads[type(self)] except Exception as exc: raise babase.NotFoundError( 'Preload data not found for ' + str(type(self)) + '; make sure to call the type\'s preload()' ' staticmethod in the activity constructor' ) from exc # Set various globals. gnode = _bascenev1.getactivity().globalsnode # Set area-of-interest bounds. aoi_bounds = self.get_def_bound_box('area_of_interest_bounds') if aoi_bounds is None: print('WARNING: no "aoi_bounds" found for map:', self.getname()) aoi_bounds = (-1, -1, -1, 1, 1, 1) gnode.area_of_interest_bounds = aoi_bounds # Set map bounds. map_bounds = self.get_def_bound_box('map_bounds') if map_bounds is None: print('WARNING: no "map_bounds" found for map:', self.getname()) map_bounds = (-30, -10, -30, 30, 100, 30) _bascenev1.set_map_bounds(map_bounds) # Set shadow ranges. try: gnode.shadow_range = [ self.defs.points[v][1] for v in [ 'shadow_lower_bottom', 'shadow_lower_top', 'shadow_upper_bottom', 'shadow_upper_top', ] ] except Exception: pass # In vr, set a fixed point in space for the overlay to show up at. # By default we use the bounds center but allow the map to override it. center = ( (aoi_bounds[0] + aoi_bounds[3]) * 0.5, (aoi_bounds[1] + aoi_bounds[4]) * 0.5, (aoi_bounds[2] + aoi_bounds[5]) * 0.5, ) if vr_overlay_offset is not None: center = ( center[0] + vr_overlay_offset[0], center[1] + vr_overlay_offset[1], center[2] + vr_overlay_offset[2], ) gnode.vr_overlay_center = center gnode.vr_overlay_center_enabled = True self.spawn_points = self.get_def_points('spawn') or [(0, 0, 0, 0, 0, 0)] self.ffa_spawn_points = self.get_def_points('ffa_spawn') or [ (0, 0, 0, 0, 0, 0) ] self.spawn_by_flag_points = self.get_def_points('spawn_by_flag') or [ (0, 0, 0, 0, 0, 0) ] self.flag_points = self.get_def_points('flag') or [(0, 0, 0)] # We just want points. self.flag_points = [p[:3] for p in self.flag_points] self.flag_points_default = self.get_def_point('flag_default') or ( 0, 1, 0, ) self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [ (0, 0, 0) ] # We just want points. self.powerup_spawn_points = [p[:3] for p in self.powerup_spawn_points] self.tnt_points = self.get_def_points('tnt') or [] # We just want points. self.tnt_points = [p[:3] for p in self.tnt_points] self.is_hockey = False self.is_flying = False # FIXME: this should be part of game; not map. # Let's select random index for first spawn point, # so that no one is offended by the constant spawn on the edge. self._next_ffa_start_index = random.randrange( len(self.ffa_spawn_points) )
[docs] def is_point_near_edge( self, point: babase.Vec3, running: bool = False ) -> bool: """Return whether the provided point is near an edge of the map. Simple bot logic uses this call to determine if they are approaching a cliff or wall. If this returns True they will generally not walk/run any farther away from the origin. If 'running' is True, the buffer should be a bit larger. """ del point, running # Unused. return False
[docs] def get_def_bound_box( self, name: str ) -> tuple[float, float, float, float, float, float] | None: """Return a 6 member bounds tuple or None if it is not defined.""" try: box = self.defs.boxes[name] return ( box[0] - box[6] / 2.0, box[1] - box[7] / 2.0, box[2] - box[8] / 2.0, box[0] + box[6] / 2.0, box[1] + box[7] / 2.0, box[2] + box[8] / 2.0, ) except Exception: return None
[docs] def get_def_point(self, name: str) -> Sequence[float] | None: """Return a single defined point or a default value in its absence.""" val = self.defs.points.get(name) return ( None if val is None else babase.vec3validate(val) if __debug__ else val )
[docs] def get_def_points(self, name: str) -> list[Sequence[float]]: """Return a list of named points. Return as many sequential ones are defined (flag1, flag2, flag3), etc. If none are defined, returns an empty list. """ point_list = [] if self.defs and name + '1' in self.defs.points: i = 1 while name + str(i) in self.defs.points: pts = self.defs.points[name + str(i)] if len(pts) == 6: point_list.append(pts) else: if len(pts) != 3: raise ValueError('invalid point') point_list.append(pts + (0, 0, 0)) i += 1 return point_list
[docs] def get_start_position(self, team_index: int) -> Sequence[float]: """Return a random starting position for the given team index.""" pnt = self.spawn_points[team_index % len(self.spawn_points)] x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3]) z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5]) pnt = ( pnt[0] + random.uniform(*x_range), pnt[1], pnt[2] + random.uniform(*z_range), ) return pnt
[docs] def get_ffa_start_position( self, players: Sequence[bascenev1.Player] ) -> Sequence[float]: """Return a random starting position in one of the FFA spawn areas. If a list of bascenev1.Player-s is provided; the returned points will be as far from these players as possible. """ # Get positions for existing players. player_pts = [] for player in players: if player.is_alive(): player_pts.append(player.position) def _getpt() -> Sequence[float]: point = self.ffa_spawn_points[self._next_ffa_start_index] self._next_ffa_start_index = (self._next_ffa_start_index + 1) % len( self.ffa_spawn_points ) x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) point = ( point[0] + random.uniform(*x_range), point[1], point[2] + random.uniform(*z_range), ) return point if not player_pts: return _getpt() # Let's calc several start points and then pick whichever is # farthest from all existing players. farthestpt_dist = -1.0 farthestpt = None for _i in range(10): testpt = babase.Vec3(_getpt()) closest_player_dist = 9999.0 for ppt in player_pts: dist = (ppt - testpt).length() closest_player_dist = min(dist, closest_player_dist) if closest_player_dist > farthestpt_dist: farthestpt_dist = closest_player_dist farthestpt = testpt assert farthestpt is not None return tuple(farthestpt)
[docs] def get_flag_position( self, team_index: int | None = None ) -> Sequence[float]: """Return a flag position on the map for the given team index. Pass None to get the default flag point. (used for things such as king-of-the-hill) """ if team_index is None: return self.flag_points_default[:3] return self.flag_points[team_index % len(self.flag_points)][:3]
[docs] @override def exists(self) -> bool: return bool(self.node)
[docs] @override def handlemessage(self, msg: Any) -> Any: from bascenev1 import _messages if isinstance(msg, _messages.DieMessage): if self.node: self.node.delete() else: return super().handlemessage(msg) return None
def register_map(maptype: type[Map]) -> None: """Register a map class with the game.""" assert babase.app.classic is not None if maptype.name in babase.app.classic.maps: raise RuntimeError(f'Map "{maptype.name}" is already registered.') babase.app.classic.maps[maptype.name] = maptype