bascenev1lib.actor.scoreboard

Defines ScoreBoard Actor and related functionality.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Defines ScoreBoard Actor and related functionality."""
  4
  5from __future__ import annotations
  6
  7import weakref
  8from typing import TYPE_CHECKING
  9
 10import bascenev1 as bs
 11
 12if TYPE_CHECKING:
 13    from typing import Any, Sequence
 14
 15
 16class _Entry:
 17    def __init__(
 18        self,
 19        scoreboard: Scoreboard,
 20        team: bs.Team,
 21        do_cover: bool,
 22        scale: float,
 23        label: bs.Lstr | None,
 24        flash_length: float,
 25        width: float | None = None,
 26        height: float | None = None,
 27    ):
 28        # pylint: disable=too-many-locals
 29        # pylint: disable=too-many-statements
 30        # pylint: disable=too-many-positional-arguments
 31        self._scoreboard = weakref.ref(scoreboard)
 32        self._do_cover = do_cover
 33        self._scale = scale
 34        self._flash_length = flash_length
 35        self._width = (140.0 if width is None else width) * self._scale
 36        self._height = (32.0 if height is None else height) * self._scale
 37        self._bar_width = 2.0 * self._scale
 38        self._bar_height = 32.0 * self._scale
 39        self._bar_tex = self._backing_tex = bs.gettexture('bar')
 40        self._cover_tex = bs.gettexture('uiAtlas')
 41        self._mesh = bs.getmesh('meterTransparent')
 42        self._pos: Sequence[float] | None = None
 43        self._flash_timer: bs.Timer | None = None
 44        self._flash_counter: int | None = None
 45        self._flash_colors: bool | None = None
 46        self._score: float | None = None
 47
 48        safe_team_color = bs.safecolor(team.color, target_intensity=1.0)
 49
 50        # FIXME: Should not do things conditionally for vr-mode, as there may
 51        #  be non-vr clients connected which will also get these value.
 52        vrmode = bs.app.env.vr
 53
 54        if self._do_cover:
 55            if vrmode:
 56                self._backing_color = [0.1 + c * 0.1 for c in safe_team_color]
 57            else:
 58                self._backing_color = [0.05 + c * 0.17 for c in safe_team_color]
 59        else:
 60            self._backing_color = [0.05 + c * 0.1 for c in safe_team_color]
 61
 62        opacity = (0.8 if vrmode else 0.8) if self._do_cover else 0.5
 63        self._backing = bs.NodeActor(
 64            bs.newnode(
 65                'image',
 66                attrs={
 67                    'scale': (self._width, self._height),
 68                    'opacity': opacity,
 69                    'color': self._backing_color,
 70                    'vr_depth': -3,
 71                    'attach': 'topLeft',
 72                    'texture': self._backing_tex,
 73                },
 74            )
 75        )
 76
 77        self._barcolor = safe_team_color
 78        self._bar = bs.NodeActor(
 79            bs.newnode(
 80                'image',
 81                attrs={
 82                    'opacity': 0.7,
 83                    'color': self._barcolor,
 84                    'attach': 'topLeft',
 85                    'texture': self._bar_tex,
 86                },
 87            )
 88        )
 89
 90        self._bar_scale = bs.newnode(
 91            'combine',
 92            owner=self._bar.node,
 93            attrs={
 94                'size': 2,
 95                'input0': self._bar_width,
 96                'input1': self._bar_height,
 97            },
 98        )
 99        assert self._bar.node
100        self._bar_scale.connectattr('output', self._bar.node, 'scale')
101        self._bar_position = bs.newnode(
102            'combine',
103            owner=self._bar.node,
104            attrs={'size': 2, 'input0': 0, 'input1': 0},
105        )
106        self._bar_position.connectattr('output', self._bar.node, 'position')
107        self._cover_color = safe_team_color
108        if self._do_cover:
109            self._cover = bs.NodeActor(
110                bs.newnode(
111                    'image',
112                    attrs={
113                        'scale': (self._width * 1.15, self._height * 1.6),
114                        'opacity': 1.0,
115                        'color': self._cover_color,
116                        'vr_depth': 2,
117                        'attach': 'topLeft',
118                        'texture': self._cover_tex,
119                        'mesh_transparent': self._mesh,
120                    },
121                )
122            )
123
124        clr = safe_team_color
125        maxwidth = 130.0 * (1.0 - scoreboard.score_split)
126        flatness = (1.0 if vrmode else 0.5) if self._do_cover else 1.0
127        self._score_text = bs.NodeActor(
128            bs.newnode(
129                'text',
130                attrs={
131                    'h_attach': 'left',
132                    'v_attach': 'top',
133                    'h_align': 'right',
134                    'v_align': 'center',
135                    'maxwidth': maxwidth,
136                    'vr_depth': 2,
137                    'scale': self._scale * 0.9,
138                    'text': '',
139                    'shadow': 1.0 if vrmode else 0.5,
140                    'flatness': flatness,
141                    'color': clr,
142                },
143            )
144        )
145
146        clr = safe_team_color
147
148        team_name_label: str | bs.Lstr
149        if label is not None:
150            team_name_label = label
151        else:
152            team_name_label = team.name
153
154            # We do our own clipping here; should probably try to tap into some
155            # existing functionality.
156            if isinstance(team_name_label, bs.Lstr):
157                # Hmmm; if the team-name is a non-translatable value lets go
158                # ahead and clip it otherwise we leave it as-is so
159                # translation can occur..
160                if team_name_label.is_flat_value():
161                    val = team_name_label.evaluate()
162                    if len(val) > 10:
163                        team_name_label = bs.Lstr(value=val[:10] + '...')
164            else:
165                if len(team_name_label) > 10:
166                    team_name_label = team_name_label[:10] + '...'
167                team_name_label = bs.Lstr(value=team_name_label)
168
169        flatness = (1.0 if vrmode else 0.5) if self._do_cover else 1.0
170        self._name_text = bs.NodeActor(
171            bs.newnode(
172                'text',
173                attrs={
174                    'h_attach': 'left',
175                    'v_attach': 'top',
176                    'h_align': 'left',
177                    'v_align': 'center',
178                    'vr_depth': 2,
179                    'scale': self._scale * 0.9,
180                    'shadow': 1.0 if vrmode else 0.5,
181                    'flatness': flatness,
182                    'maxwidth': 130 * scoreboard.score_split,
183                    'text': team_name_label,
184                    'color': clr + (1.0,),
185                },
186            )
187        )
188
189    def flash(self, countdown: bool, extra_flash: bool) -> None:
190        """Flash momentarily."""
191        self._flash_timer = bs.Timer(
192            0.1, bs.WeakCall(self._do_flash), repeat=True
193        )
194        if countdown:
195            self._flash_counter = 10
196        else:
197            self._flash_counter = int(20.0 * self._flash_length)
198        if extra_flash:
199            self._flash_counter *= 4
200        self._set_flash_colors(True)
201
202    def set_position(self, position: Sequence[float]) -> None:
203        """Set the entry's position."""
204
205        # Abort if we've been killed
206        if not self._backing.node:
207            return
208
209        self._pos = tuple(position)
210        self._backing.node.position = (
211            position[0] + self._width / 2,
212            position[1] - self._height / 2,
213        )
214        if self._do_cover:
215            assert self._cover.node
216            self._cover.node.position = (
217                position[0] + self._width / 2,
218                position[1] - self._height / 2,
219            )
220        self._bar_position.input0 = self._pos[0] + self._bar_width / 2
221        self._bar_position.input1 = self._pos[1] - self._bar_height / 2
222        assert self._score_text.node
223        self._score_text.node.position = (
224            self._pos[0] + self._width - 7.0 * self._scale,
225            self._pos[1] - self._bar_height + 16.0 * self._scale,
226        )
227        assert self._name_text.node
228        self._name_text.node.position = (
229            self._pos[0] + 7.0 * self._scale,
230            self._pos[1] - self._bar_height + 16.0 * self._scale,
231        )
232
233    def _set_flash_colors(self, flash: bool) -> None:
234        self._flash_colors = flash
235
236        def _safesetcolor(node: bs.Node | None, val: Any) -> None:
237            if node:
238                node.color = val
239
240        if flash:
241            scale = 2.0
242            _safesetcolor(
243                self._backing.node,
244                (
245                    self._backing_color[0] * scale,
246                    self._backing_color[1] * scale,
247                    self._backing_color[2] * scale,
248                ),
249            )
250            _safesetcolor(
251                self._bar.node,
252                (
253                    self._barcolor[0] * scale,
254                    self._barcolor[1] * scale,
255                    self._barcolor[2] * scale,
256                ),
257            )
258            if self._do_cover:
259                _safesetcolor(
260                    self._cover.node,
261                    (
262                        self._cover_color[0] * scale,
263                        self._cover_color[1] * scale,
264                        self._cover_color[2] * scale,
265                    ),
266                )
267        else:
268            _safesetcolor(self._backing.node, self._backing_color)
269            _safesetcolor(self._bar.node, self._barcolor)
270            if self._do_cover:
271                _safesetcolor(self._cover.node, self._cover_color)
272
273    def _do_flash(self) -> None:
274        assert self._flash_counter is not None
275        if self._flash_counter <= 0:
276            self._set_flash_colors(False)
277        else:
278            self._flash_counter -= 1
279            self._set_flash_colors(not self._flash_colors)
280
281    def set_value(
282        self,
283        score: float,
284        *,
285        max_score: float | None = None,
286        countdown: bool = False,
287        flash: bool = True,
288        show_value: bool = True,
289    ) -> None:
290        """Set the value for the scoreboard entry."""
291
292        # If we have no score yet, just set it.. otherwise compare
293        # and see if we should flash.
294        if self._score is None:
295            self._score = score
296        else:
297            if score > self._score or (countdown and score < self._score):
298                extra_flash = (
299                    max_score is not None
300                    and score >= max_score
301                    and not countdown
302                ) or (countdown and score == 0)
303                if flash:
304                    self.flash(countdown, extra_flash)
305            self._score = score
306
307        if max_score is None:
308            self._bar_width = 0.0
309        else:
310            if countdown:
311                self._bar_width = max(
312                    2.0 * self._scale,
313                    self._width * (1.0 - (float(score) / max_score)),
314                )
315            else:
316                self._bar_width = max(
317                    2.0 * self._scale,
318                    self._width * (min(1.0, float(score) / max_score)),
319                )
320
321        cur_width = self._bar_scale.input0
322        bs.animate(
323            self._bar_scale, 'input0', {0.0: cur_width, 0.25: self._bar_width}
324        )
325        self._bar_scale.input1 = self._bar_height
326        cur_x = self._bar_position.input0
327        assert self._pos is not None
328        bs.animate(
329            self._bar_position,
330            'input0',
331            {0.0: cur_x, 0.25: self._pos[0] + self._bar_width / 2},
332        )
333        self._bar_position.input1 = self._pos[1] - self._bar_height / 2
334        assert self._score_text.node
335        if show_value:
336            self._score_text.node.text = str(score)
337        else:
338            self._score_text.node.text = ''
339
340
341class _EntryProxy:
342    """Encapsulates adding/removing of a scoreboard Entry."""
343
344    def __init__(self, scoreboard: Scoreboard, team: bs.Team):
345        self._scoreboard = weakref.ref(scoreboard)
346
347        # Have to store ID here instead of a weak-ref since the team will be
348        # dead when we die and need to remove it.
349        self._team_id = team.id
350
351    def __del__(self) -> None:
352        scoreboard = self._scoreboard()
353
354        # Remove our team from the scoreboard if its still around.
355        # (but deferred, in case we die in a sim step or something where
356        # its illegal to modify nodes)
357        if scoreboard is None:
358            return
359
360        try:
361            bs.pushcall(bs.Call(scoreboard.remove_team, self._team_id))
362        except bs.ContextError:
363            # This happens if we fire after the activity expires.
364            # In that case we don't need to do anything.
365            pass
366
367
368class Scoreboard:
369    """A display for player or team scores during a game.
370
371    category: Gameplay Classes
372    """
373
374    _ENTRYSTORENAME = bs.storagename('entry')
375
376    def __init__(
377        self,
378        label: bs.Lstr | None = None,
379        score_split: float = 0.7,
380        pos: Sequence[float] | None = None,
381        width: float | None = None,
382        height: float | None = None,
383    ):
384        """Instantiate a scoreboard.
385
386        Label can be something like 'points' and will
387        show up on boards if provided.
388        """
389        # pylint: disable=too-many-positional-arguments
390        self._flat_tex = bs.gettexture('null')
391        self._entries: dict[int, _Entry] = {}
392        self._label = label
393        self.score_split = score_split
394        self._width = width
395        self._height = height
396
397        # For free-for-all we go simpler since we have one per player.
398        self._pos: Sequence[float]
399        if isinstance(bs.getsession(), bs.FreeForAllSession):
400            self._do_cover = False
401            self._spacing = 35.0
402            self._pos = (17.0, -65.0)
403            self._scale = 0.8
404            self._flash_length = 0.5
405        else:
406            self._do_cover = True
407            self._spacing = 50.0
408            self._pos = (20.0, -70.0)
409            self._scale = 1.0
410            self._flash_length = 1.0
411        self._pos = self._pos if pos is None else pos
412
413    def set_team_value(
414        self,
415        team: bs.Team,
416        score: float,
417        max_score: float | None = None,
418        *,
419        countdown: bool = False,
420        flash: bool = True,
421        show_value: bool = True,
422    ) -> None:
423        """Update the score-board display for the given bs.Team."""
424        if team.id not in self._entries:
425            self._add_team(team)
426
427            # Create a proxy in the team which will kill
428            # our entry when it dies (for convenience)
429            assert self._ENTRYSTORENAME not in team.customdata
430            team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team)
431
432        # Now set the entry.
433        self._entries[team.id].set_value(
434            score=score,
435            max_score=max_score,
436            countdown=countdown,
437            flash=flash,
438            show_value=show_value,
439        )
440
441    def _add_team(self, team: bs.Team) -> None:
442        if team.id in self._entries:
443            raise RuntimeError('Duplicate team add')
444        self._entries[team.id] = _Entry(
445            self,
446            team,
447            do_cover=self._do_cover,
448            scale=self._scale,
449            label=self._label,
450            width=self._width,
451            height=self._height,
452            flash_length=self._flash_length,
453        )
454        self._update_teams()
455
456    def remove_team(self, team_id: int) -> None:
457        """Remove the team with the given id from the scoreboard."""
458        del self._entries[team_id]
459        self._update_teams()
460
461    def _update_teams(self) -> None:
462        pos = list(self._pos)
463        for entry in list(self._entries.values()):
464            entry.set_position(pos)
465            pos[1] -= self._spacing * self._scale
class Scoreboard:
369class Scoreboard:
370    """A display for player or team scores during a game.
371
372    category: Gameplay Classes
373    """
374
375    _ENTRYSTORENAME = bs.storagename('entry')
376
377    def __init__(
378        self,
379        label: bs.Lstr | None = None,
380        score_split: float = 0.7,
381        pos: Sequence[float] | None = None,
382        width: float | None = None,
383        height: float | None = None,
384    ):
385        """Instantiate a scoreboard.
386
387        Label can be something like 'points' and will
388        show up on boards if provided.
389        """
390        # pylint: disable=too-many-positional-arguments
391        self._flat_tex = bs.gettexture('null')
392        self._entries: dict[int, _Entry] = {}
393        self._label = label
394        self.score_split = score_split
395        self._width = width
396        self._height = height
397
398        # For free-for-all we go simpler since we have one per player.
399        self._pos: Sequence[float]
400        if isinstance(bs.getsession(), bs.FreeForAllSession):
401            self._do_cover = False
402            self._spacing = 35.0
403            self._pos = (17.0, -65.0)
404            self._scale = 0.8
405            self._flash_length = 0.5
406        else:
407            self._do_cover = True
408            self._spacing = 50.0
409            self._pos = (20.0, -70.0)
410            self._scale = 1.0
411            self._flash_length = 1.0
412        self._pos = self._pos if pos is None else pos
413
414    def set_team_value(
415        self,
416        team: bs.Team,
417        score: float,
418        max_score: float | None = None,
419        *,
420        countdown: bool = False,
421        flash: bool = True,
422        show_value: bool = True,
423    ) -> None:
424        """Update the score-board display for the given bs.Team."""
425        if team.id not in self._entries:
426            self._add_team(team)
427
428            # Create a proxy in the team which will kill
429            # our entry when it dies (for convenience)
430            assert self._ENTRYSTORENAME not in team.customdata
431            team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team)
432
433        # Now set the entry.
434        self._entries[team.id].set_value(
435            score=score,
436            max_score=max_score,
437            countdown=countdown,
438            flash=flash,
439            show_value=show_value,
440        )
441
442    def _add_team(self, team: bs.Team) -> None:
443        if team.id in self._entries:
444            raise RuntimeError('Duplicate team add')
445        self._entries[team.id] = _Entry(
446            self,
447            team,
448            do_cover=self._do_cover,
449            scale=self._scale,
450            label=self._label,
451            width=self._width,
452            height=self._height,
453            flash_length=self._flash_length,
454        )
455        self._update_teams()
456
457    def remove_team(self, team_id: int) -> None:
458        """Remove the team with the given id from the scoreboard."""
459        del self._entries[team_id]
460        self._update_teams()
461
462    def _update_teams(self) -> None:
463        pos = list(self._pos)
464        for entry in list(self._entries.values()):
465            entry.set_position(pos)
466            pos[1] -= self._spacing * self._scale

A display for player or team scores during a game.

category: Gameplay Classes

Scoreboard( label: babase.Lstr | None = None, score_split: float = 0.7, pos: Optional[Sequence[float]] = None, width: float | None = None, height: float | None = None)
377    def __init__(
378        self,
379        label: bs.Lstr | None = None,
380        score_split: float = 0.7,
381        pos: Sequence[float] | None = None,
382        width: float | None = None,
383        height: float | None = None,
384    ):
385        """Instantiate a scoreboard.
386
387        Label can be something like 'points' and will
388        show up on boards if provided.
389        """
390        # pylint: disable=too-many-positional-arguments
391        self._flat_tex = bs.gettexture('null')
392        self._entries: dict[int, _Entry] = {}
393        self._label = label
394        self.score_split = score_split
395        self._width = width
396        self._height = height
397
398        # For free-for-all we go simpler since we have one per player.
399        self._pos: Sequence[float]
400        if isinstance(bs.getsession(), bs.FreeForAllSession):
401            self._do_cover = False
402            self._spacing = 35.0
403            self._pos = (17.0, -65.0)
404            self._scale = 0.8
405            self._flash_length = 0.5
406        else:
407            self._do_cover = True
408            self._spacing = 50.0
409            self._pos = (20.0, -70.0)
410            self._scale = 1.0
411            self._flash_length = 1.0
412        self._pos = self._pos if pos is None else pos

Instantiate a scoreboard.

Label can be something like 'points' and will show up on boards if provided.

score_split
def set_team_value( self, team: bascenev1.Team, score: float, max_score: float | None = None, *, countdown: bool = False, flash: bool = True, show_value: bool = True) -> None:
414    def set_team_value(
415        self,
416        team: bs.Team,
417        score: float,
418        max_score: float | None = None,
419        *,
420        countdown: bool = False,
421        flash: bool = True,
422        show_value: bool = True,
423    ) -> None:
424        """Update the score-board display for the given bs.Team."""
425        if team.id not in self._entries:
426            self._add_team(team)
427
428            # Create a proxy in the team which will kill
429            # our entry when it dies (for convenience)
430            assert self._ENTRYSTORENAME not in team.customdata
431            team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team)
432
433        # Now set the entry.
434        self._entries[team.id].set_value(
435            score=score,
436            max_score=max_score,
437            countdown=countdown,
438            flash=flash,
439            show_value=show_value,
440        )

Update the score-board display for the given bs.Team.

def remove_team(self, team_id: int) -> None:
457    def remove_team(self, team_id: int) -> None:
458        """Remove the team with the given id from the scoreboard."""
459        del self._entries[team_id]
460        self._update_teams()

Remove the team with the given id from the scoreboard.