mirror of
https://github.com/hypervortex/VH-Bombsquad-Modded-Server-Files
synced 2025-10-16 12:02:51 +00:00
554 lines
20 KiB
Python
554 lines
20 KiB
Python
# Released under the MIT License. See LICENSE for details.
|
|
#
|
|
"""Map related functionality."""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from typing import TYPE_CHECKING
|
|
|
|
import _ba, ba
|
|
from ba import _math
|
|
from ba._actor import Actor
|
|
import random
|
|
from datetime import date, datetime
|
|
import pytz
|
|
import setting
|
|
settings = setting.get_settings_data()
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Set, List, Type, Optional, Sequence, Any, Tuple
|
|
import ba
|
|
|
|
|
|
def preload_map_preview_media() -> None:
|
|
"""Preload media needed for map preview UIs.
|
|
|
|
Category: Asset Functions
|
|
"""
|
|
_ba.getmodel('level_select_button_opaque')
|
|
_ba.getmodel('level_select_button_transparent')
|
|
for maptype in list(_ba.app.maps.values()):
|
|
map_tex_name = maptype.get_preview_texture_name()
|
|
if map_tex_name is not None:
|
|
_ba.gettexture(map_tex_name)
|
|
|
|
|
|
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) -> ba.Lstr:
|
|
"""Return a ba.Lstr for displaying a given map\'s name.
|
|
|
|
Category: Asset Functions
|
|
"""
|
|
from ba import _language
|
|
return _language.Lstr(translate=('mapsNames', name))
|
|
|
|
|
|
def getmaps(playtype: str) -> List[str]:
|
|
"""Return a list of ba.Map types supporting a playtype str.
|
|
|
|
Category: Asset Functions
|
|
|
|
Maps supporting a given playtype must provide a particular set of
|
|
features and lend themselves to a certain style of play.
|
|
|
|
Play Types:
|
|
|
|
'melee'
|
|
General fighting map.
|
|
Has one or more 'spawn' locations.
|
|
|
|
'team_flag'
|
|
For games such as Capture The Flag where each team spawns by a flag.
|
|
Has two or more 'spawn' locations, each with a corresponding 'flag'
|
|
location (based on index).
|
|
|
|
'single_flag'
|
|
For games such as King of the Hill or Keep Away where multiple teams
|
|
are fighting over a single flag.
|
|
Has two or more 'spawn' locations and 1 'flag_default' location.
|
|
|
|
'conquest'
|
|
For games such as Conquest where flags are spread throughout the map
|
|
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
|
|
|
|
'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations,
|
|
and 1+ 'powerup_spawn' locations
|
|
|
|
'hockey'
|
|
For hockey games.
|
|
Has two 'goal' locations, corresponding 'spawn' locations, and one
|
|
'flag_default' location (for where puck spawns)
|
|
|
|
'football'
|
|
For football games.
|
|
Has two 'goal' locations, corresponding 'spawn' locations, and one
|
|
'flag_default' location (for where flag/ball/etc. spawns)
|
|
|
|
'race'
|
|
For racing games where players much touch each region in order.
|
|
Has two or more 'race_point' locations.
|
|
"""
|
|
return sorted(key for key, val in _ba.app.maps.items()
|
|
if playtype in val.get_play_types())
|
|
|
|
|
|
def get_unowned_maps() -> List[str]:
|
|
"""Return the list of local maps not owned by the current account.
|
|
|
|
Category: Asset Functions
|
|
"""
|
|
from ba import _store
|
|
unowned_maps: Set[str] = set()
|
|
if not _ba.app.headless_mode:
|
|
for map_section in _store.get_store_layout()['maps']:
|
|
for mapitem in map_section['items']:
|
|
if not _ba.get_purchased(mapitem):
|
|
m_info = _store.get_store_item(mapitem)
|
|
unowned_maps.add(m_info['map_type'].name)
|
|
return sorted(unowned_maps)
|
|
|
|
|
|
def get_map_class(name: str) -> Type[ba.Map]:
|
|
"""Return a map type given a name.
|
|
|
|
Category: Asset Functions
|
|
"""
|
|
name = get_filtered_map_name(name)
|
|
try:
|
|
return _ba.app.maps[name]
|
|
except KeyError:
|
|
from ba import _error
|
|
raise _error.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] = []
|
|
|
|
@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 ba.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 = _ba.getactivity()
|
|
if cls not in activity.preloads:
|
|
activity.preloads[cls] = cls.on_preload()
|
|
|
|
@classmethod
|
|
def get_play_types(cls) -> List[str]:
|
|
"""Return valid play types for this map."""
|
|
return []
|
|
|
|
@classmethod
|
|
def get_preview_texture_name(cls) -> Optional[str]:
|
|
"""Return the name of the preview texture for this map."""
|
|
return None
|
|
|
|
@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
|
|
|
|
@classmethod
|
|
def getname(cls) -> str:
|
|
"""Return the unique name of this map, in English."""
|
|
return cls.name
|
|
|
|
@classmethod
|
|
def get_music_type(cls) -> Optional[ba.MusicType]:
|
|
"""Return a music-type string that should be played on this map.
|
|
|
|
If None is returned, default music will be used.
|
|
"""
|
|
return None
|
|
|
|
def show_text(self):
|
|
texts = settings["TopMapText"]["msg"]
|
|
t = _ba.newnode('text',
|
|
attrs={
|
|
'text': random.choice(texts),
|
|
'flatness': 3.0,
|
|
'h_align': 'center',
|
|
'v_attach':'top',
|
|
'scale':2.0,
|
|
'position':(0,-67),
|
|
'color':(1,1,1)})
|
|
ba.animate_array(
|
|
t,
|
|
"color",
|
|
3,
|
|
{
|
|
0: (1,0,0),
|
|
1.2: (0,1,0),
|
|
2: (0,0,1),
|
|
3.1: (1,0,0),
|
|
},
|
|
loop=True,
|
|
)
|
|
ba.animate(t,'opacity', {0.0: 0, 1.0: 2, 4.0: 2, 5.0: 0})
|
|
ba.timer(5.0, t.delete)
|
|
|
|
|
|
def show_date_time(self):
|
|
enabled = settings["timetext"]["enable"]
|
|
if enabled:
|
|
time = settings["timetext"]["timezone"]
|
|
t = _ba.newnode('text',
|
|
attrs={
|
|
'text': u"" + "Date : " + str(datetime.now(pytz.timezone(time)).strftime("%A, %B %d, %Y")) + "\nTime : " + str(datetime.now(pytz.timezone(time)).strftime("%I:%M:%S %p")),
|
|
'scale': 0.85,
|
|
'flatness': 1,
|
|
'maxwidth': 0,
|
|
'h_attach': 'center',
|
|
'h_align': 'center',
|
|
'v_attach':'top',
|
|
'position':(400,-60),
|
|
'color':(1,1,1)})
|
|
ba.timer(0.1, t.delete)
|
|
|
|
def __init__(self,
|
|
vr_overlay_offset: Optional[Sequence[float]] = None) -> None:
|
|
"""Instantiate a map."""
|
|
from ba import _gameutils
|
|
super().__init__()
|
|
self.aid = None
|
|
|
|
# This is expected to always be a ba.Node object (whether valid or not)
|
|
# should be set to something meaningful by child classes.
|
|
self.node: Optional[_ba.Node] = None
|
|
|
|
# Make our class' preload-data available to us
|
|
# (and instruct the user if we weren't preloaded properly).
|
|
try:
|
|
self.preloaddata = _ba.getactivity().preloads[type(self)]
|
|
except Exception as exc:
|
|
from ba import _error
|
|
raise _error.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 = _ba.getactivity().globalsnode
|
|
import ba
|
|
import custom_hooks
|
|
custom_hooks.on_map_init()
|
|
|
|
self.credits = ba.NodeActor(
|
|
_ba.newnode('text',
|
|
attrs={
|
|
'text': " ",
|
|
'flatness': 1.0,
|
|
'h_align': 'center',
|
|
'v_attach':'bottom',
|
|
'h_attach':'right',
|
|
'scale':0.7,
|
|
'position':(-50,23),
|
|
'color':(0.8,0.8,0.8)
|
|
}))
|
|
|
|
import members.members as mid
|
|
list = mid.members
|
|
size = len(list)
|
|
count = ("\n\n\ue043| MEMBERS COUNT: "+str(size)+" |\ue043")
|
|
a = _ba.newnode('text',
|
|
attrs={
|
|
'text': count,
|
|
'scale': 0.75,
|
|
'flatness': 1,
|
|
'maxwidth': 0,
|
|
'h_attach': 'center',
|
|
'h_align': 'center',
|
|
'v_attach':'top',
|
|
'position':(400,-62),
|
|
'color':(1,1,1)})
|
|
ba.animate_array(
|
|
a,
|
|
"color",
|
|
3,
|
|
{
|
|
0: (1, 0, 0),
|
|
0.2: (1, 0.5, 0),
|
|
0.6: (1,1,0),
|
|
0.8: (0,1,0),
|
|
1.0: (0,1,1),
|
|
1.4: (1,0,1),
|
|
1.8: (1,0,0),
|
|
},
|
|
loop=True,
|
|
)
|
|
|
|
if settings["TopMapText"]["enable"]
|
|
|
|
ba.timer(0.1, ba.Call(self.show_date_time), repeat = True)
|
|
|
|
|
|
self.show_game=ba.NodeActor(
|
|
_ba.newnode('text',
|
|
attrs={
|
|
'text': " ",
|
|
'scale': 0.6,
|
|
'flatness': 1,
|
|
'maxwidth': 0,
|
|
'h_attach': 'left',
|
|
'h_align': 'left',
|
|
'v_attach':'bottom',
|
|
'position':(30,5),
|
|
'color':(1.5,1.5,1.5)
|
|
}))
|
|
self.top_text=ba.NodeActor(
|
|
_ba.newnode('text',
|
|
attrs={
|
|
'text': " ",
|
|
'flatness': 1,
|
|
'h_align': 'center',
|
|
'v_attach':'top',
|
|
'h_attach':'right',
|
|
'scale':0.9,
|
|
'position':(-115,-100),
|
|
'color':(1,1,0)
|
|
}))
|
|
self.Ranks=ba.NodeActor(
|
|
_ba.newnode('text',
|
|
attrs={
|
|
'text': "",
|
|
'flatness': 1,
|
|
'h_align': 'center',
|
|
'v_attach':'top',
|
|
'h_attach':'center',
|
|
'scale':0.8,
|
|
'position':(400,-50),
|
|
'color':(1,1,1)
|
|
}))
|
|
ba.timer(5.0, ba.Call(self.show_text), repeat = True)
|
|
# 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)
|
|
_ba.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.
|
|
self._next_ffa_start_index = 0
|
|
|
|
def is_point_near_edge(self,
|
|
point: ba.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
|
|
|
|
def get_def_bound_box(
|
|
self, name: str
|
|
) -> Optional[Tuple[float, float, float, float, float, float]]:
|
|
"""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
|
|
|
|
def get_def_point(self, name: str) -> Optional[Sequence[float]]:
|
|
"""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
|
|
_math.vec3validate(val) if __debug__ else val)
|
|
|
|
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
|
|
|
|
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
|
|
|
|
def get_ffa_start_position(
|
|
self, players: Sequence[ba.Player]) -> Sequence[float]:
|
|
"""Return a random starting position in one of the FFA spawn areas.
|
|
|
|
If a list of ba.Players 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 = _ba.Vec3(_getpt())
|
|
closest_player_dist = 9999.0
|
|
for ppt in player_pts:
|
|
dist = (ppt - testpt).length()
|
|
if dist < closest_player_dist:
|
|
closest_player_dist = dist
|
|
if closest_player_dist > farthestpt_dist:
|
|
farthestpt_dist = closest_player_dist
|
|
farthestpt = testpt
|
|
assert farthestpt is not None
|
|
return tuple(farthestpt)
|
|
|
|
def get_flag_position(self, team_index: int = 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]
|
|
|
|
def exists(self) -> bool:
|
|
return bool(self.node)
|
|
|
|
def handlemessage(self, msg: Any) -> Any:
|
|
from ba 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."""
|
|
if maptype.name in _ba.app.maps:
|
|
raise RuntimeError('map "' + maptype.name + '" already registered')
|
|
_ba.app.maps[maptype.name] = maptype
|
|
|
|
|