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

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:
398    def set_team_value(
399        self,
400        team: bs.Team,
401        score: float,
402        max_score: float | None = None,
403        countdown: bool = False,
404        flash: bool = True,
405        show_value: bool = True,
406    ) -> None:
407        """Update the score-board display for the given bs.Team."""
408        if team.id not in self._entries:
409            self._add_team(team)
410
411            # Create a proxy in the team which will kill
412            # our entry when it dies (for convenience)
413            assert self._ENTRYSTORENAME not in team.customdata
414            team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team)
415
416        # Now set the entry.
417        self._entries[team.id].set_value(
418            score=score,
419            max_score=max_score,
420            countdown=countdown,
421            flash=flash,
422            show_value=show_value,
423        )

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

def remove_team(self, team_id: int) -> None:
438    def remove_team(self, team_id: int) -> None:
439        """Remove the team with the given id from the scoreboard."""
440        del self._entries[team_id]
441        self._update_teams()

Remove the team with the given id from the scoreboard.