# 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
[docs]
def get_filtered_map_name(name: str) -> str:
"""Filter a map name to account for name changes, etc.
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
[docs]
def get_map_display_string(name: str) -> babase.Lstr:
"""Return a babase.Lstr for displaying a given map's name."""
return babase.Lstr(translate=('mapsNames', name))
[docs]
def get_map_class(name: str) -> type[Map]:
"""Return a map type given a name."""
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
[docs]
class Map(Actor):
"""A game map.
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
[docs]
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
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum