# Released under the MIT License. See LICENSE for details.
#
"""Bot versions of Spaz."""
# pylint: disable=too-many-lines
from __future__ import annotations
import random
import weakref
import logging
from typing import TYPE_CHECKING, override
import bascenev1 as bs
from bascenev1lib.actor.spaz import Spaz
if TYPE_CHECKING:
from typing import Any, Sequence, Callable
from bascenev1lib.actor.flag import Flag
LITE_BOT_COLOR = (1.2, 0.9, 0.2)
LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6)
DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6)
DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1)
PRO_BOT_COLOR = (1.0, 0.2, 0.1)
PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)
[docs]
class SpazBotPunchedMessage:
"""A message saying a bs.SpazBot got punched.
Category: **Message Classes**
"""
spazbot: SpazBot
"""The bs.SpazBot that got punched."""
damage: int
"""How much damage was done to the SpazBot."""
def __init__(self, spazbot: SpazBot, damage: int):
"""Instantiate a message with the given values."""
self.spazbot = spazbot
self.damage = damage
[docs]
class SpazBotDiedMessage:
"""A message saying a bs.SpazBot has died.
Category: **Message Classes**
"""
spazbot: SpazBot
"""The SpazBot that was killed."""
killerplayer: bs.Player | None
"""The bascenev1.Player that killed it (or None)."""
how: bs.DeathType
"""The particular type of death."""
def __init__(
self,
spazbot: SpazBot,
killerplayer: bs.Player | None,
how: bs.DeathType,
):
"""Instantiate with given values."""
self.spazbot = spazbot
self.killerplayer = killerplayer
self.how = how
[docs]
class SpazBot(Spaz):
"""A really dumb AI version of bs.Spaz.
Category: **Bot Classes**
Add these to a bs.BotSet to use them.
Note: currently the AI has no real ability to
navigate obstacles and so should only be used
on wide-open maps.
When a SpazBot is killed, it delivers a bs.SpazBotDiedMessage
to the current activity.
When a SpazBot is punched, it delivers a bs.SpazBotPunchedMessage
to the current activity.
"""
character = 'Spaz'
punchiness = 0.5
throwiness = 0.7
static = False
bouncy = False
run = False
charge_dist_min = 0.0 # When we can start a new charge.
charge_dist_max = 2.0 # When we can start a new charge.
run_dist_min = 0.0 # How close we can be to continue running.
charge_speed_min = 0.4
charge_speed_max = 1.0
throw_dist_min = 5.0
throw_dist_max = 9.0
throw_rate = 1.0
default_bomb_type = 'normal'
default_bomb_count = 3
start_cursed = False
color = DEFAULT_BOT_COLOR
highlight = DEFAULT_BOT_HIGHLIGHT
def __init__(self) -> None:
"""Instantiate a spaz-bot."""
super().__init__(
color=self.color,
highlight=self.highlight,
character=self.character,
source_player=None,
start_invincible=False,
can_accept_powerups=False,
)
# If you need to add custom behavior to a bot, set this to a callable
# which takes one arg (the bot) and returns False if the bot's normal
# update should be run and True if not.
self.update_callback: Callable[[SpazBot], Any] | None = None
activity = self.activity
assert isinstance(activity, bs.GameActivity)
self._map = weakref.ref(activity.map)
self.last_player_attacked_by: bs.Player | None = None
self.last_attacked_time = 0.0
self.last_attacked_type: tuple[str, str] | None = None
self.target_point_default: bs.Vec3 | None = None
self.held_count = 0
self.last_player_held_by: bs.Player | None = None
self.target_flag: Flag | None = None
self._charge_speed = 0.5 * (
self.charge_speed_min + self.charge_speed_max
)
self._lead_amount = 0.5
self._mode = 'wait'
self._charge_closing_in = False
self._last_charge_dist = 0.0
self._running = False
self._last_jump_time = 0.0
self._throw_release_time: float | None = None
self._have_dropped_throw_bomb: bool | None = None
self._player_pts: list[tuple[bs.Vec3, bs.Vec3]] | None = None
# These cooldowns didn't exist when these bots were calibrated,
# so take them out of the equation.
self._jump_cooldown = 0
self._pickup_cooldown = 0
self._fly_cooldown = 0
self._bomb_cooldown = 0
if self.start_cursed:
self.curse()
@property
def map(self) -> bs.Map:
"""The map this bot was created on."""
mval = self._map()
assert mval is not None
return mval
def _get_target_player_pt(self) -> tuple[bs.Vec3 | None, bs.Vec3 | None]:
"""Returns the position and velocity of our target.
Both values will be None in the case of no target.
"""
assert self.node
botpt = bs.Vec3(self.node.position)
closest_dist: float | None = None
closest_vel: bs.Vec3 | None = None
closest: bs.Vec3 | None = None
assert self._player_pts is not None
for plpt, plvel in self._player_pts:
dist = (plpt - botpt).length()
# Ignore player-points that are significantly below the bot
# (keeps bots from following players off cliffs).
if (closest_dist is None or dist < closest_dist) and (
plpt[1] > botpt[1] - 5.0
):
closest_dist = dist
closest_vel = plvel
closest = plpt
if closest_dist is not None:
assert closest_vel is not None
assert closest is not None
return (
bs.Vec3(closest[0], closest[1], closest[2]),
bs.Vec3(closest_vel[0], closest_vel[1], closest_vel[2]),
)
return None, None
[docs]
def set_player_points(self, pts: list[tuple[bs.Vec3, bs.Vec3]]) -> None:
"""Provide the spaz-bot with the locations of its enemies."""
self._player_pts = pts
[docs]
def update_ai(self) -> None:
"""Should be called periodically to update the spaz' AI."""
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
if self.update_callback is not None:
if self.update_callback(self):
# Bot has been handled.
return
if not self.node:
return
pos = self.node.position
our_pos = bs.Vec3(pos[0], 0, pos[2])
can_attack = True
target_pt_raw: bs.Vec3 | None
target_vel: bs.Vec3 | None
# If we're a flag-bearer, we're pretty simple-minded - just walk
# towards the flag and try to pick it up.
if self.target_flag:
if self.node.hold_node:
holding_flag = self.node.hold_node.getnodetype() == 'flag'
else:
holding_flag = False
# If we're holding the flag, just walk left.
if holding_flag:
# Just walk left.
self.node.move_left_right = -1.0
self.node.move_up_down = 0.0
# Otherwise try to go pick it up.
elif self.target_flag.node:
target_pt_raw = bs.Vec3(*self.target_flag.node.position)
diff = target_pt_raw - our_pos
diff = bs.Vec3(diff[0], 0, diff[2]) # Don't care about y.
dist = diff.length()
to_target = diff.normalized()
# If we're holding some non-flag item, drop it.
if self.node.hold_node:
self.node.pickup_pressed = True
self.node.pickup_pressed = False
return
# If we're a runner, run only when not super-near the flag.
if self.run and dist > 3.0:
self._running = True
self.node.run = 1.0
else:
self._running = False
self.node.run = 0.0
self.node.move_left_right = to_target.x
self.node.move_up_down = -to_target.z
if dist < 1.25:
self.node.pickup_pressed = True
self.node.pickup_pressed = False
return
# Not a flag-bearer. If we're holding anything but a bomb, drop it.
if self.node.hold_node:
holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
if not holding_bomb:
self.node.pickup_pressed = True
self.node.pickup_pressed = False
return
target_pt_raw, target_vel = self._get_target_player_pt()
if target_pt_raw is None:
# Use default target if we've got one.
if self.target_point_default is not None:
target_pt_raw = self.target_point_default
target_vel = bs.Vec3(0, 0, 0)
can_attack = False
# With no target, we stop moving and drop whatever we're holding.
else:
self.node.move_left_right = 0
self.node.move_up_down = 0
if self.node.hold_node:
self.node.pickup_pressed = True
self.node.pickup_pressed = False
return
# We don't want height to come into play.
target_pt_raw[1] = 0.0
assert target_vel is not None
target_vel[1] = 0.0
dist_raw = (target_pt_raw - our_pos).length()
# Use a point out in front of them as real target.
# (more out in front the farther from us they are)
target_pt = (
target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
)
diff = target_pt - our_pos
dist = diff.length()
to_target = diff.normalized()
if self._mode == 'throw':
# We can only throw if alive and well.
if not self._dead and not self.node.knockout:
assert self._throw_release_time is not None
time_till_throw = self._throw_release_time - bs.time()
if not self.node.hold_node:
# If we haven't thrown yet, whip out the bomb.
if not self._have_dropped_throw_bomb:
self.drop_bomb()
self._have_dropped_throw_bomb = True
# Otherwise our lack of held node means we successfully
# released our bomb; lets retreat now.
else:
self._mode = 'flee'
# Oh crap, we're holding a bomb; better throw it.
elif time_till_throw <= 0.0:
# Jump and throw.
def _safe_pickup(node: bs.Node) -> None:
if node and self.node:
self.node.pickup_pressed = True
self.node.pickup_pressed = False
if dist > 5.0:
self.node.jump_pressed = True
self.node.jump_pressed = False
# Throws:
bs.timer(0.1, bs.Call(_safe_pickup, self.node))
else:
# Throws:
bs.timer(0.1, bs.Call(_safe_pickup, self.node))
if self.static:
if time_till_throw < 0.3:
speed = 1.0
elif time_till_throw < 0.7 and dist > 3.0:
speed = -1.0 # Whiplash for long throws.
else:
speed = 0.02
else:
if time_till_throw < 0.7:
# Right before throw charge full speed towards target.
speed = 1.0
else:
# Earlier we can hold or move backward for a whiplash.
speed = 0.0125
self.node.move_left_right = to_target.x * speed
self.node.move_up_down = to_target.z * -1.0 * speed
elif self._mode == 'charge':
if random.random() < 0.3:
self._charge_speed = random.uniform(
self.charge_speed_min, self.charge_speed_max
)
# If we're a runner we run during charges *except when near
# an edge (otherwise we tend to fly off easily).
if self.run and dist_raw > self.run_dist_min:
self._lead_amount = 0.3
self._running = True
self.node.run = 1.0
else:
self._lead_amount = 0.01
self._running = False
self.node.run = 0.0
self.node.move_left_right = to_target.x * self._charge_speed
self.node.move_up_down = to_target.z * -1.0 * self._charge_speed
elif self._mode == 'wait':
# Every now and then, aim towards our target.
# Other than that, just stand there.
if int(bs.time() * 1000.0) % 1234 < 100:
self.node.move_left_right = to_target.x * (400.0 / 33000)
self.node.move_up_down = to_target.z * (-400.0 / 33000)
else:
self.node.move_left_right = 0
self.node.move_up_down = 0
elif self._mode == 'flee':
# Even if we're a runner, only run till we get away from our
# target (if we keep running we tend to run off edges).
if self.run and dist < 3.0:
self._running = True
self.node.run = 1.0
else:
self._running = False
self.node.run = 0.0
self.node.move_left_right = to_target.x * -1.0
self.node.move_up_down = to_target.z
# We might wanna switch states unless we're doing a throw
# (in which case that's our sole concern).
if self._mode != 'throw':
# If we're currently charging, keep track of how far we are
# from our target. When this value increases it means our charge
# is over (ran by them or something).
if self._mode == 'charge':
if (
self._charge_closing_in
and self._last_charge_dist < dist < 3.0
):
self._charge_closing_in = False
self._last_charge_dist = dist
# If we have a clean shot, throw!
if (
self.throw_dist_min <= dist < self.throw_dist_max
and random.random() < self.throwiness
and can_attack
):
self._mode = 'throw'
self._lead_amount = (
(0.4 + random.random() * 0.6)
if dist_raw > 4.0
else (0.1 + random.random() * 0.4)
)
self._have_dropped_throw_bomb = False
self._throw_release_time = bs.time() + (
1.0 / self.throw_rate
) * (0.8 + 1.3 * random.random())
# If we're static, always charge (which for us means barely move).
elif self.static:
self._mode = 'wait'
# If we're too close to charge (and aren't in the middle of an
# existing charge) run away.
elif dist < self.charge_dist_min and not self._charge_closing_in:
# ..unless we're near an edge, in which case we've got no
# choice but to charge.
if self.map.is_point_near_edge(our_pos, self._running):
if self._mode != 'charge':
self._mode = 'charge'
self._lead_amount = 0.2
self._charge_closing_in = True
self._last_charge_dist = dist
else:
self._mode = 'flee'
# We're within charging distance, backed against an edge,
# or farther than our max throw distance.. chaaarge!
elif (
dist < self.charge_dist_max
or dist > self.throw_dist_max
or self.map.is_point_near_edge(our_pos, self._running)
):
if self._mode != 'charge':
self._mode = 'charge'
self._lead_amount = 0.01
self._charge_closing_in = True
self._last_charge_dist = dist
# We're too close to throw but too far to charge - either run
# away or just chill if we're near an edge.
elif dist < self.throw_dist_min:
# Charge if either we're within charge range or
# cant retreat to throw.
self._mode = 'flee'
# Do some awesome jumps if we're running.
# FIXME: pylint: disable=too-many-boolean-expressions
if (
self._running
and 1.2 < dist < 2.2
and bs.time() - self._last_jump_time > 1.0
) or (
self.bouncy
and bs.time() - self._last_jump_time > 0.4
and random.random() < 0.5
):
self._last_jump_time = bs.time()
self.node.jump_pressed = True
self.node.jump_pressed = False
# Throw punches when real close.
if dist < (1.6 if self._running else 1.2) and can_attack:
if random.random() < self.punchiness:
self.on_punch_press()
self.on_punch_release()
[docs]
@override
def on_punched(self, damage: int) -> None:
"""
Method override; sends bs.SpazBotPunchedMessage
to the current activity.
"""
bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))
[docs]
@override
def on_expire(self) -> None:
super().on_expire()
# We're being torn down; release our callback(s) so there's
# no chance of them keeping activities or other things alive.
self.update_callback = None
[docs]
@override
def handlemessage(self, msg: Any) -> Any:
# pylint: disable=too-many-branches
assert not self.expired
# Keep track of if we're being held and by who most recently.
if isinstance(msg, bs.PickedUpMessage):
super().handlemessage(msg) # Augment standard behavior.
self.held_count += 1
picked_up_by = msg.node.source_player
if picked_up_by:
self.last_player_held_by = picked_up_by
elif isinstance(msg, bs.DroppedMessage):
super().handlemessage(msg) # Augment standard behavior.
self.held_count -= 1
if self.held_count < 0:
print('ERROR: spaz held_count < 0')
# Let's count someone dropping us as an attack.
try:
if msg.node:
picked_up_by = msg.node.source_player
else:
picked_up_by = None
except Exception:
logging.exception('Error on SpazBot DroppedMessage.')
picked_up_by = None
if picked_up_by:
self.last_player_attacked_by = picked_up_by
self.last_attacked_time = bs.time()
self.last_attacked_type = ('picked_up', 'default')
elif isinstance(msg, bs.DieMessage):
# Report normal deaths for scoring purposes.
if not self._dead and not msg.immediate:
killerplayer: bs.Player | None
# If this guy was being held at the time of death, the
# holder is the killer.
if self.held_count > 0 and self.last_player_held_by:
killerplayer = self.last_player_held_by
else:
# If they were attacked by someone in the last few
# seconds that person's the killer.
# Otherwise it was a suicide.
if (
self.last_player_attacked_by
and bs.time() - self.last_attacked_time < 4.0
):
killerplayer = self.last_player_attacked_by
else:
killerplayer = None
activity = self._activity()
# (convert dead player refs to None)
if not killerplayer:
killerplayer = None
if activity is not None:
activity.handlemessage(
SpazBotDiedMessage(self, killerplayer, msg.how)
)
super().handlemessage(msg) # Augment standard behavior.
# Keep track of the player who last hit us for point rewarding.
elif isinstance(msg, bs.HitMessage):
source_player = msg.get_source_player(bs.Player)
if source_player:
self.last_player_attacked_by = source_player
self.last_attacked_time = bs.time()
self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
super().handlemessage(msg)
else:
super().handlemessage(msg)
[docs]
class BomberBot(SpazBot):
"""A bot that throws regular bombs and occasionally punches.
category: Bot Classes
"""
character = 'Spaz'
punchiness = 0.3
[docs]
class BomberBotLite(BomberBot):
"""A less aggressive yellow version of bs.BomberBot.
category: Bot Classes
"""
color = LITE_BOT_COLOR
highlight = LITE_BOT_HIGHLIGHT
punchiness = 0.2
throw_rate = 0.7
throwiness = 0.1
charge_speed_min = 0.6
charge_speed_max = 0.6
[docs]
class BomberBotStaticLite(BomberBotLite):
"""A less aggressive generally immobile weak version of bs.BomberBot.
category: Bot Classes
"""
static = True
throw_dist_min = 0.0
[docs]
class BomberBotStatic(BomberBot):
"""A version of bs.BomberBot who generally stays in one place.
category: Bot Classes
"""
static = True
throw_dist_min = 0.0
[docs]
class BomberBotPro(BomberBot):
"""A more powerful version of bs.BomberBot.
category: Bot Classes
"""
points_mult = 2
color = PRO_BOT_COLOR
highlight = PRO_BOT_HIGHLIGHT
default_bomb_count = 3
default_boxing_gloves = True
punchiness = 0.7
throw_rate = 1.3
run = True
run_dist_min = 6.0
[docs]
class BomberBotProShielded(BomberBotPro):
"""A more powerful version of bs.BomberBot who starts with shields.
category: Bot Classes
"""
points_mult = 3
default_shields = True
[docs]
class BomberBotProStatic(BomberBotPro):
"""A more powerful bs.BomberBot who generally stays in one place.
category: Bot Classes
"""
static = True
throw_dist_min = 0.0
[docs]
class BomberBotProStaticShielded(BomberBotProShielded):
"""A powerful bs.BomberBot with shields who is generally immobile.
category: Bot Classes
"""
static = True
throw_dist_min = 0.0
[docs]
class BrawlerBot(SpazBot):
"""A bot who walks and punches things.
category: Bot Classes
"""
character = 'Kronk'
punchiness = 0.9
charge_dist_max = 9999.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
[docs]
class BrawlerBotLite(BrawlerBot):
"""A weaker version of bs.BrawlerBot.
category: Bot Classes
"""
color = LITE_BOT_COLOR
highlight = LITE_BOT_HIGHLIGHT
punchiness = 0.3
charge_speed_min = 0.6
charge_speed_max = 0.6
[docs]
class BrawlerBotPro(BrawlerBot):
"""A stronger version of bs.BrawlerBot.
category: Bot Classes
"""
color = PRO_BOT_COLOR
highlight = PRO_BOT_HIGHLIGHT
run = True
run_dist_min = 4.0
default_boxing_gloves = True
punchiness = 0.95
points_mult = 2
[docs]
class BrawlerBotProShielded(BrawlerBotPro):
"""A stronger version of bs.BrawlerBot who starts with shields.
category: Bot Classes
"""
default_shields = True
points_mult = 3
[docs]
class ChargerBot(SpazBot):
"""A speedy melee attack bot.
category: Bot Classes
"""
character = 'Snake Shadow'
punchiness = 1.0
run = True
charge_dist_min = 10.0
charge_dist_max = 9999.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
points_mult = 2
[docs]
class BouncyBot(SpazBot):
"""A speedy attacking melee bot that jumps constantly.
category: Bot Classes
"""
color = (1, 1, 1)
highlight = (1.0, 0.5, 0.5)
character = 'Easter Bunny'
punchiness = 1.0
run = True
bouncy = True
default_boxing_gloves = True
charge_dist_min = 10.0
charge_dist_max = 9999.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
points_mult = 2
[docs]
class ChargerBotPro(ChargerBot):
"""A stronger bs.ChargerBot.
category: Bot Classes
"""
color = PRO_BOT_COLOR
highlight = PRO_BOT_HIGHLIGHT
default_boxing_gloves = True
points_mult = 3
[docs]
class ChargerBotProShielded(ChargerBotPro):
"""A stronger bs.ChargerBot who starts with shields.
category: Bot Classes
"""
default_shields = True
points_mult = 4
[docs]
class TriggerBot(SpazBot):
"""A slow moving bot with trigger bombs.
category: Bot Classes
"""
character = 'Zoe'
punchiness = 0.75
throwiness = 0.7
charge_dist_max = 1.0
charge_speed_min = 0.3
charge_speed_max = 0.5
throw_dist_min = 3.5
throw_dist_max = 5.5
default_bomb_type = 'impact'
points_mult = 2
[docs]
class TriggerBotStatic(TriggerBot):
"""A bs.TriggerBot who generally stays in one place.
category: Bot Classes
"""
static = True
throw_dist_min = 0.0
[docs]
class TriggerBotPro(TriggerBot):
"""A stronger version of bs.TriggerBot.
category: Bot Classes
"""
color = PRO_BOT_COLOR
highlight = PRO_BOT_HIGHLIGHT
default_bomb_count = 3
default_boxing_gloves = True
charge_speed_min = 1.0
charge_speed_max = 1.0
punchiness = 0.9
throw_rate = 1.3
run = True
run_dist_min = 6.0
points_mult = 3
[docs]
class TriggerBotProShielded(TriggerBotPro):
"""A stronger version of bs.TriggerBot who starts with shields.
category: Bot Classes
"""
default_shields = True
points_mult = 4
[docs]
class StickyBot(SpazBot):
"""A crazy bot who runs and throws sticky bombs.
category: Bot Classes
"""
character = 'Mel'
punchiness = 0.9
throwiness = 1.0
run = True
charge_dist_min = 4.0
charge_dist_max = 10.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 0.0
throw_dist_max = 4.0
throw_rate = 2.0
default_bomb_type = 'sticky'
default_bomb_count = 3
points_mult = 3
[docs]
class StickyBotStatic(StickyBot):
"""A crazy bot who throws sticky-bombs but generally stays in one place.
category: Bot Classes
"""
static = True
[docs]
class ExplodeyBot(SpazBot):
"""A bot who runs and explodes in 5 seconds.
category: Bot Classes
"""
character = 'Jack Morgan'
run = True
charge_dist_min = 0.0
charge_dist_max = 9999
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
start_cursed = True
points_mult = 4
[docs]
class ExplodeyBotNoTimeLimit(ExplodeyBot):
"""A bot who runs but does not explode on his own.
category: Bot Classes
"""
curse_time = None
[docs]
class ExplodeyBotShielded(ExplodeyBot):
"""A bs.ExplodeyBot who starts with shields.
category: Bot Classes
"""
default_shields = True
points_mult = 5
[docs]
class SpazBotSet:
"""A container/controller for one or more bs.SpazBots.
category: Bot Classes
"""
def __init__(self) -> None:
"""Create a bot-set."""
# We spread our bots out over a few lists so we can update
# them in a staggered fashion.
self._bot_list_count = 5
self._bot_add_list = 0
self._bot_update_list = 0
self._bot_lists: list[list[SpazBot]] = [
[] for _ in range(self._bot_list_count)
]
self._spawn_sound = bs.getsound('spawn')
self._spawning_count = 0
self._bot_update_timer: bs.Timer | None = None
self.start_moving()
def __del__(self) -> None:
self.clear()
[docs]
def spawn_bot(
self,
bot_type: type[SpazBot],
pos: Sequence[float],
spawn_time: float = 3.0,
on_spawn_call: Callable[[SpazBot], Any] | None = None,
) -> None:
"""Spawn a bot from this set."""
from bascenev1lib.actor.spawner import Spawner
Spawner(
pt=pos,
spawn_time=spawn_time,
send_spawn_message=False,
spawn_callback=bs.Call(
self._spawn_bot, bot_type, pos, on_spawn_call
),
)
self._spawning_count += 1
def _spawn_bot(
self,
bot_type: type[SpazBot],
pos: Sequence[float],
on_spawn_call: Callable[[SpazBot], Any] | None,
) -> None:
spaz = bot_type()
self._spawn_sound.play(position=pos)
assert spaz.node
spaz.node.handlemessage('flash')
spaz.node.is_area_of_interest = False
spaz.handlemessage(bs.StandMessage(pos, random.uniform(0, 360)))
self.add_bot(spaz)
self._spawning_count -= 1
if on_spawn_call is not None:
on_spawn_call(spaz)
[docs]
def have_living_bots(self) -> bool:
"""Return whether any bots in the set are alive or spawning."""
return self._spawning_count > 0 or any(
any(b.is_alive() for b in l) for l in self._bot_lists
)
[docs]
def get_living_bots(self) -> list[SpazBot]:
"""Get the living bots in the set."""
bots: list[SpazBot] = []
for botlist in self._bot_lists:
for bot in botlist:
if bot.is_alive():
bots.append(bot)
return bots
def _update(self) -> None:
# Update one of our bot lists each time through.
# First off, remove no-longer-existing bots from the list.
try:
bot_list = self._bot_lists[self._bot_update_list] = [
b for b in self._bot_lists[self._bot_update_list] if b
]
except Exception:
bot_list = []
logging.exception(
'Error updating bot list: %s',
self._bot_lists[self._bot_update_list],
)
self._bot_update_list = (
self._bot_update_list + 1
) % self._bot_list_count
# Update our list of player points for the bots to use.
player_pts = []
for player in bs.getactivity().players:
assert isinstance(player, bs.Player)
try:
# TODO: could use abstracted player.position here so we
# don't have to assume their actor type, but we have no
# abstracted velocity as of yet.
if player.is_alive():
assert isinstance(player.actor, Spaz)
assert player.actor.node
player_pts.append(
(
bs.Vec3(player.actor.node.position),
bs.Vec3(player.actor.node.velocity),
)
)
except Exception:
logging.exception('Error on bot-set _update.')
for bot in bot_list:
bot.set_player_points(player_pts)
bot.update_ai()
[docs]
def clear(self) -> None:
"""Immediately clear out any bots in the set."""
# Don't do this if the activity is shutting down or dead.
activity = bs.getactivity(doraise=False)
if activity is None or activity.expired:
return
for i, bot_list in enumerate(self._bot_lists):
for bot in bot_list:
bot.handlemessage(bs.DieMessage(immediate=True))
self._bot_lists[i] = []
[docs]
def start_moving(self) -> None:
"""Start processing bot AI updates so they start doing their thing."""
self._bot_update_timer = bs.Timer(
0.05, bs.WeakCall(self._update), repeat=True
)
[docs]
def stop_moving(self) -> None:
"""Tell all bots to stop moving and stops updating their AI.
Useful when players have won and you want the
enemy bots to just stand and look bewildered.
"""
self._bot_update_timer = None
for botlist in self._bot_lists:
for bot in botlist:
if bot.node:
bot.node.move_left_right = 0
bot.node.move_up_down = 0
[docs]
def celebrate(self, duration: float) -> None:
"""Tell all living bots in the set to celebrate momentarily.
Duration is given in seconds.
"""
msg = bs.CelebrateMessage(duration=duration)
for botlist in self._bot_lists:
for bot in botlist:
if bot:
bot.handlemessage(msg)
[docs]
def final_celebrate(self) -> None:
"""Tell all bots in the set to stop what they were doing and celebrate.
Use this when the bots have won a game.
"""
self._bot_update_timer = None
# At this point stop doing anything but jumping and celebrating.
for botlist in self._bot_lists:
for bot in botlist:
if bot:
assert bot.node # (should exist if 'if bot' was True)
bot.node.move_left_right = 0
bot.node.move_up_down = 0
bs.timer(
0.5 * random.random(),
bs.Call(bot.handlemessage, bs.CelebrateMessage()),
)
jump_duration = random.randrange(400, 500)
j = random.randrange(0, 200)
for _i in range(10):
bot.node.jump_pressed = True
bot.node.jump_pressed = False
j += jump_duration
bs.timer(
random.uniform(0.0, 1.0),
bs.Call(bot.node.handlemessage, 'attack_sound'),
)
bs.timer(
random.uniform(1.0, 2.0),
bs.Call(bot.node.handlemessage, 'attack_sound'),
)
bs.timer(
random.uniform(2.0, 3.0),
bs.Call(bot.node.handlemessage, 'attack_sound'),
)
[docs]
def add_bot(self, bot: SpazBot) -> None:
"""Add a bs.SpazBot instance to the set."""
self._bot_lists[self._bot_add_list].append(bot)
self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count