# Released under the MIT License. See LICENSE for details.
#
"""Implements a flag used for marking bases, capture-the-flag games, etc."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
import bascenev1 as bs
from bascenev1lib.gameutils import SharedObjects
if TYPE_CHECKING:
from typing import Any, Sequence
[docs]
class FlagFactory:
"""Wraps up media and other resources used by `Flag`s.
Category: **Gameplay Classes**
A single instance of this is shared between all flags
and can be retrieved via FlagFactory.get().
"""
flagmaterial: bs.Material
"""The bs.Material applied to all `Flag`s."""
impact_sound: bs.Sound
"""The bs.Sound used when a `Flag` hits the ground."""
skid_sound: bs.Sound
"""The bs.Sound used when a `Flag` skids along the ground."""
no_hit_material: bs.Material
"""A bs.Material that prevents contact with most objects;
applied to 'non-touchable' flags."""
flag_texture: bs.Texture
"""The bs.Texture for flags."""
_STORENAME = bs.storagename()
def __init__(self) -> None:
"""Instantiate a `FlagFactory`.
You shouldn't need to do this; call FlagFactory.get() to
get a shared instance.
"""
shared = SharedObjects.get()
self.flagmaterial = bs.Material()
self.flagmaterial.add_actions(
conditions=(
('we_are_younger_than', 100),
'and',
('they_have_material', shared.object_material),
),
actions=('modify_node_collision', 'collide', False),
)
self.flagmaterial.add_actions(
conditions=(
'they_have_material',
shared.footing_material,
),
actions=(
('message', 'our_node', 'at_connect', 'footing', 1),
('message', 'our_node', 'at_disconnect', 'footing', -1),
),
)
self.impact_sound = bs.getsound('metalHit')
self.skid_sound = bs.getsound('metalSkid')
self.flagmaterial.add_actions(
conditions=(
'they_have_material',
shared.footing_material,
),
actions=(
('impact_sound', self.impact_sound, 2, 5),
('skid_sound', self.skid_sound, 2, 5),
),
)
self.no_hit_material = bs.Material()
self.no_hit_material.add_actions(
conditions=(
('they_have_material', shared.pickup_material),
'or',
('they_have_material', shared.attack_material),
),
actions=('modify_part_collision', 'collide', False),
)
# We also don't want anything moving it.
self.no_hit_material.add_actions(
conditions=(
('they_have_material', shared.object_material),
'or',
('they_dont_have_material', shared.footing_material),
),
actions=(
('modify_part_collision', 'collide', False),
('modify_part_collision', 'physical', False),
),
)
self.flag_texture = bs.gettexture('flagColor')
[docs]
@classmethod
def get(cls) -> FlagFactory:
"""Get/create a shared `FlagFactory` instance."""
activity = bs.getactivity()
factory = activity.customdata.get(cls._STORENAME)
if factory is None:
factory = FlagFactory()
activity.customdata[cls._STORENAME] = factory
assert isinstance(factory, FlagFactory)
return factory
[docs]
@dataclass
class FlagPickedUpMessage:
"""A message saying a `Flag` has been picked up.
Category: **Message Classes**
"""
flag: Flag
"""The `Flag` that has been picked up."""
node: bs.Node
"""The bs.Node doing the picking up."""
[docs]
@dataclass
class FlagDiedMessage:
"""A message saying a `Flag` has died.
Category: **Message Classes**
"""
flag: Flag
"""The `Flag` that died."""
[docs]
@dataclass
class FlagDroppedMessage:
"""A message saying a `Flag` has been dropped.
Category: **Message Classes**
"""
flag: Flag
"""The `Flag` that was dropped."""
node: bs.Node
"""The bs.Node that was holding it."""
[docs]
class Flag(bs.Actor):
"""A flag; used in games such as capture-the-flag or king-of-the-hill.
Category: **Gameplay Classes**
Can be stationary or carry-able by players.
"""
def __init__(
self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0),
color: Sequence[float] = (1.0, 1.0, 1.0),
materials: Sequence[bs.Material] | None = None,
touchable: bool = True,
dropped_timeout: int | None = None,
):
"""Instantiate a flag.
If 'touchable' is False, the flag will only touch terrain;
useful for things like king-of-the-hill where players should
not be moving the flag around.
'materials can be a list of extra `bs.Material`s to apply to the flag.
If 'dropped_timeout' is provided (in seconds), the flag will die
after remaining untouched for that long once it has been moved
from its initial position.
"""
super().__init__()
self._initial_position: Sequence[float] | None = None
self._has_moved = False
shared = SharedObjects.get()
factory = FlagFactory.get()
if materials is None:
materials = []
elif not isinstance(materials, list):
# In case they passed a tuple or whatnot.
materials = list(materials)
if not touchable:
materials = [factory.no_hit_material] + materials
finalmaterials = [
shared.object_material,
factory.flagmaterial,
] + materials
self.node = bs.newnode(
'flag',
attrs={
'position': (position[0], position[1] + 0.75, position[2]),
'color_texture': factory.flag_texture,
'color': color,
'materials': finalmaterials,
},
delegate=self,
)
if dropped_timeout is not None:
dropped_timeout = int(dropped_timeout)
self._dropped_timeout = dropped_timeout
self._counter: bs.Node | None
if self._dropped_timeout is not None:
self._count = self._dropped_timeout
self._tick_timer = bs.Timer(
1.0, call=bs.WeakCall(self._tick), repeat=True
)
self._counter = bs.newnode(
'text',
owner=self.node,
attrs={
'in_world': True,
'color': (1, 1, 1, 0.7),
'scale': 0.015,
'shadow': 0.5,
'flatness': 1.0,
'h_align': 'center',
},
)
else:
self._counter = None
self._held_count = 0
self._score_text: bs.Node | None = None
self._score_text_hide_timer: bs.Timer | None = None
def _tick(self) -> None:
if self.node:
# Grab our initial position after one tick (in case we fall).
if self._initial_position is None:
self._initial_position = self.node.position
# Keep track of when we first move; we don't count down
# until then.
if not self._has_moved:
nodepos = self.node.position
if (
max(
abs(nodepos[i] - self._initial_position[i])
for i in list(range(3))
)
> 1.0
):
self._has_moved = True
if self._held_count > 0 or not self._has_moved:
assert self._dropped_timeout is not None
assert self._counter
self._count = self._dropped_timeout
self._counter.text = ''
else:
self._count -= 1
if self._count <= 10:
nodepos = self.node.position
assert self._counter
self._counter.position = (
nodepos[0],
nodepos[1] + 1.3,
nodepos[2],
)
self._counter.text = str(self._count)
if self._count < 1:
self.handlemessage(bs.DieMessage())
else:
assert self._counter
self._counter.text = ''
def _hide_score_text(self) -> None:
assert self._score_text is not None
assert isinstance(self._score_text.scale, float)
bs.animate(
self._score_text, 'scale', {0: self._score_text.scale, 0.2: 0}
)
[docs]
def set_score_text(self, text: str) -> None:
"""Show a message over the flag; handy for scores."""
if not self.node:
return
if not self._score_text:
start_scale = 0.0
math = bs.newnode(
'math',
owner=self.node,
attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
)
self.node.connectattr('position', math, 'input2')
self._score_text = bs.newnode(
'text',
owner=self.node,
attrs={
'text': text,
'in_world': True,
'scale': 0.02,
'shadow': 0.5,
'flatness': 1.0,
'h_align': 'center',
},
)
math.connectattr('output', self._score_text, 'position')
else:
assert isinstance(self._score_text.scale, float)
start_scale = self._score_text.scale
self._score_text.text = text
self._score_text.color = bs.safecolor(self.node.color)
bs.animate(self._score_text, 'scale', {0: start_scale, 0.2: 0.02})
self._score_text_hide_timer = bs.Timer(
1.0, bs.WeakCall(self._hide_score_text)
)
[docs]
@override
def handlemessage(self, msg: Any) -> Any:
assert not self.expired
if isinstance(msg, bs.DieMessage):
if self.node:
self.node.delete()
if not msg.immediate:
self.activity.handlemessage(FlagDiedMessage(self))
elif isinstance(msg, bs.HitMessage):
assert self.node
assert msg.force_direction is not None
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.force_direction[0],
msg.force_direction[1],
msg.force_direction[2],
)
elif isinstance(msg, bs.PickedUpMessage):
self._held_count += 1
if self._held_count == 1 and self._counter is not None:
self._counter.text = ''
self.activity.handlemessage(FlagPickedUpMessage(self, msg.node))
elif isinstance(msg, bs.DroppedMessage):
self._held_count -= 1
if self._held_count < 0:
print('Flag held count < 0.')
self._held_count = 0
self.activity.handlemessage(FlagDroppedMessage(self, msg.node))
else:
super().handlemessage(msg)
[docs]
@staticmethod
def project_stand(pos: Sequence[float]) -> None:
"""Project a flag-stand onto the ground at the given position.
Useful for games such as capture-the-flag to show where a
movable flag originated from.
"""
assert len(pos) == 3
bs.emitfx(position=pos, emit_type='flag_stand')