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 def __init__( 18 self, 19 scoreboard: Scoreboard, 20 team: ba.Team, 21 do_cover: bool, 22 scale: float, 23 label: ba.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 = ba.gettexture('bar') 36 self._cover_tex = ba.gettexture('uiAtlas') 37 self._model = ba.getmodel('meterTransparent') 38 self._pos: Sequence[float] | None = None 39 self._flash_timer: ba.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 = ba.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 = ba.app.vr_mode 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 = ba.NodeActor( 60 ba.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 = ba.NodeActor( 75 ba.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 = ba.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 = ba.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 = ba.NodeActor( 106 ba.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 'model_transparent': self._model, 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 = ba.NodeActor( 124 ba.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 | ba.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, ba.Lstr): 153 154 # Hmmm; if the team-name is a non-translatable value lets go 155 # ahead and clip it otherwise we leave it as-is so 156 # translation can occur.. 157 if team_name_label.is_flat_value(): 158 val = team_name_label.evaluate() 159 if len(val) > 10: 160 team_name_label = ba.Lstr(value=val[:10] + '...') 161 else: 162 if len(team_name_label) > 10: 163 team_name_label = team_name_label[:10] + '...' 164 team_name_label = ba.Lstr(value=team_name_label) 165 166 flatness = (1.0 if vrmode else 0.5) if self._do_cover else 1.0 167 self._name_text = ba.NodeActor( 168 ba.newnode( 169 'text', 170 attrs={ 171 'h_attach': 'left', 172 'v_attach': 'top', 173 'h_align': 'left', 174 'v_align': 'center', 175 'vr_depth': 2, 176 'scale': self._scale * 0.9, 177 'shadow': 1.0 if vrmode else 0.5, 178 'flatness': flatness, 179 'maxwidth': 130 * scoreboard.score_split, 180 'text': team_name_label, 181 'color': clr + (1.0,), 182 }, 183 ) 184 ) 185 186 def flash(self, countdown: bool, extra_flash: bool) -> None: 187 """Flash momentarily.""" 188 self._flash_timer = ba.Timer( 189 0.1, ba.WeakCall(self._do_flash), repeat=True 190 ) 191 if countdown: 192 self._flash_counter = 10 193 else: 194 self._flash_counter = int(20.0 * self._flash_length) 195 if extra_flash: 196 self._flash_counter *= 4 197 self._set_flash_colors(True) 198 199 def set_position(self, position: Sequence[float]) -> None: 200 """Set the entry's position.""" 201 202 # Abort if we've been killed 203 if not self._backing.node: 204 return 205 206 self._pos = tuple(position) 207 self._backing.node.position = ( 208 position[0] + self._width / 2, 209 position[1] - self._height / 2, 210 ) 211 if self._do_cover: 212 assert self._cover.node 213 self._cover.node.position = ( 214 position[0] + self._width / 2, 215 position[1] - self._height / 2, 216 ) 217 self._bar_position.input0 = self._pos[0] + self._bar_width / 2 218 self._bar_position.input1 = self._pos[1] - self._bar_height / 2 219 assert self._score_text.node 220 self._score_text.node.position = ( 221 self._pos[0] + self._width - 7.0 * self._scale, 222 self._pos[1] - self._bar_height + 16.0 * self._scale, 223 ) 224 assert self._name_text.node 225 self._name_text.node.position = ( 226 self._pos[0] + 7.0 * self._scale, 227 self._pos[1] - self._bar_height + 16.0 * self._scale, 228 ) 229 230 def _set_flash_colors(self, flash: bool) -> None: 231 self._flash_colors = flash 232 233 def _safesetcolor(node: ba.Node | None, val: Any) -> None: 234 if node: 235 node.color = val 236 237 if flash: 238 scale = 2.0 239 _safesetcolor( 240 self._backing.node, 241 ( 242 self._backing_color[0] * scale, 243 self._backing_color[1] * scale, 244 self._backing_color[2] * scale, 245 ), 246 ) 247 _safesetcolor( 248 self._bar.node, 249 ( 250 self._barcolor[0] * scale, 251 self._barcolor[1] * scale, 252 self._barcolor[2] * scale, 253 ), 254 ) 255 if self._do_cover: 256 _safesetcolor( 257 self._cover.node, 258 ( 259 self._cover_color[0] * scale, 260 self._cover_color[1] * scale, 261 self._cover_color[2] * scale, 262 ), 263 ) 264 else: 265 _safesetcolor(self._backing.node, self._backing_color) 266 _safesetcolor(self._bar.node, self._barcolor) 267 if self._do_cover: 268 _safesetcolor(self._cover.node, self._cover_color) 269 270 def _do_flash(self) -> None: 271 assert self._flash_counter is not None 272 if self._flash_counter <= 0: 273 self._set_flash_colors(False) 274 else: 275 self._flash_counter -= 1 276 self._set_flash_colors(not self._flash_colors) 277 278 def set_value( 279 self, 280 score: float, 281 max_score: float | None = None, 282 countdown: bool = False, 283 flash: bool = True, 284 show_value: bool = True, 285 ) -> None: 286 """Set the value for the scoreboard entry.""" 287 288 # If we have no score yet, just set it.. otherwise compare 289 # and see if we should flash. 290 if self._score is None: 291 self._score = score 292 else: 293 if score > self._score or (countdown and score < self._score): 294 extra_flash = ( 295 max_score is not None 296 and score >= max_score 297 and not countdown 298 ) or (countdown and score == 0) 299 if flash: 300 self.flash(countdown, extra_flash) 301 self._score = score 302 303 if max_score is None: 304 self._bar_width = 0.0 305 else: 306 if countdown: 307 self._bar_width = max( 308 2.0 * self._scale, 309 self._width * (1.0 - (float(score) / max_score)), 310 ) 311 else: 312 self._bar_width = max( 313 2.0 * self._scale, 314 self._width * (min(1.0, float(score) / max_score)), 315 ) 316 317 cur_width = self._bar_scale.input0 318 ba.animate( 319 self._bar_scale, 'input0', {0.0: cur_width, 0.25: self._bar_width} 320 ) 321 self._bar_scale.input1 = self._bar_height 322 cur_x = self._bar_position.input0 323 assert self._pos is not None 324 ba.animate( 325 self._bar_position, 326 'input0', 327 {0.0: cur_x, 0.25: self._pos[0] + self._bar_width / 2}, 328 ) 329 self._bar_position.input1 = self._pos[1] - self._bar_height / 2 330 assert self._score_text.node 331 if show_value: 332 self._score_text.node.text = str(score) 333 else: 334 self._score_text.node.text = '' 335 336 337class _EntryProxy: 338 """Encapsulates adding/removing of a scoreboard Entry.""" 339 340 def __init__(self, scoreboard: Scoreboard, team: ba.Team): 341 self._scoreboard = weakref.ref(scoreboard) 342 343 # Have to store ID here instead of a weak-ref since the team will be 344 # dead when we die and need to remove it. 345 self._team_id = team.id 346 347 def __del__(self) -> None: 348 scoreboard = self._scoreboard() 349 350 # Remove our team from the scoreboard if its still around. 351 # (but deferred, in case we die in a sim step or something where 352 # its illegal to modify nodes) 353 if scoreboard is None: 354 return 355 356 try: 357 ba.pushcall(ba.Call(scoreboard.remove_team, self._team_id)) 358 except ba.ContextError: 359 # This happens if we fire after the activity expires. 360 # In that case we don't need to do anything. 361 pass 362 363 364class Scoreboard: 365 """A display for player or team scores during a game. 366 367 category: Gameplay Classes 368 """ 369 370 _ENTRYSTORENAME = ba.storagename('entry') 371 372 def __init__(self, label: ba.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 = ba.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(ba.getsession(), ba.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: ba.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 ba.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: ba.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
class
Scoreboard:
365class Scoreboard: 366 """A display for player or team scores during a game. 367 368 category: Gameplay Classes 369 """ 370 371 _ENTRYSTORENAME = ba.storagename('entry') 372 373 def __init__(self, label: ba.Lstr | None = None, score_split: float = 0.7): 374 """Instantiate a scoreboard. 375 376 Label can be something like 'points' and will 377 show up on boards if provided. 378 """ 379 self._flat_tex = ba.gettexture('null') 380 self._entries: dict[int, _Entry] = {} 381 self._label = label 382 self.score_split = score_split 383 384 # For free-for-all we go simpler since we have one per player. 385 self._pos: Sequence[float] 386 if isinstance(ba.getsession(), ba.FreeForAllSession): 387 self._do_cover = False 388 self._spacing = 35.0 389 self._pos = (17.0, -65.0) 390 self._scale = 0.8 391 self._flash_length = 0.5 392 else: 393 self._do_cover = True 394 self._spacing = 50.0 395 self._pos = (20.0, -70.0) 396 self._scale = 1.0 397 self._flash_length = 1.0 398 399 def set_team_value( 400 self, 401 team: ba.Team, 402 score: float, 403 max_score: float | None = None, 404 countdown: bool = False, 405 flash: bool = True, 406 show_value: bool = True, 407 ) -> None: 408 """Update the score-board display for the given ba.Team.""" 409 if team.id not in self._entries: 410 self._add_team(team) 411 412 # Create a proxy in the team which will kill 413 # our entry when it dies (for convenience) 414 assert self._ENTRYSTORENAME not in team.customdata 415 team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team) 416 417 # Now set the entry. 418 self._entries[team.id].set_value( 419 score=score, 420 max_score=max_score, 421 countdown=countdown, 422 flash=flash, 423 show_value=show_value, 424 ) 425 426 def _add_team(self, team: ba.Team) -> None: 427 if team.id in self._entries: 428 raise RuntimeError('Duplicate team add') 429 self._entries[team.id] = _Entry( 430 self, 431 team, 432 do_cover=self._do_cover, 433 scale=self._scale, 434 label=self._label, 435 flash_length=self._flash_length, 436 ) 437 self._update_teams() 438 439 def remove_team(self, team_id: int) -> None: 440 """Remove the team with the given id from the scoreboard.""" 441 del self._entries[team_id] 442 self._update_teams() 443 444 def _update_teams(self) -> None: 445 pos = list(self._pos) 446 for entry in list(self._entries.values()): 447 entry.set_position(pos) 448 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)
373 def __init__(self, label: ba.Lstr | None = None, score_split: float = 0.7): 374 """Instantiate a scoreboard. 375 376 Label can be something like 'points' and will 377 show up on boards if provided. 378 """ 379 self._flat_tex = ba.gettexture('null') 380 self._entries: dict[int, _Entry] = {} 381 self._label = label 382 self.score_split = score_split 383 384 # For free-for-all we go simpler since we have one per player. 385 self._pos: Sequence[float] 386 if isinstance(ba.getsession(), ba.FreeForAllSession): 387 self._do_cover = False 388 self._spacing = 35.0 389 self._pos = (17.0, -65.0) 390 self._scale = 0.8 391 self._flash_length = 0.5 392 else: 393 self._do_cover = True 394 self._spacing = 50.0 395 self._pos = (20.0, -70.0) 396 self._scale = 1.0 397 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:
399 def set_team_value( 400 self, 401 team: ba.Team, 402 score: float, 403 max_score: float | None = None, 404 countdown: bool = False, 405 flash: bool = True, 406 show_value: bool = True, 407 ) -> None: 408 """Update the score-board display for the given ba.Team.""" 409 if team.id not in self._entries: 410 self._add_team(team) 411 412 # Create a proxy in the team which will kill 413 # our entry when it dies (for convenience) 414 assert self._ENTRYSTORENAME not in team.customdata 415 team.customdata[self._ENTRYSTORENAME] = _EntryProxy(self, team) 416 417 # Now set the entry. 418 self._entries[team.id].set_value( 419 score=score, 420 max_score=max_score, 421 countdown=countdown, 422 flash=flash, 423 show_value=show_value, 424 )
Update the score-board display for the given ba.Team.