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

A display for player or team scores during a game.

category: Gameplay Classes

Scoreboard(label: ba._language.Lstr | None = None, score_split: float = 0.7)
331    def __init__(self, label: ba.Lstr | None = None, score_split: float = 0.7):
332        """Instantiate a scoreboard.
333
334        Label can be something like 'points' and will
335        show up on boards if provided.
336        """
337        self._flat_tex = ba.gettexture('null')
338        self._entries: dict[int, _Entry] = {}
339        self._label = label
340        self.score_split = score_split
341
342        # For free-for-all we go simpler since we have one per player.
343        self._pos: Sequence[float]
344        if isinstance(ba.getsession(), ba.FreeForAllSession):
345            self._do_cover = False
346            self._spacing = 35.0
347            self._pos = (17.0, -65.0)
348            self._scale = 0.8
349            self._flash_length = 0.5
350        else:
351            self._do_cover = True
352            self._spacing = 50.0
353            self._pos = (20.0, -70.0)
354            self._scale = 1.0
355            self._flash_length = 1.0

Instantiate a scoreboard.

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

def set_team_value( self, team: ba._team.Team, score: float, max_score: float | None = None, countdown: bool = False, flash: bool = True, show_value: bool = True) -> None:
357    def set_team_value(self,
358                       team: ba.Team,
359                       score: float,
360                       max_score: float | None = None,
361                       countdown: bool = False,
362                       flash: bool = True,
363                       show_value: bool = True) -> None:
364        """Update the score-board display for the given ba.Team."""
365        if team.id not in self._entries:
366            self._add_team(team)
367
368            # Create a proxy in the team which will kill
369            # our entry when it dies (for convenience)
370            assert self._ENTRYSTORENAME not in team.customdata
371            team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team)
372
373        # Now set the entry.
374        self._entries[team.id].set_value(score=score,
375                                         max_score=max_score,
376                                         countdown=countdown,
377                                         flash=flash,
378                                         show_value=show_value)

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

def remove_team(self, team_id: int) -> None:
391    def remove_team(self, team_id: int) -> None:
392        """Remove the team with the given id from the scoreboard."""
393        del self._entries[team_id]
394        self._update_teams()

Remove the team with the given id from the scoreboard.