# Released under the MIT License. See LICENSE for details.
#
"""Various classes for bombs, mines, tnt, etc."""
# FIXME
# pylint: disable=too-many-lines
from __future__ import annotations
import random
from typing import TYPE_CHECKING, override
import bascenev1 as bs
from bascenev1lib.gameutils import SharedObjects
if TYPE_CHECKING:
from typing import Any, Sequence, Callable
[docs]
class BombFactory:
"""Wraps up media and other resources used by the
:class:`~bascenev1lib.actor.bomb.Bomb` actor.
A single instance of this is shared between all bombs and can be
retrieved via the static :meth:`BombFactory.get()` method.
"""
#: The mesh used for standard or ice bombs.
bomb_mesh: bs.Mesh
#: The mesh used for sticky-bombs.
sticky_bomb_mesh: bs.Mesh
#: The mesh used for impact-bombs.
impact_bomb_mesh: bs.Mesh
#: The mesh used for land-mines.
land_mine_mesh: bs.Mesh
#: The mesh used of a tnt box.
tnt_mesh: bs.Mesh
#: The texture used for regular bombs.
regular_tex: bs.Texture
#: The bs.Texture for ice bombs.
ice_tex: bs.Texture
#: The bs.Texture for sticky bombs.
sticky_tex: bs.Texture
#: The bs.Texture for impact bombs.
impact_tex: bs.Texture
#: The texture for impact bombs with lights lit.
impact_lit_tex: bs.Texture
#: The texture for land-mines.
land_mine_tex: bs.Texture
#: The texture for land-mines with the light lit.
land_mine_lit_tex: bs.Texture
#: The texture for tnt boxes.
tnt_tex: bs.Texture
#: The sound for the hiss sound an ice bomb makes.
hiss_sound: bs.Sound
#: The sound for random falling debris after an explosion.
debris_fall_sound: bs.Sound
#: A sound for random wood debris falling after an explosion.
wood_debris_fall_sound: bs.Sound
#: A tuple of sounds for explosions.
explode_sounds: Sequence[bs.Sound]
#: A sound of an ice bomb freezing something.
freeze_sound: bs.Sound
#: A sound of a burning fuse.
fuse_sound: bs.Sound
#: A sound for an activating impact bomb.
activate_sound: bs.Sound
#: A sound for an impact bomb about to explode due to time-out.
warn_sound: bs.Sound
#: A material applied to all bombs.
bomb_material: bs.Material
#: A material that generates standard bomb noises on impacts, etc.
normal_sound_material: bs.Material
#: A material that makes 'splat' sounds and makes collisions softer.
sticky_material: bs.Material
#: A material that keeps land-mines from blowing up. Applied to
#: land-mines when they are created to allow land-mines to touch
#: without exploding."""
land_mine_no_explode_material: bs.Material
#: A bs.Material applied to activated land-mines that causes them to
#: explode on impact."""
land_mine_blast_material: bs.Material
#: A material applied to activated impact-bombs that causes them
#: to explode on impact."""
impact_blast_material: bs.Material
blast_material: bs.Material
#: A bs.Material applied to bomb blast geometry which triggers impact
#: events with what it touches.
#: A tuple of sounds for when bombs hit the ground.
dink_sounds: Sequence[bs.Sound]
#: The sound for a squish made by a sticky bomb hitting something.
sticky_impact_sound: bs.Sound
#: The sound for a rolling bomb.
roll_sound: bs.Sound
_STORENAME = bs.storagename()
[docs]
@classmethod
def get(cls) -> BombFactory:
"""Create and/or return the single shared instance of this class."""
activity = bs.getactivity()
factory = activity.customdata.get(cls._STORENAME)
if factory is None:
factory = BombFactory()
activity.customdata[cls._STORENAME] = factory
assert isinstance(factory, BombFactory)
return factory
[docs]
def random_explode_sound(self) -> bs.Sound:
"""Return a random explosion sound from the factory."""
return self.explode_sounds[random.randrange(len(self.explode_sounds))]
def __init__(self) -> None:
"""Instantiate a BombFactory.
You shouldn't need to do this; call
bascenev1lib.actor.bomb.get_factory() to get a shared instance.
"""
shared = SharedObjects.get()
self.bomb_mesh = bs.getmesh('bomb')
self.sticky_bomb_mesh = bs.getmesh('bombSticky')
self.impact_bomb_mesh = bs.getmesh('impactBomb')
self.land_mine_mesh = bs.getmesh('landMine')
self.tnt_mesh = bs.getmesh('tnt')
self.regular_tex = bs.gettexture('bombColor')
self.ice_tex = bs.gettexture('bombColorIce')
self.sticky_tex = bs.gettexture('bombStickyColor')
self.impact_tex = bs.gettexture('impactBombColor')
self.impact_lit_tex = bs.gettexture('impactBombColorLit')
self.land_mine_tex = bs.gettexture('landMine')
self.land_mine_lit_tex = bs.gettexture('landMineLit')
self.tnt_tex = bs.gettexture('tnt')
self.hiss_sound = bs.getsound('hiss')
self.debris_fall_sound = bs.getsound('debrisFall')
self.wood_debris_fall_sound = bs.getsound('woodDebrisFall')
self.explode_sounds = (
bs.getsound('explosion01'),
bs.getsound('explosion02'),
bs.getsound('explosion03'),
bs.getsound('explosion04'),
bs.getsound('explosion05'),
)
self.freeze_sound = bs.getsound('freeze')
self.fuse_sound = bs.getsound('fuse01')
self.activate_sound = bs.getsound('activateBeep')
self.warn_sound = bs.getsound('warnBeep')
# Set up our material so new bombs don't collide with objects
# that they are initially overlapping.
self.bomb_material = bs.Material()
self.normal_sound_material = bs.Material()
self.sticky_material = bs.Material()
self.bomb_material.add_actions(
conditions=(
(
('we_are_younger_than', 100),
'or',
('they_are_younger_than', 100),
),
'and',
('they_have_material', shared.object_material),
),
actions=('modify_node_collision', 'collide', False),
)
# We want pickup materials to always hit us even if we're currently
# not colliding with their node. (generally due to the above rule)
self.bomb_material.add_actions(
conditions=('they_have_material', shared.pickup_material),
actions=('modify_part_collision', 'use_node_collide', False),
)
self.bomb_material.add_actions(
actions=('modify_part_collision', 'friction', 0.3)
)
self.land_mine_no_explode_material = bs.Material()
self.land_mine_blast_material = bs.Material()
self.land_mine_blast_material.add_actions(
conditions=(
('we_are_older_than', 200),
'and',
('they_are_older_than', 200),
'and',
('eval_colliding',),
'and',
(
(
'they_dont_have_material',
self.land_mine_no_explode_material,
),
'and',
(
('they_have_material', shared.object_material),
'or',
('they_have_material', shared.player_material),
),
),
),
actions=('message', 'our_node', 'at_connect', ImpactMessage()),
)
self.impact_blast_material = bs.Material()
self.impact_blast_material.add_actions(
conditions=(
('we_are_older_than', 200),
'and',
('they_are_older_than', 200),
'and',
('eval_colliding',),
'and',
(
('they_have_material', shared.footing_material),
'or',
('they_have_material', shared.object_material),
),
),
actions=('message', 'our_node', 'at_connect', ImpactMessage()),
)
self.blast_material = bs.Material()
self.blast_material.add_actions(
conditions=('they_have_material', shared.object_material),
actions=(
('modify_part_collision', 'collide', True),
('modify_part_collision', 'physical', False),
('message', 'our_node', 'at_connect', ExplodeHitMessage()),
),
)
self.dink_sounds = (
bs.getsound('bombDrop01'),
bs.getsound('bombDrop02'),
)
self.sticky_impact_sound = bs.getsound('stickyImpact')
self.roll_sound = bs.getsound('bombRoll01')
# Collision sounds.
self.normal_sound_material.add_actions(
conditions=('they_have_material', shared.footing_material),
actions=(
('impact_sound', self.dink_sounds, 2, 0.8),
('roll_sound', self.roll_sound, 3, 6),
),
)
self.sticky_material.add_actions(
actions=(
('modify_part_collision', 'stiffness', 0.1),
('modify_part_collision', 'damping', 1.0),
)
)
self.sticky_material.add_actions(
conditions=(
('they_have_material', shared.player_material),
'or',
('they_have_material', shared.footing_material),
),
actions=('message', 'our_node', 'at_connect', SplatMessage()),
)
[docs]
class SplatMessage:
"""Tells an object to make a splat noise."""
[docs]
class ExplodeMessage:
"""Tells an object to explode."""
[docs]
class ImpactMessage:
"""Tell an object it touched something."""
[docs]
class ArmMessage:
"""Tell an object to become armed."""
[docs]
class WarnMessage:
"""Tell an object to issue a warning sound."""
[docs]
class ExplodeHitMessage:
"""Tell an object it was hit by an explosion."""
[docs]
class Blast(bs.Actor):
"""An explosion, as generated by a bomb or some other object.
category: Gameplay Classes
"""
def __init__(
self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0),
velocity: Sequence[float] = (0.0, 0.0, 0.0),
blast_radius: float = 2.0,
blast_type: str = 'normal',
source_player: bs.Player | None = None,
hit_type: str = 'explosion',
hit_subtype: str = 'normal',
):
"""Instantiate with given values."""
# bah; get off my lawn!
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
super().__init__()
shared = SharedObjects.get()
factory = BombFactory.get()
self.blast_type = blast_type
self._source_player = source_player
self.hit_type = hit_type
self.hit_subtype = hit_subtype
self.radius = blast_radius
# Set our position a bit lower so we throw more things upward.
rmats = (factory.blast_material, shared.attack_material)
self.node = bs.newnode(
'region',
delegate=self,
attrs={
'position': (position[0], position[1] - 0.1, position[2]),
'scale': (self.radius, self.radius, self.radius),
'type': 'sphere',
'materials': rmats,
},
)
bs.timer(0.05, self.node.delete)
# Throw in an explosion and flash.
evel = (velocity[0], max(-1.0, velocity[1]), velocity[2])
explosion = bs.newnode(
'explosion',
attrs={
'position': position,
'velocity': evel,
'radius': self.radius,
'big': (self.blast_type == 'tnt'),
},
)
if self.blast_type == 'ice':
explosion.color = (0, 0.05, 0.4)
bs.timer(1.0, explosion.delete)
if self.blast_type != 'ice':
bs.emitfx(
position=position,
velocity=velocity,
count=int(1.0 + random.random() * 4),
emit_type='tendrils',
tendril_type='thin_smoke',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 4),
emit_type='tendrils',
tendril_type='ice' if self.blast_type == 'ice' else 'smoke',
)
bs.emitfx(
position=position,
emit_type='distortion',
spread=1.0 if self.blast_type == 'tnt' else 2.0,
)
# And emit some shrapnel.
if self.blast_type == 'ice':
def emit() -> None:
bs.emitfx(
position=position,
velocity=velocity,
count=30,
spread=2.0,
scale=0.4,
chunk_type='ice',
emit_type='stickers',
)
# It looks better if we delay a bit.
bs.timer(0.05, emit)
elif self.blast_type == 'sticky':
def emit() -> None:
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 8),
spread=0.7,
chunk_type='slime',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 8),
scale=0.5,
spread=0.7,
chunk_type='slime',
)
bs.emitfx(
position=position,
velocity=velocity,
count=15,
scale=0.6,
chunk_type='slime',
emit_type='stickers',
)
bs.emitfx(
position=position,
velocity=velocity,
count=20,
scale=0.7,
chunk_type='spark',
emit_type='stickers',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(6.0 + random.random() * 12),
scale=0.8,
spread=1.5,
chunk_type='spark',
)
# It looks better if we delay a bit.
bs.timer(0.05, emit)
elif self.blast_type == 'impact':
def emit() -> None:
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 8),
scale=0.8,
chunk_type='metal',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 8),
scale=0.4,
chunk_type='metal',
)
bs.emitfx(
position=position,
velocity=velocity,
count=20,
scale=0.7,
chunk_type='spark',
emit_type='stickers',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(8.0 + random.random() * 15),
scale=0.8,
spread=1.5,
chunk_type='spark',
)
# It looks better if we delay a bit.
bs.timer(0.05, emit)
else: # Regular or land mine bomb shrapnel.
def emit() -> None:
if self.blast_type != 'tnt':
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 8),
chunk_type='rock',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(4.0 + random.random() * 8),
scale=0.5,
chunk_type='rock',
)
bs.emitfx(
position=position,
velocity=velocity,
count=30,
scale=1.0 if self.blast_type == 'tnt' else 0.7,
chunk_type='spark',
emit_type='stickers',
)
bs.emitfx(
position=position,
velocity=velocity,
count=int(18.0 + random.random() * 20),
scale=1.0 if self.blast_type == 'tnt' else 0.8,
spread=1.5,
chunk_type='spark',
)
# TNT throws splintery chunks.
if self.blast_type == 'tnt':
def emit_splinters() -> None:
bs.emitfx(
position=position,
velocity=velocity,
count=int(20.0 + random.random() * 25),
scale=0.8,
spread=1.0,
chunk_type='splinter',
)
bs.timer(0.01, emit_splinters)
# Every now and then do a sparky one.
if self.blast_type == 'tnt' or random.random() < 0.1:
def emit_extra_sparks() -> None:
bs.emitfx(
position=position,
velocity=velocity,
count=int(10.0 + random.random() * 20),
scale=0.8,
spread=1.5,
chunk_type='spark',
)
bs.timer(0.02, emit_extra_sparks)
# It looks better if we delay a bit.
bs.timer(0.05, emit)
lcolor = (0.6, 0.6, 1.0) if self.blast_type == 'ice' else (1, 0.3, 0.1)
light = bs.newnode(
'light',
attrs={
'position': position,
'volume_intensity_scale': 10.0,
'color': lcolor,
},
)
scl = random.uniform(0.6, 0.9)
scorch_radius = light_radius = self.radius
if self.blast_type == 'tnt':
light_radius *= 1.4
scorch_radius *= 1.15
scl *= 3.0
iscale = 1.6
bs.animate(
light,
'intensity',
{
0: 2.0 * iscale,
scl * 0.02: 0.1 * iscale,
scl * 0.025: 0.2 * iscale,
scl * 0.05: 17.0 * iscale,
scl * 0.06: 5.0 * iscale,
scl * 0.08: 4.0 * iscale,
scl * 0.2: 0.6 * iscale,
scl * 2.0: 0.00 * iscale,
scl * 3.0: 0.0,
},
)
bs.animate(
light,
'radius',
{
0: light_radius * 0.2,
scl * 0.05: light_radius * 0.55,
scl * 0.1: light_radius * 0.3,
scl * 0.3: light_radius * 0.15,
scl * 1.0: light_radius * 0.05,
},
)
bs.timer(scl * 3.0, light.delete)
# Make a scorch that fades over time.
scorch = bs.newnode(
'scorch',
attrs={
'position': position,
'size': scorch_radius * 0.5,
'big': (self.blast_type == 'tnt'),
},
)
if self.blast_type == 'ice':
scorch.color = (1, 1, 1.5)
bs.animate(scorch, 'presence', {3.000: 1, 13.000: 0})
bs.timer(13.0, scorch.delete)
if self.blast_type == 'ice':
factory.hiss_sound.play(position=light.position)
lpos = light.position
factory.random_explode_sound().play(position=lpos)
factory.debris_fall_sound.play(position=lpos)
bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0)
# TNT is more epic.
if self.blast_type == 'tnt':
factory.random_explode_sound().play(position=lpos)
def _extra_boom() -> None:
factory.random_explode_sound().play(position=lpos)
bs.timer(0.25, _extra_boom)
def _extra_debris_sound() -> None:
factory.debris_fall_sound.play(position=lpos)
factory.wood_debris_fall_sound.play(position=lpos)
bs.timer(0.4, _extra_debris_sound)
[docs]
@override
def handlemessage(self, msg: Any) -> Any:
assert not self.expired
if isinstance(msg, bs.DieMessage):
if self.node:
self.node.delete()
elif isinstance(msg, ExplodeHitMessage):
node = bs.getcollision().opposingnode
assert self.node
nodepos = self.node.position
mag = 2000.0
if self.blast_type == 'ice':
mag *= 0.5
elif self.blast_type == 'land_mine':
mag *= 2.5
elif self.blast_type == 'tnt':
mag *= 2.0
node.handlemessage(
bs.HitMessage(
pos=nodepos,
velocity=(0, 0, 0),
magnitude=mag,
hit_type=self.hit_type,
hit_subtype=self.hit_subtype,
radius=self.radius,
source_player=bs.existing(self._source_player),
)
)
if self.blast_type == 'ice':
BombFactory.get().freeze_sound.play(10, position=nodepos)
node.handlemessage(bs.FreezeMessage())
else:
return super().handlemessage(msg)
return None
[docs]
class Bomb(bs.Actor):
"""A standard bomb and its variants such as land-mines and tnt-boxes.
category: Gameplay Classes
"""
# Ew; should try to clean this up later.
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
def __init__(
self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0),
velocity: Sequence[float] = (0.0, 0.0, 0.0),
bomb_type: str = 'normal',
blast_radius: float = 2.0,
bomb_scale: float = 1.0,
source_player: bs.Player | None = None,
owner: bs.Node | None = None,
):
"""Create a new Bomb.
bomb_type can be 'ice','impact','land_mine','normal','sticky', or
'tnt'. Note that for impact or land_mine bombs you have to call arm()
before they will go off.
"""
super().__init__()
shared = SharedObjects.get()
factory = BombFactory.get()
if bomb_type not in (
'ice',
'impact',
'land_mine',
'normal',
'sticky',
'tnt',
):
raise ValueError('invalid bomb type: ' + bomb_type)
self.bomb_type = bomb_type
self._exploded = False
self.scale = bomb_scale
self.texture_sequence: bs.Node | None = None
if self.bomb_type == 'sticky':
self._last_sticky_sound_time = 0.0
self.blast_radius = blast_radius
if self.bomb_type == 'ice':
self.blast_radius *= 1.2
elif self.bomb_type == 'impact':
self.blast_radius *= 0.7
elif self.bomb_type == 'land_mine':
self.blast_radius *= 0.7
elif self.bomb_type == 'tnt':
self.blast_radius *= 1.45
self._explode_callbacks: list[Callable[[Bomb, Blast], Any]] = []
# The player this came from.
self._source_player = source_player
# By default our hit type/subtype is our own, but we pick up types of
# whoever sets us off so we know what caused a chain reaction.
# UPDATE (July 2020): not inheriting hit-types anymore; this causes
# weird effects such as land-mines inheriting 'punch' hit types and
# then not being able to destroy certain things they normally could,
# etc. Inheriting owner/source-node from things that set us off
# should be all we need I think...
self.hit_type = 'explosion'
self.hit_subtype = self.bomb_type
# The node this came from.
# FIXME: can we unify this and source_player?
self.owner = owner
# Adding footing-materials to things can screw up jumping and flying
# since players carrying those things and thus touching footing
# objects will think they're on solid ground.. perhaps we don't
# wanna add this even in the tnt case?
materials: tuple[bs.Material, ...]
if self.bomb_type == 'tnt':
materials = (
factory.bomb_material,
shared.footing_material,
shared.object_material,
)
else:
materials = (factory.bomb_material, shared.object_material)
if self.bomb_type == 'impact':
materials = materials + (factory.impact_blast_material,)
elif self.bomb_type == 'land_mine':
materials = materials + (factory.land_mine_no_explode_material,)
if self.bomb_type == 'sticky':
materials = materials + (factory.sticky_material,)
else:
materials = materials + (factory.normal_sound_material,)
if self.bomb_type == 'land_mine':
fuse_time = None
self.node = bs.newnode(
'prop',
delegate=self,
attrs={
'position': position,
'velocity': velocity,
'mesh': factory.land_mine_mesh,
'light_mesh': factory.land_mine_mesh,
'body': 'landMine',
'body_scale': self.scale,
'shadow_size': 0.44,
'color_texture': factory.land_mine_tex,
'reflection': 'powerup',
'reflection_scale': [1.0],
'materials': materials,
},
)
elif self.bomb_type == 'tnt':
fuse_time = None
self.node = bs.newnode(
'prop',
delegate=self,
attrs={
'position': position,
'velocity': velocity,
'mesh': factory.tnt_mesh,
'light_mesh': factory.tnt_mesh,
'body': 'crate',
'body_scale': self.scale,
'shadow_size': 0.5,
'color_texture': factory.tnt_tex,
'reflection': 'soft',
'reflection_scale': [0.23],
'materials': materials,
},
)
elif self.bomb_type == 'impact':
fuse_time = 20.0
self.node = bs.newnode(
'prop',
delegate=self,
attrs={
'position': position,
'velocity': velocity,
'body': 'sphere',
'body_scale': self.scale,
'mesh': factory.impact_bomb_mesh,
'shadow_size': 0.3,
'color_texture': factory.impact_tex,
'reflection': 'powerup',
'reflection_scale': [1.5],
'materials': materials,
},
)
self.arm_timer = bs.Timer(
0.2, bs.WeakCall(self.handlemessage, ArmMessage())
)
self.warn_timer = bs.Timer(
fuse_time - 1.7, bs.WeakCall(self.handlemessage, WarnMessage())
)
else:
fuse_time = 3.0
if self.bomb_type == 'sticky':
sticky = True
mesh = factory.sticky_bomb_mesh
rtype = 'sharper'
rscale = 1.8
else:
sticky = False
mesh = factory.bomb_mesh
rtype = 'sharper'
rscale = 1.8
if self.bomb_type == 'ice':
tex = factory.ice_tex
elif self.bomb_type == 'sticky':
tex = factory.sticky_tex
else:
tex = factory.regular_tex
self.node = bs.newnode(
'bomb',
delegate=self,
attrs={
'position': position,
'velocity': velocity,
'mesh': mesh,
'body_scale': self.scale,
'shadow_size': 0.3,
'color_texture': tex,
'sticky': sticky,
'owner': owner,
'reflection': rtype,
'reflection_scale': [rscale],
'materials': materials,
},
)
sound = bs.newnode(
'sound',
owner=self.node,
attrs={'sound': factory.fuse_sound, 'volume': 0.25},
)
self.node.connectattr('position', sound, 'position')
bs.animate(self.node, 'fuse_length', {0.0: 1.0, fuse_time: 0.0})
# Light the fuse!!!
if self.bomb_type not in ('land_mine', 'tnt'):
assert fuse_time is not None
bs.timer(
fuse_time, bs.WeakCall(self.handlemessage, ExplodeMessage())
)
bs.animate(
self.node,
'mesh_scale',
{0: 0, 0.2: 1.3 * self.scale, 0.26: self.scale},
)
[docs]
def get_source_player[PlayerT: bs.Player](
self, playertype: type[PlayerT]
) -> PlayerT | None:
"""Return the source-player if one exists and is the provided type."""
player: Any = self._source_player
return (
player
if isinstance(player, playertype) and player.exists()
else None
)
[docs]
@override
def on_expire(self) -> None:
super().on_expire()
# Release callbacks/refs so we don't wind up with dependency loops.
self._explode_callbacks = []
def _handle_die(self) -> None:
if self.node:
self.node.delete()
def _handle_oob(self) -> None:
self.handlemessage(bs.DieMessage())
def _handle_impact(self) -> None:
node = bs.getcollision().opposingnode
# If we're an impact bomb and we came from this node, don't explode.
# (otherwise we blow up on our own head when jumping).
# Alternately if we're hitting another impact-bomb from the same
# source, don't explode. (can cause accidental explosions if rapidly
# throwing/etc.)
node_delegate = node.getdelegate(object)
if node:
if self.bomb_type == 'impact' and (
node is self.owner
or (
isinstance(node_delegate, Bomb)
and node_delegate.bomb_type == 'impact'
and node_delegate.owner is self.owner
)
):
return
self.handlemessage(ExplodeMessage())
def _handle_dropped(self) -> None:
if self.bomb_type == 'land_mine':
self.arm_timer = bs.Timer(
1.25, bs.WeakCall(self.handlemessage, ArmMessage())
)
# Once we've thrown a sticky bomb we can stick to it.
elif self.bomb_type == 'sticky':
def _setsticky(node: bs.Node) -> None:
if node:
node.stick_to_owner = True
bs.timer(0.25, lambda: _setsticky(self.node))
def _handle_splat(self) -> None:
node = bs.getcollision().opposingnode
if (
node is not self.owner
and bs.time() - self._last_sticky_sound_time > 1.0
):
self._last_sticky_sound_time = bs.time()
assert self.node
BombFactory.get().sticky_impact_sound.play(
2.0,
position=self.node.position,
)
[docs]
def add_explode_callback(self, call: Callable[[Bomb, Blast], Any]) -> None:
"""Add a call to be run when the bomb has exploded.
The bomb and the new blast object are passed as arguments.
"""
self._explode_callbacks.append(call)
[docs]
def explode(self) -> None:
"""Blows up the bomb if it has not yet done so."""
if self._exploded:
return
self._exploded = True
if self.node:
blast = Blast(
position=self.node.position,
velocity=self.node.velocity,
blast_radius=self.blast_radius,
blast_type=self.bomb_type,
source_player=bs.existing(self._source_player),
hit_type=self.hit_type,
hit_subtype=self.hit_subtype,
).autoretain()
for callback in self._explode_callbacks:
callback(self, blast)
# We blew up so we need to go away.
# NOTE TO SELF: do we actually need this delay?
bs.timer(0.001, bs.WeakCall(self.handlemessage, bs.DieMessage()))
def _handle_warn(self) -> None:
if self.texture_sequence and self.node:
self.texture_sequence.rate = 30
BombFactory.get().warn_sound.play(0.5, position=self.node.position)
def _add_material(self, material: bs.Material) -> None:
if not self.node:
return
materials = self.node.materials
if material not in materials:
assert isinstance(materials, tuple)
self.node.materials = materials + (material,)
[docs]
def arm(self) -> None:
"""Arm the bomb (for land-mines and impact-bombs).
These types of bombs will not explode until they have been armed.
"""
if not self.node:
return
factory = BombFactory.get()
intex: Sequence[bs.Texture]
if self.bomb_type == 'land_mine':
intex = (factory.land_mine_lit_tex, factory.land_mine_tex)
self.texture_sequence = bs.newnode(
'texture_sequence',
owner=self.node,
attrs={'rate': 30, 'input_textures': intex},
)
bs.timer(0.5, self.texture_sequence.delete)
# We now make it explodable.
bs.timer(
0.25,
bs.WeakCall(
self._add_material, factory.land_mine_blast_material
),
)
elif self.bomb_type == 'impact':
intex = (
factory.impact_lit_tex,
factory.impact_tex,
factory.impact_tex,
)
self.texture_sequence = bs.newnode(
'texture_sequence',
owner=self.node,
attrs={'rate': 100, 'input_textures': intex},
)
bs.timer(
0.25,
bs.WeakCall(
self._add_material, factory.land_mine_blast_material
),
)
else:
raise RuntimeError(
'arm() should only be called on land-mines or impact bombs'
)
self.texture_sequence.connectattr(
'output_texture', self.node, 'color_texture'
)
factory.activate_sound.play(0.5, position=self.node.position)
def _handle_hit(self, msg: bs.HitMessage) -> None:
ispunched = msg.srcnode and msg.srcnode.getnodetype() == 'spaz'
# Normal bombs are triggered by non-punch impacts;
# impact-bombs by all impacts.
if not self._exploded and (
not ispunched or self.bomb_type in ['impact', 'land_mine']
):
# Also lets change the owner of the bomb to whoever is setting
# us off. (this way points for big chain reactions go to the
# person causing them).
source_player = msg.get_source_player(bs.Player)
if source_player is not None:
self._source_player = source_player
# Also inherit the hit type (if a landmine sets off by a bomb,
# the credit should go to the mine)
# the exception is TNT. TNT always gets credit.
# UPDATE (July 2020): not doing this anymore. Causes too much
# weird logic such as bombs acting like punches. Holler if
# anything is noticeably broken due to this.
# if self.bomb_type != 'tnt':
# self.hit_type = msg.hit_type
# self.hit_subtype = msg.hit_subtype
bs.timer(
0.1 + random.random() * 0.1,
bs.WeakCall(self.handlemessage, ExplodeMessage()),
)
assert self.node
self.node.handlemessage(
'impulse',
msg.pos[0],
msg.pos[1],
msg.pos[2],
msg.velocity[0],
msg.velocity[1],
msg.velocity[2],
msg.magnitude,
msg.velocity_magnitude,
msg.radius,
0,
msg.velocity[0],
msg.velocity[1],
msg.velocity[2],
)
if msg.srcnode:
pass
[docs]
@override
def handlemessage(self, msg: Any) -> Any:
if isinstance(msg, ExplodeMessage):
self.explode()
elif isinstance(msg, ImpactMessage):
self._handle_impact()
elif isinstance(msg, bs.PickedUpMessage):
# Change our source to whoever just picked us up *only* if it
# is None. This way we can get points for killing bots with their
# own bombs. Hmm would there be a downside to this?
if self._source_player is None:
self._source_player = msg.node.source_player
elif isinstance(msg, SplatMessage):
self._handle_splat()
elif isinstance(msg, bs.DroppedMessage):
self._handle_dropped()
elif isinstance(msg, bs.HitMessage):
self._handle_hit(msg)
elif isinstance(msg, bs.DieMessage):
self._handle_die()
elif isinstance(msg, bs.OutOfBoundsMessage):
self._handle_oob()
elif isinstance(msg, ArmMessage):
self.arm()
elif isinstance(msg, WarnMessage):
self._handle_warn()
else:
super().handlemessage(msg)
[docs]
class TNTSpawner:
"""Regenerates TNT at a given point in space every now and then.
category: Gameplay Classes
"""
def __init__(self, position: Sequence[float], respawn_time: float = 20.0):
"""Instantiate with given position and respawn_time (in seconds)."""
self._position = position
self._tnt: Bomb | None = None
self._respawn_time = random.uniform(0.8, 1.2) * respawn_time
self._wait_time = 0.0
self._update()
# Go with slightly more than 1 second to avoid timer stacking.
self._update_timer = bs.Timer(
1.1, bs.WeakCall(self._update), repeat=True
)
def _update(self) -> None:
tnt_alive = self._tnt is not None and self._tnt.node
if not tnt_alive:
# Respawn if its been long enough.. otherwise just increment our
# how-long-since-we-died value.
if self._tnt is None or self._wait_time >= self._respawn_time:
self._tnt = Bomb(position=self._position, bomb_type='tnt')
self._wait_time = 0.0
else:
self._wait_time += 1.1
# 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