# 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 resources used by :class:`Flag`.
A single instance of this is shared between all flags and can be
retrieved via :meth:`FlagFactory.get()`.
"""
#: The material applied to all flags.
flagmaterial: bs.Material
#: The sound used when a flag hits the ground.
impact_sound: bs.Sound
#: The sound used when a flag skids along the ground.
skid_sound: bs.Sound
#: A material that prevents contact with most objects.
#: This gets applied to 'non-touchable' flags.
no_hit_material: bs.Material
flag_texture: bs.Texture
"""The texture for flags."""
_STORENAME = bs.storagename()
def __init__(self) -> None:
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 flag-factory 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."""
#: The flag that has been picked up.
flag: Flag
#: The bs.Node doing the picking up.
node: bs.Node
[docs]
@dataclass
class FlagDiedMessage:
"""A message saying a `Flag` has died."""
#: The flag that died.
flag: Flag
#: Whether the flag killed itself.
self_kill: bool = False
[docs]
@dataclass
class FlagDroppedMessage:
"""A message saying a `Flag` has been dropped."""
#: The flag that was dropped.
flag: Flag
#: The node that was holding the flag.
node: bs.Node
[docs]
class Flag(bs.Actor):
"""A flag; used in games such as capture-the-flag or king-of-the-hill.
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(how=bs.DeathType.LEFT_GAME)
)
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, (msg.how is bs.DeathType.LEFT_GAME)
)
)
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 from a 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')
# 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