mirror of
https://github.com/hypervortex/VH-Bombsquad-Modded-Server-Files
synced 2025-11-07 17:36:08 +00:00
613 lines
22 KiB
Python
613 lines
22 KiB
Python
# Released under the MIT License. See LICENSE for details.
|
|
#
|
|
"""Defines a capture-the-flag game."""
|
|
|
|
# ba_meta require api 7
|
|
# (see https://ballistica.net/wiki/meta-tag-system)
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import ba
|
|
from bastd.actor.playerspaz import PlayerSpaz
|
|
from bastd.actor.scoreboard import Scoreboard
|
|
from bastd.actor.flag import (
|
|
FlagFactory,
|
|
Flag,
|
|
FlagPickedUpMessage,
|
|
FlagDroppedMessage,
|
|
FlagDiedMessage,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Any, Sequence
|
|
|
|
|
|
class CTFFlag(Flag):
|
|
"""Special flag type for CTF games."""
|
|
|
|
activity: CaptureTheFlagGame
|
|
|
|
def __init__(self, team: Team):
|
|
assert team.flagmaterial is not None
|
|
super().__init__(
|
|
materials=[team.flagmaterial],
|
|
position=team.base_pos,
|
|
color=team.color,
|
|
)
|
|
self._team = team
|
|
self.held_count = 0
|
|
self.counter = ba.newnode(
|
|
'text',
|
|
owner=self.node,
|
|
attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'},
|
|
)
|
|
self.reset_return_times()
|
|
self.last_player_to_hold: Player | None = None
|
|
self.time_out_respawn_time: int | None = None
|
|
self.touch_return_time: float | None = None
|
|
|
|
def reset_return_times(self) -> None:
|
|
"""Clear flag related times in the activity."""
|
|
self.time_out_respawn_time = int(self.activity.flag_idle_return_time)
|
|
self.touch_return_time = float(self.activity.flag_touch_return_time)
|
|
|
|
@property
|
|
def team(self) -> Team:
|
|
"""The flag's team."""
|
|
return self._team
|
|
|
|
|
|
class Player(ba.Player['Team']):
|
|
"""Our player type for this game."""
|
|
|
|
def __init__(self) -> None:
|
|
self.touching_own_flag = 0
|
|
|
|
|
|
class Team(ba.Team[Player]):
|
|
"""Our team type for this game."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_pos: Sequence[float],
|
|
base_region_material: ba.Material,
|
|
base_region: ba.Node,
|
|
spaz_material_no_flag_physical: ba.Material,
|
|
spaz_material_no_flag_collide: ba.Material,
|
|
flagmaterial: ba.Material,
|
|
):
|
|
self.base_pos = base_pos
|
|
self.base_region_material = base_region_material
|
|
self.base_region = base_region
|
|
self.spaz_material_no_flag_physical = spaz_material_no_flag_physical
|
|
self.spaz_material_no_flag_collide = spaz_material_no_flag_collide
|
|
self.flagmaterial = flagmaterial
|
|
self.score = 0
|
|
self.flag_return_touches = 0
|
|
self.home_flag_at_base = True
|
|
self.touch_return_timer: ba.Timer | None = None
|
|
self.enemy_flag_at_base = False
|
|
self.flag: CTFFlag | None = None
|
|
self.last_flag_leave_time: float | None = None
|
|
self.touch_return_timer_ticking: ba.NodeActor | None = None
|
|
|
|
|
|
# ba_meta export game
|
|
class CaptureTheFlagGame(ba.TeamGameActivity[Player, Team]):
|
|
"""Game of stealing other team's flag and returning it to your base."""
|
|
|
|
name = 'Capture the Flag'
|
|
description = 'Return the enemy flag to score.'
|
|
available_settings = [
|
|
ba.IntSetting('Score to Win', min_value=1, default=3),
|
|
ba.IntSetting(
|
|
'Flag Touch Return Time',
|
|
min_value=0,
|
|
default=0,
|
|
increment=1,
|
|
),
|
|
ba.IntSetting(
|
|
'Flag Idle Return Time',
|
|
min_value=5,
|
|
default=30,
|
|
increment=5,
|
|
),
|
|
ba.IntChoiceSetting(
|
|
'Time Limit',
|
|
choices=[
|
|
('None', 0),
|
|
('1 Minute', 60),
|
|
('2 Minutes', 120),
|
|
('5 Minutes', 300),
|
|
('10 Minutes', 600),
|
|
('20 Minutes', 1200),
|
|
],
|
|
default=0,
|
|
),
|
|
ba.FloatChoiceSetting(
|
|
'Respawn Times',
|
|
choices=[
|
|
('Shorter', 0.25),
|
|
('Short', 0.5),
|
|
('Normal', 1.0),
|
|
('Long', 2.0),
|
|
('Longer', 4.0),
|
|
],
|
|
default=1.0,
|
|
),
|
|
ba.BoolSetting('Epic Mode', default=False),
|
|
]
|
|
|
|
@classmethod
|
|
def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
|
|
return issubclass(sessiontype, ba.DualTeamSession)
|
|
|
|
@classmethod
|
|
def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
|
|
return ba.getmaps('team_flag')
|
|
|
|
def __init__(self, settings: dict):
|
|
super().__init__(settings)
|
|
self._scoreboard = Scoreboard()
|
|
self._alarmsound = ba.getsound('alarm')
|
|
self._ticking_sound = ba.getsound('ticking')
|
|
self._score_sound = ba.getsound('score')
|
|
self._swipsound = ba.getsound('swip')
|
|
self._last_score_time = 0
|
|
self._all_bases_material = ba.Material()
|
|
self._last_home_flag_notice_print_time = 0.0
|
|
self._score_to_win = int(settings['Score to Win'])
|
|
self._epic_mode = bool(settings['Epic Mode'])
|
|
self._time_limit = float(settings['Time Limit'])
|
|
|
|
self.flag_touch_return_time = float(settings['Flag Touch Return Time'])
|
|
self.flag_idle_return_time = float(settings['Flag Idle Return Time'])
|
|
|
|
# Base class overrides.
|
|
self.slow_motion = self._epic_mode
|
|
self.default_music = (
|
|
ba.MusicType.EPIC if self._epic_mode else ba.MusicType.FLAG_CATCHER
|
|
)
|
|
|
|
def get_instance_description(self) -> str | Sequence:
|
|
if self._score_to_win == 1:
|
|
return 'Steal the enemy flag.'
|
|
return 'Steal the enemy flag ${ARG1} times.', self._score_to_win
|
|
|
|
def get_instance_description_short(self) -> str | Sequence:
|
|
if self._score_to_win == 1:
|
|
return 'return 1 flag'
|
|
return 'return ${ARG1} flags', self._score_to_win
|
|
|
|
def create_team(self, sessionteam: ba.SessionTeam) -> Team:
|
|
|
|
# Create our team instance and its initial values.
|
|
|
|
base_pos = self.map.get_flag_position(sessionteam.id)
|
|
Flag.project_stand(base_pos)
|
|
|
|
ba.newnode(
|
|
'light',
|
|
attrs={
|
|
'position': base_pos,
|
|
'intensity': 0.6,
|
|
'height_attenuated': False,
|
|
'volume_intensity_scale': 0.1,
|
|
'radius': 0.1,
|
|
'color': sessionteam.color,
|
|
},
|
|
)
|
|
|
|
base_region_mat = ba.Material()
|
|
pos = base_pos
|
|
base_region = ba.newnode(
|
|
'region',
|
|
attrs={
|
|
'position': (pos[0], pos[1] + 0.75, pos[2]),
|
|
'scale': (0.5, 0.5, 0.5),
|
|
'type': 'sphere',
|
|
'materials': [base_region_mat, self._all_bases_material],
|
|
},
|
|
)
|
|
|
|
spaz_mat_no_flag_physical = ba.Material()
|
|
spaz_mat_no_flag_collide = ba.Material()
|
|
flagmat = ba.Material()
|
|
|
|
team = Team(
|
|
base_pos=base_pos,
|
|
base_region_material=base_region_mat,
|
|
base_region=base_region,
|
|
spaz_material_no_flag_physical=spaz_mat_no_flag_physical,
|
|
spaz_material_no_flag_collide=spaz_mat_no_flag_collide,
|
|
flagmaterial=flagmat,
|
|
)
|
|
|
|
# Some parts of our spazzes don't collide physically with our
|
|
# flags but generate callbacks.
|
|
spaz_mat_no_flag_physical.add_actions(
|
|
conditions=('they_have_material', flagmat),
|
|
actions=(
|
|
('modify_part_collision', 'physical', False),
|
|
(
|
|
'call',
|
|
'at_connect',
|
|
lambda: self._handle_touching_own_flag(team, True),
|
|
),
|
|
(
|
|
'call',
|
|
'at_disconnect',
|
|
lambda: self._handle_touching_own_flag(team, False),
|
|
),
|
|
),
|
|
)
|
|
|
|
# Other parts of our spazzes don't collide with our flags at all.
|
|
spaz_mat_no_flag_collide.add_actions(
|
|
conditions=('they_have_material', flagmat),
|
|
actions=('modify_part_collision', 'collide', False),
|
|
)
|
|
|
|
# We wanna know when *any* flag enters/leaves our base.
|
|
base_region_mat.add_actions(
|
|
conditions=('they_have_material', FlagFactory.get().flagmaterial),
|
|
actions=(
|
|
('modify_part_collision', 'collide', True),
|
|
('modify_part_collision', 'physical', False),
|
|
(
|
|
'call',
|
|
'at_connect',
|
|
lambda: self._handle_flag_entered_base(team),
|
|
),
|
|
(
|
|
'call',
|
|
'at_disconnect',
|
|
lambda: self._handle_flag_left_base(team),
|
|
),
|
|
),
|
|
)
|
|
|
|
return team
|
|
|
|
def on_team_join(self, team: Team) -> None:
|
|
# Can't do this in create_team because the team's color/etc. have
|
|
# not been wired up yet at that point.
|
|
self._spawn_flag_for_team(team)
|
|
self._update_scoreboard()
|
|
|
|
def on_begin(self) -> None:
|
|
super().on_begin()
|
|
self.setup_standard_time_limit(self._time_limit)
|
|
self.setup_standard_powerup_drops()
|
|
ba.timer(1.0, call=self._tick, repeat=True)
|
|
|
|
def _spawn_flag_for_team(self, team: Team) -> None:
|
|
team.flag = CTFFlag(team)
|
|
team.flag_return_touches = 0
|
|
self._flash_base(team, length=1.0)
|
|
assert team.flag.node
|
|
ba.playsound(self._swipsound, position=team.flag.node.position)
|
|
|
|
def _handle_flag_entered_base(self, team: Team) -> None:
|
|
try:
|
|
flag = ba.getcollision().opposingnode.getdelegate(CTFFlag, True)
|
|
except ba.NotFoundError:
|
|
# Don't think this should logically ever happen.
|
|
print('Error getting CTFFlag in entering-base callback.')
|
|
return
|
|
|
|
if flag.team is team:
|
|
team.home_flag_at_base = True
|
|
|
|
# If the enemy flag is already here, score!
|
|
if team.enemy_flag_at_base:
|
|
# And show team name which scored (but actually we could
|
|
# show here player who returned enemy flag).
|
|
self.show_zoom_message(
|
|
ba.Lstr(
|
|
resource='nameScoresText', subs=[('${NAME}', team.name)]
|
|
),
|
|
color=team.color,
|
|
)
|
|
self._score(team)
|
|
else:
|
|
team.enemy_flag_at_base = True
|
|
if team.home_flag_at_base:
|
|
# Award points to whoever was carrying the enemy flag.
|
|
player = flag.last_player_to_hold
|
|
if player and player.team is team:
|
|
assert self.stats
|
|
self.stats.player_scored(player, 50, big_message=True)
|
|
|
|
# Update score and reset flags.
|
|
self._score(team)
|
|
|
|
# If the home-team flag isn't here, print a message to that effect.
|
|
else:
|
|
# Don't want slo-mo affecting this
|
|
curtime = ba.time(ba.TimeType.BASE)
|
|
if curtime - self._last_home_flag_notice_print_time > 5.0:
|
|
self._last_home_flag_notice_print_time = curtime
|
|
bpos = team.base_pos
|
|
tval = ba.Lstr(resource='ownFlagAtYourBaseWarning')
|
|
tnode = ba.newnode(
|
|
'text',
|
|
attrs={
|
|
'text': tval,
|
|
'in_world': True,
|
|
'scale': 0.013,
|
|
'color': (1, 1, 0, 1),
|
|
'h_align': 'center',
|
|
'position': (bpos[0], bpos[1] + 3.2, bpos[2]),
|
|
},
|
|
)
|
|
ba.timer(5.1, tnode.delete)
|
|
ba.animate(
|
|
tnode, 'scale', {0.0: 0, 0.2: 0.013, 4.8: 0.013, 5.0: 0}
|
|
)
|
|
|
|
def _tick(self) -> None:
|
|
# If either flag is away from base and not being held, tick down its
|
|
# respawn timer.
|
|
for team in self.teams:
|
|
flag = team.flag
|
|
assert flag is not None
|
|
|
|
if not team.home_flag_at_base and flag.held_count == 0:
|
|
time_out_counting_down = True
|
|
if flag.time_out_respawn_time is None:
|
|
flag.reset_return_times()
|
|
assert flag.time_out_respawn_time is not None
|
|
flag.time_out_respawn_time -= 1
|
|
if flag.time_out_respawn_time <= 0:
|
|
flag.handlemessage(ba.DieMessage())
|
|
else:
|
|
time_out_counting_down = False
|
|
|
|
if flag.node and flag.counter:
|
|
pos = flag.node.position
|
|
flag.counter.position = (pos[0], pos[1] + 1.3, pos[2])
|
|
|
|
# If there's no self-touches on this flag, set its text
|
|
# to show its auto-return counter. (if there's self-touches
|
|
# its showing that time).
|
|
if team.flag_return_touches == 0:
|
|
flag.counter.text = (
|
|
str(flag.time_out_respawn_time)
|
|
if (
|
|
time_out_counting_down
|
|
and flag.time_out_respawn_time is not None
|
|
and flag.time_out_respawn_time <= 10
|
|
)
|
|
else ''
|
|
)
|
|
flag.counter.color = (1, 1, 1, 0.5)
|
|
flag.counter.scale = 0.014
|
|
|
|
def _score(self, team: Team) -> None:
|
|
team.score += 1
|
|
ba.playsound(self._score_sound)
|
|
self._flash_base(team)
|
|
self._update_scoreboard()
|
|
|
|
# Have teammates celebrate.
|
|
for player in team.players:
|
|
if player.actor:
|
|
player.actor.handlemessage(ba.CelebrateMessage(2.0))
|
|
|
|
# Reset all flags/state.
|
|
for reset_team in self.teams:
|
|
if not reset_team.home_flag_at_base:
|
|
assert reset_team.flag is not None
|
|
reset_team.flag.handlemessage(ba.DieMessage())
|
|
reset_team.enemy_flag_at_base = False
|
|
if team.score >= self._score_to_win:
|
|
self.end_game()
|
|
|
|
def end_game(self) -> None:
|
|
results = ba.GameResults()
|
|
for team in self.teams:
|
|
results.set_team_score(team, team.score)
|
|
self.end(results=results, announce_delay=0.8)
|
|
|
|
def _handle_flag_left_base(self, team: Team) -> None:
|
|
cur_time = ba.time()
|
|
try:
|
|
flag = ba.getcollision().opposingnode.getdelegate(CTFFlag, True)
|
|
except ba.NotFoundError:
|
|
# This can happen if the flag stops touching us due to being
|
|
# deleted; that's ok.
|
|
return
|
|
|
|
if flag.team is team:
|
|
|
|
# Check times here to prevent too much flashing.
|
|
if (
|
|
team.last_flag_leave_time is None
|
|
or cur_time - team.last_flag_leave_time > 3.0
|
|
):
|
|
ba.playsound(self._alarmsound, position=team.base_pos)
|
|
self._flash_base(team)
|
|
team.last_flag_leave_time = cur_time
|
|
team.home_flag_at_base = False
|
|
else:
|
|
team.enemy_flag_at_base = False
|
|
|
|
def _touch_return_update(self, team: Team) -> None:
|
|
# Count down only while its away from base and not being held.
|
|
assert team.flag is not None
|
|
if team.home_flag_at_base or team.flag.held_count > 0:
|
|
team.touch_return_timer_ticking = None
|
|
return # No need to return when its at home.
|
|
if team.touch_return_timer_ticking is None:
|
|
team.touch_return_timer_ticking = ba.NodeActor(
|
|
ba.newnode(
|
|
'sound',
|
|
attrs={
|
|
'sound': self._ticking_sound,
|
|
'positional': False,
|
|
'loop': True,
|
|
},
|
|
)
|
|
)
|
|
flag = team.flag
|
|
if flag.touch_return_time is not None:
|
|
flag.touch_return_time -= 0.1
|
|
if flag.counter:
|
|
flag.counter.text = f'{flag.touch_return_time:.1f}'
|
|
flag.counter.color = (1, 1, 0, 1)
|
|
flag.counter.scale = 0.02
|
|
|
|
if flag.touch_return_time <= 0.0:
|
|
self._award_players_touching_own_flag(team)
|
|
flag.handlemessage(ba.DieMessage())
|
|
|
|
def _award_players_touching_own_flag(self, team: Team) -> None:
|
|
for player in team.players:
|
|
if player.touching_own_flag > 0:
|
|
return_score = 10 + 5 * int(self.flag_touch_return_time)
|
|
self.stats.player_scored(
|
|
player, return_score, screenmessage=False
|
|
)
|
|
|
|
def _handle_touching_own_flag(self, team: Team, connecting: bool) -> None:
|
|
"""Called when a player touches or stops touching their own team flag.
|
|
|
|
We keep track of when each player is touching their own flag so we
|
|
can award points when returned.
|
|
"""
|
|
player: Player | None
|
|
try:
|
|
spaz = ba.getcollision().sourcenode.getdelegate(PlayerSpaz, True)
|
|
except ba.NotFoundError:
|
|
return
|
|
|
|
if not spaz.is_alive():
|
|
return
|
|
|
|
player = spaz.getplayer(Player, True)
|
|
|
|
if player:
|
|
player.touching_own_flag += 1 if connecting else -1
|
|
|
|
# If return-time is zero, just kill it immediately.. otherwise keep
|
|
# track of touches and count down.
|
|
if float(self.flag_touch_return_time) <= 0.0:
|
|
assert team.flag is not None
|
|
if (
|
|
connecting
|
|
and not team.home_flag_at_base
|
|
and team.flag.held_count == 0
|
|
):
|
|
self._award_players_touching_own_flag(team)
|
|
ba.getcollision().opposingnode.handlemessage(ba.DieMessage())
|
|
|
|
# Takes a non-zero amount of time to return.
|
|
else:
|
|
if connecting:
|
|
team.flag_return_touches += 1
|
|
if team.flag_return_touches == 1:
|
|
team.touch_return_timer = ba.Timer(
|
|
0.1,
|
|
call=ba.Call(self._touch_return_update, team),
|
|
repeat=True,
|
|
)
|
|
team.touch_return_timer_ticking = None
|
|
else:
|
|
team.flag_return_touches -= 1
|
|
if team.flag_return_touches == 0:
|
|
team.touch_return_timer = None
|
|
team.touch_return_timer_ticking = None
|
|
if team.flag_return_touches < 0:
|
|
ba.print_error('CTF flag_return_touches < 0')
|
|
|
|
def _flash_base(self, team: Team, length: float = 2.0) -> None:
|
|
light = ba.newnode(
|
|
'light',
|
|
attrs={
|
|
'position': team.base_pos,
|
|
'height_attenuated': False,
|
|
'radius': 0.3,
|
|
'color': team.color,
|
|
},
|
|
)
|
|
ba.animate(light, 'intensity', {0.0: 0, 0.25: 2.0, 0.5: 0}, loop=True)
|
|
ba.timer(length, light.delete)
|
|
|
|
def spawn_player_spaz(
|
|
self,
|
|
player: Player,
|
|
position: Sequence[float] | None = None,
|
|
angle: float | None = None,
|
|
) -> PlayerSpaz:
|
|
"""Intercept new spazzes and add our team material for them."""
|
|
spaz = super().spawn_player_spaz(player, position, angle)
|
|
player = spaz.getplayer(Player, True)
|
|
team: Team = player.team
|
|
player.touching_own_flag = 0
|
|
no_physical_mats: list[ba.Material] = [
|
|
team.spaz_material_no_flag_physical
|
|
]
|
|
no_collide_mats: list[ba.Material] = [
|
|
team.spaz_material_no_flag_collide
|
|
]
|
|
|
|
# Our normal parts should still collide; just not physically
|
|
# (so we can calc restores).
|
|
assert spaz.node
|
|
spaz.node.materials = list(spaz.node.materials) + no_physical_mats
|
|
spaz.node.roller_materials = (
|
|
list(spaz.node.roller_materials) + no_physical_mats
|
|
)
|
|
|
|
# Pickups and punches shouldn't hit at all though.
|
|
spaz.node.punch_materials = (
|
|
list(spaz.node.punch_materials) + no_collide_mats
|
|
)
|
|
spaz.node.pickup_materials = (
|
|
list(spaz.node.pickup_materials) + no_collide_mats
|
|
)
|
|
spaz.node.extras_material = (
|
|
list(spaz.node.extras_material) + no_collide_mats
|
|
)
|
|
return spaz
|
|
|
|
def _update_scoreboard(self) -> None:
|
|
for team in self.teams:
|
|
self._scoreboard.set_team_value(
|
|
team, team.score, self._score_to_win
|
|
)
|
|
|
|
def handlemessage(self, msg: Any) -> Any:
|
|
|
|
if isinstance(msg, ba.PlayerDiedMessage):
|
|
super().handlemessage(msg) # Augment standard behavior.
|
|
self.respawn_player(msg.getplayer(Player))
|
|
|
|
elif isinstance(msg, FlagDiedMessage):
|
|
assert isinstance(msg.flag, CTFFlag)
|
|
ba.timer(0.1, ba.Call(self._spawn_flag_for_team, msg.flag.team))
|
|
|
|
elif isinstance(msg, FlagPickedUpMessage):
|
|
|
|
# Store the last player to hold the flag for scoring purposes.
|
|
assert isinstance(msg.flag, CTFFlag)
|
|
try:
|
|
msg.flag.last_player_to_hold = msg.node.getdelegate(
|
|
PlayerSpaz, True
|
|
).getplayer(Player, True)
|
|
except ba.NotFoundError:
|
|
pass
|
|
|
|
msg.flag.held_count += 1
|
|
msg.flag.reset_return_times()
|
|
|
|
elif isinstance(msg, FlagDroppedMessage):
|
|
# Store the last player to hold the flag for scoring purposes.
|
|
assert isinstance(msg.flag, CTFFlag)
|
|
msg.flag.held_count -= 1
|
|
|
|
else:
|
|
super().handlemessage(msg)
|