bastd.activity.coopscore

Provides a score screen for coop games.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Provides a score screen for coop games."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import random
   9from typing import TYPE_CHECKING
  10
  11import _ba
  12import ba
  13from bastd.actor.text import Text
  14from bastd.actor.zoomtext import ZoomText
  15
  16if TYPE_CHECKING:
  17    from typing import Any, Sequence
  18    from bastd.ui.store.button import StoreButton
  19    from bastd.ui.league.rankbutton import LeagueRankButton
  20
  21
  22class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
  23    """Score screen showing the results of a cooperative game."""
  24
  25    def __init__(self, settings: dict):
  26        # pylint: disable=too-many-statements
  27        super().__init__(settings)
  28
  29        # Keep prev activity alive while we fade in
  30        self.transition_time = 0.5
  31        self.inherits_tint = True
  32        self.inherits_vr_camera_offset = True
  33        self.inherits_music = True
  34        self.use_fixed_vr_overlay = True
  35
  36        self._do_new_rating: bool = self.session.tournament_id is not None
  37
  38        self._score_display_sound = ba.getsound('scoreHit01')
  39        self._score_display_sound_small = ba.getsound('scoreHit02')
  40        self.drum_roll_sound = ba.getsound('drumRoll')
  41        self.cymbal_sound = ba.getsound('cymbal')
  42
  43        # These get used in UI bits so need to load them in the UI context.
  44        with ba.Context('ui'):
  45            self._replay_icon_texture = ba.gettexture('replayIcon')
  46            self._menu_icon_texture = ba.gettexture('menuIcon')
  47            self._next_level_icon_texture = ba.gettexture('nextLevelIcon')
  48
  49        self._campaign: ba.Campaign = settings['campaign']
  50
  51        self._have_achievements = bool(
  52            ba.app.ach.achievements_for_coop_level(self._campaign.name + ':' +
  53                                                   settings['level']))
  54
  55        self._account_type = (_ba.get_v1_account_type()
  56                              if _ba.get_v1_account_state() == 'signed_in' else
  57                              None)
  58
  59        self._game_service_icon_color: Sequence[float] | None
  60        self._game_service_achievements_texture: ba.Texture | None
  61        self._game_service_leaderboards_texture: ba.Texture | None
  62
  63        with ba.Context('ui'):
  64            if self._account_type == 'Game Center':
  65                self._game_service_icon_color = (1.0, 1.0, 1.0)
  66                icon = ba.gettexture('gameCenterIcon')
  67                self._game_service_achievements_texture = icon
  68                self._game_service_leaderboards_texture = icon
  69                self._account_has_achievements = True
  70            elif self._account_type == 'Game Circle':
  71                icon = ba.gettexture('gameCircleIcon')
  72                self._game_service_icon_color = (1, 1, 1)
  73                self._game_service_achievements_texture = icon
  74                self._game_service_leaderboards_texture = icon
  75                self._account_has_achievements = True
  76            elif self._account_type == 'Google Play':
  77                self._game_service_icon_color = (0.8, 1.0, 0.6)
  78                self._game_service_achievements_texture = (
  79                    ba.gettexture('googlePlayAchievementsIcon'))
  80                self._game_service_leaderboards_texture = (
  81                    ba.gettexture('googlePlayLeaderboardsIcon'))
  82                self._account_has_achievements = True
  83            else:
  84                self._game_service_icon_color = None
  85                self._game_service_achievements_texture = None
  86                self._game_service_leaderboards_texture = None
  87                self._account_has_achievements = False
  88
  89        self._cashregistersound = ba.getsound('cashRegister')
  90        self._gun_cocking_sound = ba.getsound('gunCocking')
  91        self._dingsound = ba.getsound('ding')
  92        self._score_link: str | None = None
  93        self._root_ui: ba.Widget | None = None
  94        self._background: ba.Actor | None = None
  95        self._old_best_rank = 0.0
  96        self._game_name_str: str | None = None
  97        self._game_config_str: str | None = None
  98
  99        # Ui bits.
 100        self._corner_button_offs: tuple[float, float] | None = None
 101        self._league_rank_button: LeagueRankButton | None = None
 102        self._store_button_instance: StoreButton | None = None
 103        self._restart_button: ba.Widget | None = None
 104        self._update_corner_button_positions_timer: ba.Timer | None = None
 105        self._next_level_error: ba.Actor | None = None
 106
 107        # Score/gameplay bits.
 108        self._was_complete: bool | None = None
 109        self._is_complete: bool | None = None
 110        self._newly_complete: bool | None = None
 111        self._is_more_levels: bool | None = None
 112        self._next_level_name: str | None = None
 113        self._show_friend_scores: bool | None = None
 114        self._show_info: dict[str, Any] | None = None
 115        self._name_str: str | None = None
 116        self._friends_loading_status: ba.Actor | None = None
 117        self._score_loading_status: ba.Actor | None = None
 118        self._tournament_time_remaining: float | None = None
 119        self._tournament_time_remaining_text: Text | None = None
 120        self._tournament_time_remaining_text_timer: ba.Timer | None = None
 121
 122        # Stuff for activity skip by pressing button
 123        self._birth_time = ba.time()
 124        self._min_view_time = 5.0
 125        self._allow_server_transition = False
 126        self._server_transitioning: bool | None = None
 127
 128        self._playerinfos: list[ba.PlayerInfo] = settings['playerinfos']
 129        assert isinstance(self._playerinfos, list)
 130        assert (isinstance(i, ba.PlayerInfo) for i in self._playerinfos)
 131
 132        self._score: int | None = settings['score']
 133        assert isinstance(self._score, (int, type(None)))
 134
 135        self._fail_message: ba.Lstr | None = settings['fail_message']
 136        assert isinstance(self._fail_message, (ba.Lstr, type(None)))
 137
 138        self._begin_time: float | None = None
 139
 140        self._score_order: str
 141        if 'score_order' in settings:
 142            if not settings['score_order'] in ['increasing', 'decreasing']:
 143                raise ValueError('Invalid score order: ' +
 144                                 settings['score_order'])
 145            self._score_order = settings['score_order']
 146        else:
 147            self._score_order = 'increasing'
 148        assert isinstance(self._score_order, str)
 149
 150        self._score_type: str
 151        if 'score_type' in settings:
 152            if not settings['score_type'] in ['points', 'time']:
 153                raise ValueError('Invalid score type: ' +
 154                                 settings['score_type'])
 155            self._score_type = settings['score_type']
 156        else:
 157            self._score_type = 'points'
 158        assert isinstance(self._score_type, str)
 159
 160        self._level_name: str = settings['level']
 161        assert isinstance(self._level_name, str)
 162
 163        self._game_name_str = self._campaign.name + ':' + self._level_name
 164        self._game_config_str = str(len(
 165            self._playerinfos)) + 'p' + self._campaign.getlevel(
 166                self._level_name).get_score_version_string().replace(' ', '_')
 167
 168        # If game-center/etc scores are available we show our friends'
 169        # scores. Otherwise we show our local high scores.
 170        self._show_friend_scores = _ba.game_service_has_leaderboard(
 171            self._game_name_str, self._game_config_str)
 172
 173        try:
 174            self._old_best_rank = self._campaign.getlevel(
 175                self._level_name).rating
 176        except Exception:
 177            self._old_best_rank = 0.0
 178
 179        self._victory: bool = settings['outcome'] == 'victory'
 180
 181    def __del__(self) -> None:
 182        super().__del__()
 183
 184        # If our UI is still up, kill it.
 185        if self._root_ui:
 186            with ba.Context('ui'):
 187                ba.containerwidget(edit=self._root_ui, transition='out_left')
 188
 189    def on_transition_in(self) -> None:
 190        from bastd.actor import background  # FIXME NO BSSTD
 191        ba.set_analytics_screen('Coop Score Screen')
 192        super().on_transition_in()
 193        self._background = background.Background(fade_time=0.45,
 194                                                 start_faded=False,
 195                                                 show_logo=True)
 196
 197    def _ui_menu(self) -> None:
 198        from bastd.ui import specialoffer
 199        if specialoffer.show_offer():
 200            return
 201        ba.containerwidget(edit=self._root_ui, transition='out_left')
 202        with ba.Context(self):
 203            ba.timer(0.1, ba.Call(ba.WeakCall(self.session.end)))
 204
 205    def _ui_restart(self) -> None:
 206        from bastd.ui.tournamententry import TournamentEntryWindow
 207        from bastd.ui import specialoffer
 208        if specialoffer.show_offer():
 209            return
 210
 211        # If we're in a tournament and it looks like there's no time left,
 212        # disallow.
 213        if self.session.tournament_id is not None:
 214            if self._tournament_time_remaining is None:
 215                ba.screenmessage(
 216                    ba.Lstr(resource='tournamentCheckingStateText'),
 217                    color=(1, 0, 0))
 218                ba.playsound(ba.getsound('error'))
 219                return
 220            if self._tournament_time_remaining <= 0:
 221                ba.screenmessage(ba.Lstr(resource='tournamentEndedText'),
 222                                 color=(1, 0, 0))
 223                ba.playsound(ba.getsound('error'))
 224                return
 225
 226        # If there are currently fewer players than our session min,
 227        # don't allow.
 228        if len(self.players) < self.session.min_players:
 229            ba.screenmessage(ba.Lstr(resource='notEnoughPlayersRemainingText'),
 230                             color=(1, 0, 0))
 231            ba.playsound(ba.getsound('error'))
 232            return
 233
 234        self._campaign.set_selected_level(self._level_name)
 235
 236        # If this is a tournament, go back to the tournament-entry UI
 237        # otherwise just hop back in.
 238        tournament_id = self.session.tournament_id
 239        if tournament_id is not None:
 240            assert self._restart_button is not None
 241            TournamentEntryWindow(
 242                tournament_id=tournament_id,
 243                tournament_activity=self,
 244                position=self._restart_button.get_screen_space_center())
 245        else:
 246            ba.containerwidget(edit=self._root_ui, transition='out_left')
 247            self.can_show_ad_on_death = True
 248            with ba.Context(self):
 249                self.end({'outcome': 'restart'})
 250
 251    def _ui_next(self) -> None:
 252        from bastd.ui.specialoffer import show_offer
 253        if show_offer():
 254            return
 255
 256        # If we didn't just complete this level but are choosing to play the
 257        # next one, set it as current (this won't happen otherwise).
 258        if (self._is_complete and self._is_more_levels
 259                and not self._newly_complete):
 260            assert self._next_level_name is not None
 261            self._campaign.set_selected_level(self._next_level_name)
 262        ba.containerwidget(edit=self._root_ui, transition='out_left')
 263        with ba.Context(self):
 264            self.end({'outcome': 'next_level'})
 265
 266    def _ui_gc(self) -> None:
 267        _ba.show_online_score_ui('leaderboard',
 268                                 game=self._game_name_str,
 269                                 game_version=self._game_config_str)
 270
 271    def _ui_show_achievements(self) -> None:
 272        _ba.show_online_score_ui('achievements')
 273
 274    def _ui_worlds_best(self) -> None:
 275        if self._score_link is None:
 276            ba.playsound(ba.getsound('error'))
 277            ba.screenmessage(ba.Lstr(resource='scoreListUnavailableText'),
 278                             color=(1, 0.5, 0))
 279        else:
 280            ba.open_url(self._score_link)
 281
 282    def _ui_error(self) -> None:
 283        with ba.Context(self):
 284            self._next_level_error = Text(
 285                ba.Lstr(resource='completeThisLevelToProceedText'),
 286                flash=True,
 287                maxwidth=360,
 288                scale=0.54,
 289                h_align=Text.HAlign.CENTER,
 290                color=(0.5, 0.7, 0.5, 1),
 291                position=(300, -235))
 292            ba.playsound(ba.getsound('error'))
 293            ba.timer(
 294                2.0,
 295                ba.WeakCall(self._next_level_error.handlemessage,
 296                            ba.DieMessage()))
 297
 298    def _should_show_worlds_best_button(self) -> bool:
 299        # Link is too complicated to display with no browser.
 300        return ba.is_browser_likely_available()
 301
 302    def request_ui(self) -> None:
 303        """Set up a callback to show our UI at the next opportune time."""
 304        # We don't want to just show our UI in case the user already has the
 305        # main menu up, so instead we add a callback for when the menu
 306        # closes; if we're still alive, we'll come up then.
 307        # If there's no main menu this gets called immediately.
 308        ba.app.add_main_menu_close_callback(ba.WeakCall(self.show_ui))
 309
 310    def show_ui(self) -> None:
 311        """Show the UI for restarting, playing the next Level, etc."""
 312        # pylint: disable=too-many-locals
 313        from bastd.ui.store.button import StoreButton
 314        from bastd.ui.league.rankbutton import LeagueRankButton
 315
 316        delay = 0.7 if (self._score is not None) else 0.0
 317
 318        # If there's no players left in the game, lets not show the UI
 319        # (that would allow restarting the game with zero players, etc).
 320        if not self.players:
 321            return
 322
 323        rootc = self._root_ui = ba.containerwidget(size=(0, 0),
 324                                                   transition='in_right')
 325
 326        h_offs = 7.0
 327        v_offs = -280.0
 328
 329        # We wanna prevent controllers users from popping up browsers
 330        # or game-center widgets in cases where they can't easily get back
 331        # to the game (like on mac).
 332        can_select_extra_buttons = ba.app.platform == 'android'
 333
 334        _ba.set_ui_input_device(None)  # Menu is up for grabs.
 335
 336        if self._show_friend_scores:
 337            ba.buttonwidget(parent=rootc,
 338                            color=(0.45, 0.4, 0.5),
 339                            position=(h_offs - 520, v_offs + 480),
 340                            size=(300, 60),
 341                            label=ba.Lstr(resource='topFriendsText'),
 342                            on_activate_call=ba.WeakCall(self._ui_gc),
 343                            transition_delay=delay + 0.5,
 344                            icon=self._game_service_leaderboards_texture,
 345                            icon_color=self._game_service_icon_color,
 346                            autoselect=True,
 347                            selectable=can_select_extra_buttons)
 348
 349        if self._have_achievements and self._account_has_achievements:
 350            ba.buttonwidget(parent=rootc,
 351                            color=(0.45, 0.4, 0.5),
 352                            position=(h_offs - 520, v_offs + 450 - 235 + 40),
 353                            size=(300, 60),
 354                            label=ba.Lstr(resource='achievementsText'),
 355                            on_activate_call=ba.WeakCall(
 356                                self._ui_show_achievements),
 357                            transition_delay=delay + 1.5,
 358                            icon=self._game_service_achievements_texture,
 359                            icon_color=self._game_service_icon_color,
 360                            autoselect=True,
 361                            selectable=can_select_extra_buttons)
 362
 363        if self._should_show_worlds_best_button():
 364            ba.buttonwidget(
 365                parent=rootc,
 366                color=(0.45, 0.4, 0.5),
 367                position=(160, v_offs + 480),
 368                size=(350, 62),
 369                label=ba.Lstr(resource='tournamentStandingsText')
 370                if self.session.tournament_id is not None else ba.Lstr(
 371                    resource='worldsBestScoresText') if self._score_type
 372                == 'points' else ba.Lstr(resource='worldsBestTimesText'),
 373                autoselect=True,
 374                on_activate_call=ba.WeakCall(self._ui_worlds_best),
 375                transition_delay=delay + 1.9,
 376                selectable=can_select_extra_buttons)
 377        else:
 378            pass
 379
 380        show_next_button = self._is_more_levels and not (ba.app.demo_mode
 381                                                         or ba.app.arcade_mode)
 382
 383        if not show_next_button:
 384            h_offs += 70
 385
 386        menu_button = ba.buttonwidget(parent=rootc,
 387                                      autoselect=True,
 388                                      position=(h_offs - 130 - 60, v_offs),
 389                                      size=(110, 85),
 390                                      label='',
 391                                      on_activate_call=ba.WeakCall(
 392                                          self._ui_menu))
 393        ba.imagewidget(parent=rootc,
 394                       draw_controller=menu_button,
 395                       position=(h_offs - 130 - 60 + 22, v_offs + 14),
 396                       size=(60, 60),
 397                       texture=self._menu_icon_texture,
 398                       opacity=0.8)
 399        self._restart_button = restart_button = ba.buttonwidget(
 400            parent=rootc,
 401            autoselect=True,
 402            position=(h_offs - 60, v_offs),
 403            size=(110, 85),
 404            label='',
 405            on_activate_call=ba.WeakCall(self._ui_restart))
 406        ba.imagewidget(parent=rootc,
 407                       draw_controller=restart_button,
 408                       position=(h_offs - 60 + 19, v_offs + 7),
 409                       size=(70, 70),
 410                       texture=self._replay_icon_texture,
 411                       opacity=0.8)
 412
 413        next_button: ba.Widget | None = None
 414
 415        # Our 'next' button is disabled if we haven't unlocked the next
 416        # level yet and invisible if there is none.
 417        if show_next_button:
 418            if self._is_complete:
 419                call = ba.WeakCall(self._ui_next)
 420                button_sound = True
 421                image_opacity = 0.8
 422                color = None
 423            else:
 424                call = ba.WeakCall(self._ui_error)
 425                button_sound = False
 426                image_opacity = 0.2
 427                color = (0.3, 0.3, 0.3)
 428            next_button = ba.buttonwidget(parent=rootc,
 429                                          autoselect=True,
 430                                          position=(h_offs + 130 - 60, v_offs),
 431                                          size=(110, 85),
 432                                          label='',
 433                                          on_activate_call=call,
 434                                          color=color,
 435                                          enable_sound=button_sound)
 436            ba.imagewidget(parent=rootc,
 437                           draw_controller=next_button,
 438                           position=(h_offs + 130 - 60 + 12, v_offs + 5),
 439                           size=(80, 80),
 440                           texture=self._next_level_icon_texture,
 441                           opacity=image_opacity)
 442
 443        x_offs_extra = 0 if show_next_button else -100
 444        self._corner_button_offs = (h_offs + 300.0 + 100.0 + x_offs_extra,
 445                                    v_offs + 560.0)
 446
 447        if ba.app.demo_mode or ba.app.arcade_mode:
 448            self._league_rank_button = None
 449            self._store_button_instance = None
 450        else:
 451            self._league_rank_button = LeagueRankButton(
 452                parent=rootc,
 453                position=(h_offs + 300 + 100 + x_offs_extra, v_offs + 560),
 454                size=(100, 60),
 455                scale=0.9,
 456                color=(0.4, 0.4, 0.9),
 457                textcolor=(0.9, 0.9, 2.0),
 458                transition_delay=0.0,
 459                smooth_update_delay=5.0)
 460            self._store_button_instance = StoreButton(
 461                parent=rootc,
 462                position=(h_offs + 400 + 100 + x_offs_extra, v_offs + 560),
 463                show_tickets=True,
 464                sale_scale=0.85,
 465                size=(100, 60),
 466                scale=0.9,
 467                button_type='square',
 468                color=(0.35, 0.25, 0.45),
 469                textcolor=(0.9, 0.7, 1.0),
 470                transition_delay=0.0)
 471
 472        ba.containerwidget(edit=rootc,
 473                           selected_child=next_button if
 474                           (self._newly_complete and self._victory
 475                            and show_next_button) else restart_button,
 476                           on_cancel_call=menu_button.activate)
 477
 478        self._update_corner_button_positions()
 479        self._update_corner_button_positions_timer = ba.Timer(
 480            1.0,
 481            ba.WeakCall(self._update_corner_button_positions),
 482            repeat=True,
 483            timetype=ba.TimeType.REAL)
 484
 485    def _update_corner_button_positions(self) -> None:
 486        offs = -55 if _ba.is_party_icon_visible() else 0
 487        assert self._corner_button_offs is not None
 488        pos_x = self._corner_button_offs[0] + offs
 489        pos_y = self._corner_button_offs[1]
 490        if self._league_rank_button is not None:
 491            self._league_rank_button.set_position((pos_x, pos_y))
 492        if self._store_button_instance is not None:
 493            self._store_button_instance.set_position((pos_x + 100, pos_y))
 494
 495    def _player_press(self) -> None:
 496        # (Only for headless builds).
 497
 498        # If this activity is a good 'end point', ask server-mode just once if
 499        # it wants to do anything special like switch sessions or kill the app.
 500        if (self._allow_server_transition and _ba.app.server is not None
 501                and self._server_transitioning is None):
 502            self._server_transitioning = _ba.app.server.handle_transition()
 503            assert isinstance(self._server_transitioning, bool)
 504
 505        # If server-mode is handling this, don't do anything ourself.
 506        if self._server_transitioning is True:
 507            return
 508
 509        # Otherwise restart current level.
 510        self._campaign.set_selected_level(self._level_name)
 511        with ba.Context(self):
 512            self.end({'outcome': 'restart'})
 513
 514    def _safe_assign(self, player: ba.Player) -> None:
 515        # (Only for headless builds).
 516
 517        # Just to be extra careful, don't assign if we're transitioning out.
 518        # (though theoretically that should be ok).
 519        if not self.is_transitioning_out() and player:
 520            player.assigninput(
 521                (ba.InputType.JUMP_PRESS, ba.InputType.PUNCH_PRESS,
 522                 ba.InputType.BOMB_PRESS, ba.InputType.PICK_UP_PRESS),
 523                self._player_press)
 524
 525    def on_player_join(self, player: ba.Player) -> None:
 526        super().on_player_join(player)
 527
 528        if ba.app.server is not None:
 529            # Host can't press retry button, so anyone can do it instead.
 530            time_till_assign = max(
 531                0, self._birth_time + self._min_view_time - _ba.time())
 532
 533            ba.timer(time_till_assign, ba.WeakCall(self._safe_assign, player))
 534
 535    def on_begin(self) -> None:
 536        # FIXME: Clean this up.
 537        # pylint: disable=too-many-statements
 538        # pylint: disable=too-many-branches
 539        # pylint: disable=too-many-locals
 540        super().on_begin()
 541
 542        self._begin_time = ba.time()
 543
 544        # Calc whether the level is complete and other stuff.
 545        levels = self._campaign.levels
 546        level = self._campaign.getlevel(self._level_name)
 547        self._was_complete = level.complete
 548        self._is_complete = (self._was_complete or self._victory)
 549        self._newly_complete = (self._is_complete and not self._was_complete)
 550        self._is_more_levels = ((level.index < len(levels) - 1)
 551                                and self._campaign.sequential)
 552
 553        # Any time we complete a level, set the next one as unlocked.
 554        if self._is_complete and self._is_more_levels:
 555            _ba.add_transaction({
 556                'type': 'COMPLETE_LEVEL',
 557                'campaign': self._campaign.name,
 558                'level': self._level_name
 559            })
 560            self._next_level_name = levels[level.index + 1].name
 561
 562            # If this is the first time we completed it, set the next one
 563            # as current.
 564            if self._newly_complete:
 565                cfg = ba.app.config
 566                cfg['Selected Coop Game'] = (self._campaign.name + ':' +
 567                                             self._next_level_name)
 568                cfg.commit()
 569                self._campaign.set_selected_level(self._next_level_name)
 570
 571        ba.timer(1.0, ba.WeakCall(self.request_ui))
 572
 573        if (self._is_complete and self._victory and self._is_more_levels
 574                and not (ba.app.demo_mode or ba.app.arcade_mode)):
 575            Text(ba.Lstr(value='${A}:\n',
 576                         subs=[('${A}', ba.Lstr(resource='levelUnlockedText'))
 577                               ]) if self._newly_complete else
 578                 ba.Lstr(value='${A}:\n',
 579                         subs=[('${A}', ba.Lstr(resource='nextLevelText'))]),
 580                 transition=Text.Transition.IN_RIGHT,
 581                 transition_delay=5.2,
 582                 flash=self._newly_complete,
 583                 scale=0.54,
 584                 h_align=Text.HAlign.CENTER,
 585                 maxwidth=270,
 586                 color=(0.5, 0.7, 0.5, 1),
 587                 position=(270, -235)).autoretain()
 588            assert self._next_level_name is not None
 589            Text(ba.Lstr(translate=('coopLevelNames', self._next_level_name)),
 590                 transition=Text.Transition.IN_RIGHT,
 591                 transition_delay=5.2,
 592                 flash=self._newly_complete,
 593                 scale=0.7,
 594                 h_align=Text.HAlign.CENTER,
 595                 maxwidth=205,
 596                 color=(0.5, 0.7, 0.5, 1),
 597                 position=(270, -255)).autoretain()
 598            if self._newly_complete:
 599                ba.timer(5.2, ba.Call(ba.playsound, self._cashregistersound))
 600                ba.timer(5.2, ba.Call(ba.playsound, self._dingsound))
 601
 602        offs_x = -195
 603        if len(self._playerinfos) > 1:
 604            pstr = ba.Lstr(value='- ${A} -',
 605                           subs=[('${A}',
 606                                  ba.Lstr(resource='multiPlayerCountText',
 607                                          subs=[('${COUNT}',
 608                                                 str(len(self._playerinfos)))
 609                                                ]))])
 610        else:
 611            pstr = ba.Lstr(value='- ${A} -',
 612                           subs=[('${A}',
 613                                  ba.Lstr(resource='singlePlayerCountText'))])
 614        ZoomText(self._campaign.getlevel(self._level_name).displayname,
 615                 maxwidth=800,
 616                 flash=False,
 617                 trail=False,
 618                 color=(0.5, 1, 0.5, 1),
 619                 h_align='center',
 620                 scale=0.4,
 621                 position=(0, 292),
 622                 jitter=1.0).autoretain()
 623        Text(pstr,
 624             maxwidth=300,
 625             transition=Text.Transition.FADE_IN,
 626             scale=0.7,
 627             h_align=Text.HAlign.CENTER,
 628             v_align=Text.VAlign.CENTER,
 629             color=(0.5, 0.7, 0.5, 1),
 630             position=(0, 230)).autoretain()
 631
 632        if ba.app.server is None:
 633            # If we're running in normal non-headless build, show this text
 634            # because only host can continue the game.
 635            adisp = _ba.get_v1_account_display_string()
 636            txt = Text(ba.Lstr(resource='waitingForHostText',
 637                               subs=[('${HOST}', adisp)]),
 638                       maxwidth=300,
 639                       transition=Text.Transition.FADE_IN,
 640                       transition_delay=8.0,
 641                       scale=0.85,
 642                       h_align=Text.HAlign.CENTER,
 643                       v_align=Text.VAlign.CENTER,
 644                       color=(1, 1, 0, 1),
 645                       position=(0, -230)).autoretain()
 646            assert txt.node
 647            txt.node.client_only = True
 648        else:
 649            # In headless build, anyone can continue the game.
 650            sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
 651            Text(sval,
 652                 v_attach=Text.VAttach.BOTTOM,
 653                 h_align=Text.HAlign.CENTER,
 654                 flash=True,
 655                 vr_depth=50,
 656                 position=(0, 60),
 657                 scale=0.8,
 658                 color=(0.5, 0.7, 0.5, 0.5),
 659                 transition=Text.Transition.IN_BOTTOM_SLOW,
 660                 transition_delay=self._min_view_time).autoretain()
 661
 662        if self._score is not None:
 663            ba.timer(0.35,
 664                     ba.Call(ba.playsound, self._score_display_sound_small))
 665
 666        # Vestigial remain; this stuff should just be instance vars.
 667        self._show_info = {}
 668
 669        if self._score is not None:
 670            ba.timer(0.8, ba.WeakCall(self._show_score_val, offs_x))
 671        else:
 672            ba.pushcall(ba.WeakCall(self._show_fail))
 673
 674        self._name_str = name_str = ', '.join(
 675            [p.name for p in self._playerinfos])
 676
 677        if self._show_friend_scores:
 678            self._friends_loading_status = Text(
 679                ba.Lstr(value='${A}...',
 680                        subs=[('${A}', ba.Lstr(resource='loadingText'))]),
 681                position=(-405, 150 + 30),
 682                color=(1, 1, 1, 0.4),
 683                transition=Text.Transition.FADE_IN,
 684                scale=0.7,
 685                transition_delay=2.0)
 686        self._score_loading_status = Text(ba.Lstr(
 687            value='${A}...', subs=[('${A}', ba.Lstr(resource='loadingText'))]),
 688                                          position=(280, 150 + 30),
 689                                          color=(1, 1, 1, 0.4),
 690                                          transition=Text.Transition.FADE_IN,
 691                                          scale=0.7,
 692                                          transition_delay=2.0)
 693
 694        if self._score is not None:
 695            ba.timer(0.4, ba.WeakCall(self._play_drumroll))
 696
 697        # Add us to high scores, filter, and store.
 698        our_high_scores_all = self._campaign.getlevel(
 699            self._level_name).get_high_scores()
 700
 701        our_high_scores = our_high_scores_all.setdefault(
 702            str(len(self._playerinfos)) + ' Player', [])
 703
 704        if self._score is not None:
 705            our_score: list | None = [
 706                self._score, {
 707                    'players': [{
 708                        'name': p.name,
 709                        'character': p.character
 710                    } for p in self._playerinfos]
 711                }
 712            ]
 713            our_high_scores.append(our_score)
 714        else:
 715            our_score = None
 716
 717        try:
 718            our_high_scores.sort(reverse=self._score_order == 'increasing',
 719                                 key=lambda x: x[0])
 720        except Exception:
 721            ba.print_exception('Error sorting scores.')
 722            print(f'our_high_scores: {our_high_scores}')
 723
 724        del our_high_scores[10:]
 725
 726        if self._score is not None:
 727            sver = (self._campaign.getlevel(
 728                self._level_name).get_score_version_string())
 729            _ba.add_transaction({
 730                'type': 'SET_LEVEL_LOCAL_HIGH_SCORES',
 731                'campaign': self._campaign.name,
 732                'level': self._level_name,
 733                'scoreVersion': sver,
 734                'scores': our_high_scores_all
 735            })
 736        if _ba.get_v1_account_state() != 'signed_in':
 737            # We expect this only in kiosk mode; complain otherwise.
 738            if not (ba.app.demo_mode or ba.app.arcade_mode):
 739                print('got not-signed-in at score-submit; unexpected')
 740            if self._show_friend_scores:
 741                ba.pushcall(ba.WeakCall(self._got_friend_score_results, None))
 742            ba.pushcall(ba.WeakCall(self._got_score_results, None))
 743        else:
 744            assert self._game_name_str is not None
 745            assert self._game_config_str is not None
 746            _ba.submit_score(self._game_name_str,
 747                             self._game_config_str,
 748                             name_str,
 749                             self._score,
 750                             ba.WeakCall(self._got_score_results),
 751                             ba.WeakCall(self._got_friend_score_results)
 752                             if self._show_friend_scores else None,
 753                             order=self._score_order,
 754                             tournament_id=self.session.tournament_id,
 755                             score_type=self._score_type,
 756                             campaign=self._campaign.name,
 757                             level=self._level_name)
 758
 759        # Apply the transactions we've been adding locally.
 760        _ba.run_transactions()
 761
 762        # If we're not doing the world's-best button, just show a title
 763        # instead.
 764        ts_height = 300
 765        ts_h_offs = 210
 766        v_offs = 40
 767        txt = Text(ba.Lstr(resource='tournamentStandingsText')
 768                   if self.session.tournament_id is not None else ba.Lstr(
 769                       resource='worldsBestScoresText') if self._score_type
 770                   == 'points' else ba.Lstr(resource='worldsBestTimesText'),
 771                   maxwidth=210,
 772                   position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 773                   transition=Text.Transition.IN_LEFT,
 774                   v_align=Text.VAlign.CENTER,
 775                   scale=1.2,
 776                   transition_delay=2.2).autoretain()
 777
 778        # If we've got a button on the server, only show this on clients.
 779        if self._should_show_worlds_best_button():
 780            assert txt.node
 781            txt.node.client_only = True
 782
 783        # If we have no friend scores, display local best scores.
 784        if self._show_friend_scores:
 785
 786            # Host has a button, so we need client-only text.
 787            ts_height = 300
 788            ts_h_offs = -480
 789            v_offs = 40
 790            txt = Text(ba.Lstr(resource='topFriendsText'),
 791                       maxwidth=210,
 792                       position=(ts_h_offs - 10,
 793                                 ts_height / 2 + 25 + v_offs + 20),
 794                       transition=Text.Transition.IN_RIGHT,
 795                       v_align=Text.VAlign.CENTER,
 796                       scale=1.2,
 797                       transition_delay=1.8).autoretain()
 798            assert txt.node
 799            txt.node.client_only = True
 800        else:
 801
 802            ts_height = 300
 803            ts_h_offs = -480
 804            v_offs = 40
 805            Text(ba.Lstr(resource='yourBestScoresText') if self._score_type
 806                 == 'points' else ba.Lstr(resource='yourBestTimesText'),
 807                 maxwidth=210,
 808                 position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 809                 transition=Text.Transition.IN_RIGHT,
 810                 v_align=Text.VAlign.CENTER,
 811                 scale=1.2,
 812                 transition_delay=1.8).autoretain()
 813
 814            display_scores = list(our_high_scores)
 815            display_count = 5
 816
 817            while len(display_scores) < display_count:
 818                display_scores.append((0, None))
 819
 820            showed_ours = False
 821            h_offs_extra = 85 if self._score_type == 'points' else 130
 822            v_offs_extra = 20
 823            v_offs_names = 0
 824            scale = 1.0
 825            p_count = len(self._playerinfos)
 826            h_offs_extra -= 75
 827            if p_count > 1:
 828                h_offs_extra -= 20
 829            if p_count == 2:
 830                scale = 0.9
 831            elif p_count == 3:
 832                scale = 0.65
 833            elif p_count == 4:
 834                scale = 0.5
 835            times: list[tuple[float, float]] = []
 836            for i in range(display_count):
 837                times.insert(random.randrange(0,
 838                                              len(times) + 1),
 839                             (1.9 + i * 0.05, 2.3 + i * 0.05))
 840            for i in range(display_count):
 841                try:
 842                    if display_scores[i][1] is None:
 843                        name_str = '-'
 844                    else:
 845                        # noinspection PyUnresolvedReferences
 846                        name_str = ', '.join([
 847                            p['name'] for p in display_scores[i][1]['players']
 848                        ])
 849                except Exception:
 850                    ba.print_exception(
 851                        f'Error calcing name_str for {display_scores}')
 852                    name_str = '-'
 853                if display_scores[i] == our_score and not showed_ours:
 854                    flash = True
 855                    color0 = (0.6, 0.4, 0.1, 1.0)
 856                    color1 = (0.6, 0.6, 0.6, 1.0)
 857                    tdelay1 = 3.7
 858                    tdelay2 = 3.7
 859                    showed_ours = True
 860                else:
 861                    flash = False
 862                    color0 = (0.6, 0.4, 0.1, 1.0)
 863                    color1 = (0.6, 0.6, 0.6, 1.0)
 864                    tdelay1 = times[i][0]
 865                    tdelay2 = times[i][1]
 866                Text(str(display_scores[i][0]) if self._score_type == 'points'
 867                     else ba.timestring(display_scores[i][0] * 10,
 868                                        timeformat=ba.TimeFormat.MILLISECONDS,
 869                                        suppress_format_warning=True),
 870                     position=(ts_h_offs + 20 + h_offs_extra,
 871                               v_offs_extra + ts_height / 2 + -ts_height *
 872                               (i + 1) / 10 + v_offs + 11.0),
 873                     h_align=Text.HAlign.RIGHT,
 874                     v_align=Text.VAlign.CENTER,
 875                     color=color0,
 876                     flash=flash,
 877                     transition=Text.Transition.IN_RIGHT,
 878                     transition_delay=tdelay1).autoretain()
 879
 880                Text(ba.Lstr(value=name_str),
 881                     position=(ts_h_offs + 35 + h_offs_extra,
 882                               v_offs_extra + ts_height / 2 + -ts_height *
 883                               (i + 1) / 10 + v_offs_names + v_offs + 11.0),
 884                     maxwidth=80.0 + 100.0 * len(self._playerinfos),
 885                     v_align=Text.VAlign.CENTER,
 886                     color=color1,
 887                     flash=flash,
 888                     scale=scale,
 889                     transition=Text.Transition.IN_RIGHT,
 890                     transition_delay=tdelay2).autoretain()
 891
 892        # Show achievements for this level.
 893        ts_height = -150
 894        ts_h_offs = -480
 895        v_offs = 40
 896
 897        # Only make this if we don't have the button
 898        # (never want clients to see it so no need for client-only
 899        # version, etc).
 900        if self._have_achievements:
 901            if not self._account_has_achievements:
 902                Text(ba.Lstr(resource='achievementsText'),
 903                     position=(ts_h_offs - 10,
 904                               ts_height / 2 + 25 + v_offs + 3),
 905                     maxwidth=210,
 906                     host_only=True,
 907                     transition=Text.Transition.IN_RIGHT,
 908                     v_align=Text.VAlign.CENTER,
 909                     scale=1.2,
 910                     transition_delay=2.8).autoretain()
 911
 912            assert self._game_name_str is not None
 913            achievements = ba.app.ach.achievements_for_coop_level(
 914                self._game_name_str)
 915            hval = -455
 916            vval = -100
 917            tdelay = 0.0
 918            for ach in achievements:
 919                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
 920                vval -= 55
 921                tdelay += 0.250
 922
 923        ba.timer(5.0, ba.WeakCall(self._show_tips))
 924
 925    def _play_drumroll(self) -> None:
 926        ba.NodeActor(
 927            ba.newnode('sound',
 928                       attrs={
 929                           'sound': self.drum_roll_sound,
 930                           'positional': False,
 931                           'loop': False
 932                       })).autoretain()
 933
 934    def _got_friend_score_results(self, results: list[Any] | None) -> None:
 935
 936        # FIXME: tidy this up
 937        # pylint: disable=too-many-locals
 938        # pylint: disable=too-many-branches
 939        # pylint: disable=too-many-statements
 940        from efro.util import asserttype
 941        # delay a bit if results come in too fast
 942        assert self._begin_time is not None
 943        base_delay = max(0, 1.9 - (ba.time() - self._begin_time))
 944        ts_height = 300
 945        ts_h_offs = -550
 946        v_offs = 30
 947
 948        # Report in case of error.
 949        if results is None:
 950            self._friends_loading_status = Text(
 951                ba.Lstr(resource='friendScoresUnavailableText'),
 952                maxwidth=330,
 953                position=(-475, 150 + v_offs),
 954                color=(1, 1, 1, 0.4),
 955                transition=Text.Transition.FADE_IN,
 956                transition_delay=base_delay + 0.8,
 957                scale=0.7)
 958            return
 959
 960        self._friends_loading_status = None
 961
 962        # Ok, it looks like we aren't able to reliably get a just-submitted
 963        # result returned in the score list, so we need to look for our score
 964        # in this list and replace it if ours is better or add ours otherwise.
 965        if self._score is not None:
 966            our_score_entry = [self._score, 'Me', True]
 967            for score in results:
 968                if score[2]:
 969                    if self._score_order == 'increasing':
 970                        our_score_entry[0] = max(score[0], self._score)
 971                    else:
 972                        our_score_entry[0] = min(score[0], self._score)
 973                    results.remove(score)
 974                    break
 975            results.append(our_score_entry)
 976            results.sort(reverse=self._score_order == 'increasing',
 977                         key=lambda x: asserttype(x[0], int))
 978
 979        # If we're not submitting our own score, we still want to change the
 980        # name of our own score to 'Me'.
 981        else:
 982            for score in results:
 983                if score[2]:
 984                    score[1] = 'Me'
 985                    break
 986        h_offs_extra = 80 if self._score_type == 'points' else 130
 987        v_offs_extra = 20
 988        v_offs_names = 0
 989        scale = 1.0
 990
 991        # Make sure there's at least 5.
 992        while len(results) < 5:
 993            results.append([0, '-', False])
 994        results = results[:5]
 995        times: list[tuple[float, float]] = []
 996        for i in range(len(results)):
 997            times.insert(random.randrange(0,
 998                                          len(times) + 1),
 999                         (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05))
1000        for i, tval in enumerate(results):
1001            score = int(tval[0])
1002            name_str = tval[1]
1003            is_me = tval[2]
1004            if is_me and score == self._score:
1005                flash = True
1006                color0 = (0.6, 0.4, 0.1, 1.0)
1007                color1 = (0.6, 0.6, 0.6, 1.0)
1008                tdelay1 = base_delay + 1.0
1009                tdelay2 = base_delay + 1.0
1010            else:
1011                flash = False
1012                if is_me:
1013                    color0 = (0.6, 0.4, 0.1, 1.0)
1014                    color1 = (0.9, 1.0, 0.9, 1.0)
1015                else:
1016                    color0 = (0.6, 0.4, 0.1, 1.0)
1017                    color1 = (0.6, 0.6, 0.6, 1.0)
1018                tdelay1 = times[i][0]
1019                tdelay2 = times[i][1]
1020            if name_str != '-':
1021                Text(str(score) if self._score_type == 'points' else
1022                     ba.timestring(score * 10,
1023                                   timeformat=ba.TimeFormat.MILLISECONDS),
1024                     position=(ts_h_offs + 20 + h_offs_extra,
1025                               v_offs_extra + ts_height / 2 + -ts_height *
1026                               (i + 1) / 10 + v_offs + 11.0),
1027                     h_align=Text.HAlign.RIGHT,
1028                     v_align=Text.VAlign.CENTER,
1029                     color=color0,
1030                     flash=flash,
1031                     transition=Text.Transition.IN_RIGHT,
1032                     transition_delay=tdelay1).autoretain()
1033            else:
1034                if is_me:
1035                    print('Error: got empty name_str on score result:', tval)
1036
1037            Text(ba.Lstr(value=name_str),
1038                 position=(ts_h_offs + 35 + h_offs_extra,
1039                           v_offs_extra + ts_height / 2 + -ts_height *
1040                           (i + 1) / 10 + v_offs_names + v_offs + 11.0),
1041                 color=color1,
1042                 maxwidth=160.0,
1043                 v_align=Text.VAlign.CENTER,
1044                 flash=flash,
1045                 scale=scale,
1046                 transition=Text.Transition.IN_RIGHT,
1047                 transition_delay=tdelay2).autoretain()
1048
1049    def _got_score_results(self, results: dict[str, Any] | None) -> None:
1050
1051        # FIXME: tidy this up
1052        # pylint: disable=too-many-locals
1053        # pylint: disable=too-many-branches
1054        # pylint: disable=too-many-statements
1055
1056        # We need to manually run this in the context of our activity
1057        # and only if we aren't shutting down.
1058        # (really should make the submit_score call handle that stuff itself)
1059        if self.expired:
1060            return
1061        with ba.Context(self):
1062            # Delay a bit if results come in too fast.
1063            assert self._begin_time is not None
1064            base_delay = max(0, 2.7 - (ba.time() - self._begin_time))
1065            v_offs = 20
1066            if results is None:
1067                self._score_loading_status = Text(
1068                    ba.Lstr(resource='worldScoresUnavailableText'),
1069                    position=(230, 150 + v_offs),
1070                    color=(1, 1, 1, 0.4),
1071                    transition=Text.Transition.FADE_IN,
1072                    transition_delay=base_delay + 0.3,
1073                    scale=0.7)
1074            else:
1075                self._score_link = results['link']
1076                assert self._score_link is not None
1077                if not self._score_link.startswith('http://'):
1078                    self._score_link = (_ba.get_master_server_address() + '/' +
1079                                        self._score_link)
1080                self._score_loading_status = None
1081                if 'tournamentSecondsRemaining' in results:
1082                    secs_remaining = results['tournamentSecondsRemaining']
1083                    assert isinstance(secs_remaining, int)
1084                    self._tournament_time_remaining = secs_remaining
1085                    self._tournament_time_remaining_text_timer = ba.Timer(
1086                        1.0,
1087                        ba.WeakCall(
1088                            self._update_tournament_time_remaining_text),
1089                        repeat=True,
1090                        timetype=ba.TimeType.BASE)
1091
1092            assert self._show_info is not None
1093            self._show_info['results'] = results
1094            if results is not None:
1095                if results['tops'] != '':
1096                    self._show_info['tops'] = results['tops']
1097                else:
1098                    self._show_info['tops'] = []
1099            offs_x = -195
1100            available = (self._show_info['results'] is not None)
1101            if self._score is not None:
1102                ba.timer((1.5 + base_delay),
1103                         ba.WeakCall(self._show_world_rank, offs_x),
1104                         timetype=ba.TimeType.BASE)
1105            ts_h_offs = 200
1106            ts_height = 300
1107
1108            # Show world tops.
1109            if available:
1110
1111                # Show the number of games represented by this
1112                # list (except for in tournaments).
1113                if self.session.tournament_id is None:
1114                    Text(ba.Lstr(resource='lastGamesText',
1115                                 subs=[
1116                                     ('${COUNT}',
1117                                      str(self._show_info['results']['total']))
1118                                 ]),
1119                         position=(ts_h_offs - 35 + 95,
1120                                   ts_height / 2 + 6 + v_offs),
1121                         color=(0.4, 0.4, 0.4, 1.0),
1122                         scale=0.7,
1123                         transition=Text.Transition.IN_RIGHT,
1124                         transition_delay=base_delay + 0.3).autoretain()
1125                else:
1126                    v_offs += 20
1127
1128                h_offs_extra = 0
1129                v_offs_names = 0
1130                scale = 1.0
1131                p_count = len(self._playerinfos)
1132                if p_count > 1:
1133                    h_offs_extra -= 40
1134                if self._score_type != 'points':
1135                    h_offs_extra += 60
1136                if p_count == 2:
1137                    scale = 0.9
1138                elif p_count == 3:
1139                    scale = 0.65
1140                elif p_count == 4:
1141                    scale = 0.5
1142
1143                # Make sure there's at least 10.
1144                while len(self._show_info['tops']) < 10:
1145                    self._show_info['tops'].append([0, '-'])
1146
1147                times: list[tuple[float, float]] = []
1148                for i in range(len(self._show_info['tops'])):
1149                    times.insert(
1150                        random.randrange(0,
1151                                         len(times) + 1),
1152                        (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05))
1153                for i, tval in enumerate(self._show_info['tops']):
1154                    score = int(tval[0])
1155                    name_str = tval[1]
1156                    if self._name_str == name_str and self._score == score:
1157                        flash = True
1158                        color0 = (0.6, 0.4, 0.1, 1.0)
1159                        color1 = (0.6, 0.6, 0.6, 1.0)
1160                        tdelay1 = base_delay + 1.0
1161                        tdelay2 = base_delay + 1.0
1162                    else:
1163                        flash = False
1164                        if self._name_str == name_str:
1165                            color0 = (0.6, 0.4, 0.1, 1.0)
1166                            color1 = (0.9, 1.0, 0.9, 1.0)
1167                        else:
1168                            color0 = (0.6, 0.4, 0.1, 1.0)
1169                            color1 = (0.6, 0.6, 0.6, 1.0)
1170                        tdelay1 = times[i][0]
1171                        tdelay2 = times[i][1]
1172
1173                    if name_str != '-':
1174                        Text(str(score) if self._score_type == 'points' else
1175                             ba.timestring(
1176                                 score * 10,
1177                                 timeformat=ba.TimeFormat.MILLISECONDS),
1178                             position=(ts_h_offs + 20 + h_offs_extra,
1179                                       ts_height / 2 + -ts_height *
1180                                       (i + 1) / 10 + v_offs + 11.0),
1181                             h_align=Text.HAlign.RIGHT,
1182                             v_align=Text.VAlign.CENTER,
1183                             color=color0,
1184                             flash=flash,
1185                             transition=Text.Transition.IN_LEFT,
1186                             transition_delay=tdelay1).autoretain()
1187                    Text(ba.Lstr(value=name_str),
1188                         position=(ts_h_offs + 35 + h_offs_extra,
1189                                   ts_height / 2 + -ts_height * (i + 1) / 10 +
1190                                   v_offs_names + v_offs + 11.0),
1191                         maxwidth=80.0 + 100.0 * len(self._playerinfos),
1192                         v_align=Text.VAlign.CENTER,
1193                         color=color1,
1194                         flash=flash,
1195                         scale=scale,
1196                         transition=Text.Transition.IN_LEFT,
1197                         transition_delay=tdelay2).autoretain()
1198
1199    def _show_tips(self) -> None:
1200        from bastd.actor.tipstext import TipsText
1201        TipsText(offs_y=30).autoretain()
1202
1203    def _update_tournament_time_remaining_text(self) -> None:
1204        if self._tournament_time_remaining is None:
1205            return
1206        self._tournament_time_remaining = max(
1207            0, self._tournament_time_remaining - 1)
1208        if self._tournament_time_remaining_text is not None:
1209            val = ba.timestring(self._tournament_time_remaining,
1210                                suppress_format_warning=True,
1211                                centi=False)
1212            self._tournament_time_remaining_text.node.text = val
1213
1214    def _show_world_rank(self, offs_x: float) -> None:
1215        # FIXME: Tidy this up.
1216        # pylint: disable=too-many-locals
1217        # pylint: disable=too-many-branches
1218        # pylint: disable=too-many-statements
1219        from ba.internal import get_tournament_prize_strings
1220        assert self._show_info is not None
1221        available = (self._show_info['results'] is not None)
1222
1223        if available:
1224            error = (self._show_info['results']['error']
1225                     if 'error' in self._show_info['results'] else None)
1226            rank = self._show_info['results']['rank']
1227            total = self._show_info['results']['total']
1228            rating = (10.0 if total == 1 else 10.0 * (1.0 - (float(rank - 1) /
1229                                                             (total - 1))))
1230            player_rank = self._show_info['results']['playerRank']
1231            best_player_rank = self._show_info['results']['bestPlayerRank']
1232        else:
1233            error = False
1234            rating = None
1235            player_rank = None
1236            best_player_rank = None
1237
1238        # If we've got tournament-seconds-remaining, show it.
1239        if self._tournament_time_remaining is not None:
1240            Text(ba.Lstr(resource='coopSelectWindow.timeRemainingText'),
1241                 position=(-360, -70 - 100),
1242                 color=(1, 1, 1, 0.7),
1243                 h_align=Text.HAlign.CENTER,
1244                 v_align=Text.VAlign.CENTER,
1245                 transition=Text.Transition.FADE_IN,
1246                 scale=0.8,
1247                 maxwidth=300,
1248                 transition_delay=2.0).autoretain()
1249            self._tournament_time_remaining_text = Text(
1250                '',
1251                position=(-360, -110 - 100),
1252                color=(1, 1, 1, 0.7),
1253                h_align=Text.HAlign.CENTER,
1254                v_align=Text.VAlign.CENTER,
1255                transition=Text.Transition.FADE_IN,
1256                scale=1.6,
1257                maxwidth=150,
1258                transition_delay=2.0)
1259
1260        # If we're a tournament, show prizes.
1261        try:
1262            tournament_id = self.session.tournament_id
1263            if tournament_id is not None:
1264                if tournament_id in ba.app.accounts_v1.tournament_info:
1265                    tourney_info = ba.app.accounts_v1.tournament_info[
1266                        tournament_id]
1267                    # pylint: disable=unbalanced-tuple-unpacking
1268                    pr1, pv1, pr2, pv2, pr3, pv3 = (
1269                        get_tournament_prize_strings(tourney_info))
1270                    # pylint: enable=unbalanced-tuple-unpacking
1271                    Text(ba.Lstr(resource='coopSelectWindow.prizesText'),
1272                         position=(-360, -70 + 77),
1273                         color=(1, 1, 1, 0.7),
1274                         h_align=Text.HAlign.CENTER,
1275                         v_align=Text.VAlign.CENTER,
1276                         transition=Text.Transition.FADE_IN,
1277                         scale=1.0,
1278                         maxwidth=300,
1279                         transition_delay=2.0).autoretain()
1280                    vval = -107 + 70
1281                    for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)):
1282                        Text(rng,
1283                             position=(-410 + 10, vval),
1284                             color=(1, 1, 1, 0.7),
1285                             h_align=Text.HAlign.RIGHT,
1286                             v_align=Text.VAlign.CENTER,
1287                             transition=Text.Transition.FADE_IN,
1288                             scale=0.6,
1289                             maxwidth=300,
1290                             transition_delay=2.0).autoretain()
1291                        Text(val,
1292                             position=(-390 + 10, vval),
1293                             color=(0.7, 0.7, 0.7, 1.0),
1294                             h_align=Text.HAlign.LEFT,
1295                             v_align=Text.VAlign.CENTER,
1296                             transition=Text.Transition.FADE_IN,
1297                             scale=0.8,
1298                             maxwidth=300,
1299                             transition_delay=2.0).autoretain()
1300                        vval -= 35
1301        except Exception:
1302            ba.print_exception('Error showing prize ranges.')
1303
1304        if self._do_new_rating:
1305            if error:
1306                ZoomText(ba.Lstr(resource='failText'),
1307                         flash=True,
1308                         trail=True,
1309                         scale=1.0 if available else 0.333,
1310                         tilt_translate=0.11,
1311                         h_align='center',
1312                         position=(190 + offs_x, -60),
1313                         maxwidth=200,
1314                         jitter=1.0).autoretain()
1315                Text(ba.Lstr(translate=('serverResponses', error)),
1316                     position=(0, -140),
1317                     color=(1, 1, 1, 0.7),
1318                     h_align=Text.HAlign.CENTER,
1319                     v_align=Text.VAlign.CENTER,
1320                     transition=Text.Transition.FADE_IN,
1321                     scale=0.9,
1322                     maxwidth=400,
1323                     transition_delay=1.0).autoretain()
1324            else:
1325                ZoomText((('#' + str(player_rank)) if player_rank is not None
1326                          else ba.Lstr(resource='unavailableText')),
1327                         flash=True,
1328                         trail=True,
1329                         scale=1.0 if available else 0.333,
1330                         tilt_translate=0.11,
1331                         h_align='center',
1332                         position=(190 + offs_x, -60),
1333                         maxwidth=200,
1334                         jitter=1.0).autoretain()
1335
1336                Text(ba.Lstr(value='${A}:',
1337                             subs=[('${A}', ba.Lstr(resource='rankText'))]),
1338                     position=(0, 36),
1339                     maxwidth=300,
1340                     transition=Text.Transition.FADE_IN,
1341                     h_align=Text.HAlign.CENTER,
1342                     v_align=Text.VAlign.CENTER,
1343                     transition_delay=0).autoretain()
1344                if best_player_rank is not None:
1345                    Text(ba.Lstr(resource='currentStandingText',
1346                                 fallback_resource='bestRankText',
1347                                 subs=[('${RANK}', str(best_player_rank))]),
1348                         position=(0, -155),
1349                         color=(1, 1, 1, 0.7),
1350                         h_align=Text.HAlign.CENTER,
1351                         transition=Text.Transition.FADE_IN,
1352                         scale=0.7,
1353                         transition_delay=1.0).autoretain()
1354        else:
1355            ZoomText((f'{rating:.1f}' if available else ba.Lstr(
1356                resource='unavailableText')),
1357                     flash=True,
1358                     trail=True,
1359                     scale=0.6 if available else 0.333,
1360                     tilt_translate=0.11,
1361                     h_align='center',
1362                     position=(190 + offs_x, -94),
1363                     maxwidth=200,
1364                     jitter=1.0).autoretain()
1365
1366            if available:
1367                if rating >= 9.5:
1368                    stars = 3
1369                elif rating >= 7.5:
1370                    stars = 2
1371                elif rating > 0.0:
1372                    stars = 1
1373                else:
1374                    stars = 0
1375                star_tex = ba.gettexture('star')
1376                star_x = 135 + offs_x
1377                for _i in range(stars):
1378                    img = ba.NodeActor(
1379                        ba.newnode('image',
1380                                   attrs={
1381                                       'texture': star_tex,
1382                                       'position': (star_x, -16),
1383                                       'scale': (62, 62),
1384                                       'opacity': 1.0,
1385                                       'color': (2.2, 1.2, 0.3),
1386                                       'absolute_scale': True
1387                                   })).autoretain()
1388
1389                    assert img.node
1390                    ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1391                    star_x += 60
1392                for _i in range(3 - stars):
1393                    img = ba.NodeActor(
1394                        ba.newnode('image',
1395                                   attrs={
1396                                       'texture': star_tex,
1397                                       'position': (star_x, -16),
1398                                       'scale': (62, 62),
1399                                       'opacity': 1.0,
1400                                       'color': (0.3, 0.3, 0.3),
1401                                       'absolute_scale': True
1402                                   })).autoretain()
1403                    assert img.node
1404                    ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1405                    star_x += 60
1406
1407                def dostar(count: int, xval: float, offs_y: float,
1408                           score: str) -> None:
1409                    Text(score + ' =',
1410                         position=(xval, -64 + offs_y),
1411                         color=(0.6, 0.6, 0.6, 0.6),
1412                         h_align=Text.HAlign.CENTER,
1413                         v_align=Text.VAlign.CENTER,
1414                         transition=Text.Transition.FADE_IN,
1415                         scale=0.4,
1416                         transition_delay=1.0).autoretain()
1417                    stx = xval + 20
1418                    for _i2 in range(count):
1419                        img2 = ba.NodeActor(
1420                            ba.newnode('image',
1421                                       attrs={
1422                                           'texture': star_tex,
1423                                           'position': (stx, -64 + offs_y),
1424                                           'scale': (12, 12),
1425                                           'opacity': 0.7,
1426                                           'color': (2.2, 1.2, 0.3),
1427                                           'absolute_scale': True
1428                                       })).autoretain()
1429                        assert img2.node
1430                        ba.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
1431                        stx += 13.0
1432
1433                dostar(1, -44 - 30, -112, '0.0')
1434                dostar(2, 10 - 30, -112, '7.5')
1435                dostar(3, 77 - 30, -112, '9.5')
1436            try:
1437                best_rank = self._campaign.getlevel(self._level_name).rating
1438            except Exception:
1439                best_rank = 0.0
1440
1441            if available:
1442                Text(ba.Lstr(
1443                    resource='outOfText',
1444                    subs=[('${RANK}',
1445                           str(int(self._show_info['results']['rank']))),
1446                          ('${ALL}', str(self._show_info['results']['total']))
1447                          ]),
1448                     position=(0, -155 if self._newly_complete else -145),
1449                     color=(1, 1, 1, 0.7),
1450                     h_align=Text.HAlign.CENTER,
1451                     transition=Text.Transition.FADE_IN,
1452                     scale=0.55,
1453                     transition_delay=1.0).autoretain()
1454
1455            new_best = (best_rank > self._old_best_rank and best_rank > 0.0)
1456            was_string = ba.Lstr(value=' ${A}',
1457                                 subs=[('${A}',
1458                                        ba.Lstr(resource='scoreWasText')),
1459                                       ('${COUNT}', str(self._old_best_rank))])
1460            if not self._newly_complete:
1461                Text(ba.Lstr(value='${A}${B}',
1462                             subs=[('${A}',
1463                                    ba.Lstr(resource='newPersonalBestText')),
1464                                   ('${B}', was_string)]) if new_best else
1465                     ba.Lstr(resource='bestRatingText',
1466                             subs=[('${RATING}', str(best_rank))]),
1467                     position=(0, -165),
1468                     color=(1, 1, 1, 0.7),
1469                     flash=new_best,
1470                     h_align=Text.HAlign.CENTER,
1471                     transition=(Text.Transition.IN_RIGHT
1472                                 if new_best else Text.Transition.FADE_IN),
1473                     scale=0.5,
1474                     transition_delay=1.0).autoretain()
1475
1476            Text(ba.Lstr(value='${A}:',
1477                         subs=[('${A}', ba.Lstr(resource='ratingText'))]),
1478                 position=(0, 36),
1479                 maxwidth=300,
1480                 transition=Text.Transition.FADE_IN,
1481                 h_align=Text.HAlign.CENTER,
1482                 v_align=Text.VAlign.CENTER,
1483                 transition_delay=0).autoretain()
1484
1485        ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))
1486        if not error:
1487            ba.timer(0.35, ba.Call(ba.playsound, self.cymbal_sound))
1488
1489    def _show_fail(self) -> None:
1490        ZoomText(ba.Lstr(resource='failText'),
1491                 maxwidth=300,
1492                 flash=False,
1493                 trail=True,
1494                 h_align='center',
1495                 tilt_translate=0.11,
1496                 position=(0, 40),
1497                 jitter=1.0).autoretain()
1498        if self._fail_message is not None:
1499            Text(self._fail_message,
1500                 h_align=Text.HAlign.CENTER,
1501                 position=(0, -130),
1502                 maxwidth=300,
1503                 color=(1, 1, 1, 0.5),
1504                 transition=Text.Transition.FADE_IN,
1505                 transition_delay=1.0).autoretain()
1506        ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))
1507
1508    def _show_score_val(self, offs_x: float) -> None:
1509        assert self._score_type is not None
1510        assert self._score is not None
1511        ZoomText((str(self._score) if self._score_type == 'points' else
1512                  ba.timestring(self._score * 10,
1513                                timeformat=ba.TimeFormat.MILLISECONDS)),
1514                 maxwidth=300,
1515                 flash=True,
1516                 trail=True,
1517                 scale=1.0 if self._score_type == 'points' else 0.6,
1518                 h_align='center',
1519                 tilt_translate=0.11,
1520                 position=(190 + offs_x, 115),
1521                 jitter=1.0).autoretain()
1522        Text(ba.Lstr(
1523            value='${A}:', subs=[('${A}', ba.Lstr(
1524                resource='finalScoreText'))]) if self._score_type == 'points'
1525             else ba.Lstr(value='${A}:',
1526                          subs=[('${A}', ba.Lstr(resource='finalTimeText'))]),
1527             maxwidth=300,
1528             position=(0, 200),
1529             transition=Text.Transition.FADE_IN,
1530             h_align=Text.HAlign.CENTER,
1531             v_align=Text.VAlign.CENTER,
1532             transition_delay=0).autoretain()
1533        ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))
class CoopScoreScreen(ba._activity.Activity[ba._player.Player, ba._team.Team]):
  23class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
  24    """Score screen showing the results of a cooperative game."""
  25
  26    def __init__(self, settings: dict):
  27        # pylint: disable=too-many-statements
  28        super().__init__(settings)
  29
  30        # Keep prev activity alive while we fade in
  31        self.transition_time = 0.5
  32        self.inherits_tint = True
  33        self.inherits_vr_camera_offset = True
  34        self.inherits_music = True
  35        self.use_fixed_vr_overlay = True
  36
  37        self._do_new_rating: bool = self.session.tournament_id is not None
  38
  39        self._score_display_sound = ba.getsound('scoreHit01')
  40        self._score_display_sound_small = ba.getsound('scoreHit02')
  41        self.drum_roll_sound = ba.getsound('drumRoll')
  42        self.cymbal_sound = ba.getsound('cymbal')
  43
  44        # These get used in UI bits so need to load them in the UI context.
  45        with ba.Context('ui'):
  46            self._replay_icon_texture = ba.gettexture('replayIcon')
  47            self._menu_icon_texture = ba.gettexture('menuIcon')
  48            self._next_level_icon_texture = ba.gettexture('nextLevelIcon')
  49
  50        self._campaign: ba.Campaign = settings['campaign']
  51
  52        self._have_achievements = bool(
  53            ba.app.ach.achievements_for_coop_level(self._campaign.name + ':' +
  54                                                   settings['level']))
  55
  56        self._account_type = (_ba.get_v1_account_type()
  57                              if _ba.get_v1_account_state() == 'signed_in' else
  58                              None)
  59
  60        self._game_service_icon_color: Sequence[float] | None
  61        self._game_service_achievements_texture: ba.Texture | None
  62        self._game_service_leaderboards_texture: ba.Texture | None
  63
  64        with ba.Context('ui'):
  65            if self._account_type == 'Game Center':
  66                self._game_service_icon_color = (1.0, 1.0, 1.0)
  67                icon = ba.gettexture('gameCenterIcon')
  68                self._game_service_achievements_texture = icon
  69                self._game_service_leaderboards_texture = icon
  70                self._account_has_achievements = True
  71            elif self._account_type == 'Game Circle':
  72                icon = ba.gettexture('gameCircleIcon')
  73                self._game_service_icon_color = (1, 1, 1)
  74                self._game_service_achievements_texture = icon
  75                self._game_service_leaderboards_texture = icon
  76                self._account_has_achievements = True
  77            elif self._account_type == 'Google Play':
  78                self._game_service_icon_color = (0.8, 1.0, 0.6)
  79                self._game_service_achievements_texture = (
  80                    ba.gettexture('googlePlayAchievementsIcon'))
  81                self._game_service_leaderboards_texture = (
  82                    ba.gettexture('googlePlayLeaderboardsIcon'))
  83                self._account_has_achievements = True
  84            else:
  85                self._game_service_icon_color = None
  86                self._game_service_achievements_texture = None
  87                self._game_service_leaderboards_texture = None
  88                self._account_has_achievements = False
  89
  90        self._cashregistersound = ba.getsound('cashRegister')
  91        self._gun_cocking_sound = ba.getsound('gunCocking')
  92        self._dingsound = ba.getsound('ding')
  93        self._score_link: str | None = None
  94        self._root_ui: ba.Widget | None = None
  95        self._background: ba.Actor | None = None
  96        self._old_best_rank = 0.0
  97        self._game_name_str: str | None = None
  98        self._game_config_str: str | None = None
  99
 100        # Ui bits.
 101        self._corner_button_offs: tuple[float, float] | None = None
 102        self._league_rank_button: LeagueRankButton | None = None
 103        self._store_button_instance: StoreButton | None = None
 104        self._restart_button: ba.Widget | None = None
 105        self._update_corner_button_positions_timer: ba.Timer | None = None
 106        self._next_level_error: ba.Actor | None = None
 107
 108        # Score/gameplay bits.
 109        self._was_complete: bool | None = None
 110        self._is_complete: bool | None = None
 111        self._newly_complete: bool | None = None
 112        self._is_more_levels: bool | None = None
 113        self._next_level_name: str | None = None
 114        self._show_friend_scores: bool | None = None
 115        self._show_info: dict[str, Any] | None = None
 116        self._name_str: str | None = None
 117        self._friends_loading_status: ba.Actor | None = None
 118        self._score_loading_status: ba.Actor | None = None
 119        self._tournament_time_remaining: float | None = None
 120        self._tournament_time_remaining_text: Text | None = None
 121        self._tournament_time_remaining_text_timer: ba.Timer | None = None
 122
 123        # Stuff for activity skip by pressing button
 124        self._birth_time = ba.time()
 125        self._min_view_time = 5.0
 126        self._allow_server_transition = False
 127        self._server_transitioning: bool | None = None
 128
 129        self._playerinfos: list[ba.PlayerInfo] = settings['playerinfos']
 130        assert isinstance(self._playerinfos, list)
 131        assert (isinstance(i, ba.PlayerInfo) for i in self._playerinfos)
 132
 133        self._score: int | None = settings['score']
 134        assert isinstance(self._score, (int, type(None)))
 135
 136        self._fail_message: ba.Lstr | None = settings['fail_message']
 137        assert isinstance(self._fail_message, (ba.Lstr, type(None)))
 138
 139        self._begin_time: float | None = None
 140
 141        self._score_order: str
 142        if 'score_order' in settings:
 143            if not settings['score_order'] in ['increasing', 'decreasing']:
 144                raise ValueError('Invalid score order: ' +
 145                                 settings['score_order'])
 146            self._score_order = settings['score_order']
 147        else:
 148            self._score_order = 'increasing'
 149        assert isinstance(self._score_order, str)
 150
 151        self._score_type: str
 152        if 'score_type' in settings:
 153            if not settings['score_type'] in ['points', 'time']:
 154                raise ValueError('Invalid score type: ' +
 155                                 settings['score_type'])
 156            self._score_type = settings['score_type']
 157        else:
 158            self._score_type = 'points'
 159        assert isinstance(self._score_type, str)
 160
 161        self._level_name: str = settings['level']
 162        assert isinstance(self._level_name, str)
 163
 164        self._game_name_str = self._campaign.name + ':' + self._level_name
 165        self._game_config_str = str(len(
 166            self._playerinfos)) + 'p' + self._campaign.getlevel(
 167                self._level_name).get_score_version_string().replace(' ', '_')
 168
 169        # If game-center/etc scores are available we show our friends'
 170        # scores. Otherwise we show our local high scores.
 171        self._show_friend_scores = _ba.game_service_has_leaderboard(
 172            self._game_name_str, self._game_config_str)
 173
 174        try:
 175            self._old_best_rank = self._campaign.getlevel(
 176                self._level_name).rating
 177        except Exception:
 178            self._old_best_rank = 0.0
 179
 180        self._victory: bool = settings['outcome'] == 'victory'
 181
 182    def __del__(self) -> None:
 183        super().__del__()
 184
 185        # If our UI is still up, kill it.
 186        if self._root_ui:
 187            with ba.Context('ui'):
 188                ba.containerwidget(edit=self._root_ui, transition='out_left')
 189
 190    def on_transition_in(self) -> None:
 191        from bastd.actor import background  # FIXME NO BSSTD
 192        ba.set_analytics_screen('Coop Score Screen')
 193        super().on_transition_in()
 194        self._background = background.Background(fade_time=0.45,
 195                                                 start_faded=False,
 196                                                 show_logo=True)
 197
 198    def _ui_menu(self) -> None:
 199        from bastd.ui import specialoffer
 200        if specialoffer.show_offer():
 201            return
 202        ba.containerwidget(edit=self._root_ui, transition='out_left')
 203        with ba.Context(self):
 204            ba.timer(0.1, ba.Call(ba.WeakCall(self.session.end)))
 205
 206    def _ui_restart(self) -> None:
 207        from bastd.ui.tournamententry import TournamentEntryWindow
 208        from bastd.ui import specialoffer
 209        if specialoffer.show_offer():
 210            return
 211
 212        # If we're in a tournament and it looks like there's no time left,
 213        # disallow.
 214        if self.session.tournament_id is not None:
 215            if self._tournament_time_remaining is None:
 216                ba.screenmessage(
 217                    ba.Lstr(resource='tournamentCheckingStateText'),
 218                    color=(1, 0, 0))
 219                ba.playsound(ba.getsound('error'))
 220                return
 221            if self._tournament_time_remaining <= 0:
 222                ba.screenmessage(ba.Lstr(resource='tournamentEndedText'),
 223                                 color=(1, 0, 0))
 224                ba.playsound(ba.getsound('error'))
 225                return
 226
 227        # If there are currently fewer players than our session min,
 228        # don't allow.
 229        if len(self.players) < self.session.min_players:
 230            ba.screenmessage(ba.Lstr(resource='notEnoughPlayersRemainingText'),
 231                             color=(1, 0, 0))
 232            ba.playsound(ba.getsound('error'))
 233            return
 234
 235        self._campaign.set_selected_level(self._level_name)
 236
 237        # If this is a tournament, go back to the tournament-entry UI
 238        # otherwise just hop back in.
 239        tournament_id = self.session.tournament_id
 240        if tournament_id is not None:
 241            assert self._restart_button is not None
 242            TournamentEntryWindow(
 243                tournament_id=tournament_id,
 244                tournament_activity=self,
 245                position=self._restart_button.get_screen_space_center())
 246        else:
 247            ba.containerwidget(edit=self._root_ui, transition='out_left')
 248            self.can_show_ad_on_death = True
 249            with ba.Context(self):
 250                self.end({'outcome': 'restart'})
 251
 252    def _ui_next(self) -> None:
 253        from bastd.ui.specialoffer import show_offer
 254        if show_offer():
 255            return
 256
 257        # If we didn't just complete this level but are choosing to play the
 258        # next one, set it as current (this won't happen otherwise).
 259        if (self._is_complete and self._is_more_levels
 260                and not self._newly_complete):
 261            assert self._next_level_name is not None
 262            self._campaign.set_selected_level(self._next_level_name)
 263        ba.containerwidget(edit=self._root_ui, transition='out_left')
 264        with ba.Context(self):
 265            self.end({'outcome': 'next_level'})
 266
 267    def _ui_gc(self) -> None:
 268        _ba.show_online_score_ui('leaderboard',
 269                                 game=self._game_name_str,
 270                                 game_version=self._game_config_str)
 271
 272    def _ui_show_achievements(self) -> None:
 273        _ba.show_online_score_ui('achievements')
 274
 275    def _ui_worlds_best(self) -> None:
 276        if self._score_link is None:
 277            ba.playsound(ba.getsound('error'))
 278            ba.screenmessage(ba.Lstr(resource='scoreListUnavailableText'),
 279                             color=(1, 0.5, 0))
 280        else:
 281            ba.open_url(self._score_link)
 282
 283    def _ui_error(self) -> None:
 284        with ba.Context(self):
 285            self._next_level_error = Text(
 286                ba.Lstr(resource='completeThisLevelToProceedText'),
 287                flash=True,
 288                maxwidth=360,
 289                scale=0.54,
 290                h_align=Text.HAlign.CENTER,
 291                color=(0.5, 0.7, 0.5, 1),
 292                position=(300, -235))
 293            ba.playsound(ba.getsound('error'))
 294            ba.timer(
 295                2.0,
 296                ba.WeakCall(self._next_level_error.handlemessage,
 297                            ba.DieMessage()))
 298
 299    def _should_show_worlds_best_button(self) -> bool:
 300        # Link is too complicated to display with no browser.
 301        return ba.is_browser_likely_available()
 302
 303    def request_ui(self) -> None:
 304        """Set up a callback to show our UI at the next opportune time."""
 305        # We don't want to just show our UI in case the user already has the
 306        # main menu up, so instead we add a callback for when the menu
 307        # closes; if we're still alive, we'll come up then.
 308        # If there's no main menu this gets called immediately.
 309        ba.app.add_main_menu_close_callback(ba.WeakCall(self.show_ui))
 310
 311    def show_ui(self) -> None:
 312        """Show the UI for restarting, playing the next Level, etc."""
 313        # pylint: disable=too-many-locals
 314        from bastd.ui.store.button import StoreButton
 315        from bastd.ui.league.rankbutton import LeagueRankButton
 316
 317        delay = 0.7 if (self._score is not None) else 0.0
 318
 319        # If there's no players left in the game, lets not show the UI
 320        # (that would allow restarting the game with zero players, etc).
 321        if not self.players:
 322            return
 323
 324        rootc = self._root_ui = ba.containerwidget(size=(0, 0),
 325                                                   transition='in_right')
 326
 327        h_offs = 7.0
 328        v_offs = -280.0
 329
 330        # We wanna prevent controllers users from popping up browsers
 331        # or game-center widgets in cases where they can't easily get back
 332        # to the game (like on mac).
 333        can_select_extra_buttons = ba.app.platform == 'android'
 334
 335        _ba.set_ui_input_device(None)  # Menu is up for grabs.
 336
 337        if self._show_friend_scores:
 338            ba.buttonwidget(parent=rootc,
 339                            color=(0.45, 0.4, 0.5),
 340                            position=(h_offs - 520, v_offs + 480),
 341                            size=(300, 60),
 342                            label=ba.Lstr(resource='topFriendsText'),
 343                            on_activate_call=ba.WeakCall(self._ui_gc),
 344                            transition_delay=delay + 0.5,
 345                            icon=self._game_service_leaderboards_texture,
 346                            icon_color=self._game_service_icon_color,
 347                            autoselect=True,
 348                            selectable=can_select_extra_buttons)
 349
 350        if self._have_achievements and self._account_has_achievements:
 351            ba.buttonwidget(parent=rootc,
 352                            color=(0.45, 0.4, 0.5),
 353                            position=(h_offs - 520, v_offs + 450 - 235 + 40),
 354                            size=(300, 60),
 355                            label=ba.Lstr(resource='achievementsText'),
 356                            on_activate_call=ba.WeakCall(
 357                                self._ui_show_achievements),
 358                            transition_delay=delay + 1.5,
 359                            icon=self._game_service_achievements_texture,
 360                            icon_color=self._game_service_icon_color,
 361                            autoselect=True,
 362                            selectable=can_select_extra_buttons)
 363
 364        if self._should_show_worlds_best_button():
 365            ba.buttonwidget(
 366                parent=rootc,
 367                color=(0.45, 0.4, 0.5),
 368                position=(160, v_offs + 480),
 369                size=(350, 62),
 370                label=ba.Lstr(resource='tournamentStandingsText')
 371                if self.session.tournament_id is not None else ba.Lstr(
 372                    resource='worldsBestScoresText') if self._score_type
 373                == 'points' else ba.Lstr(resource='worldsBestTimesText'),
 374                autoselect=True,
 375                on_activate_call=ba.WeakCall(self._ui_worlds_best),
 376                transition_delay=delay + 1.9,
 377                selectable=can_select_extra_buttons)
 378        else:
 379            pass
 380
 381        show_next_button = self._is_more_levels and not (ba.app.demo_mode
 382                                                         or ba.app.arcade_mode)
 383
 384        if not show_next_button:
 385            h_offs += 70
 386
 387        menu_button = ba.buttonwidget(parent=rootc,
 388                                      autoselect=True,
 389                                      position=(h_offs - 130 - 60, v_offs),
 390                                      size=(110, 85),
 391                                      label='',
 392                                      on_activate_call=ba.WeakCall(
 393                                          self._ui_menu))
 394        ba.imagewidget(parent=rootc,
 395                       draw_controller=menu_button,
 396                       position=(h_offs - 130 - 60 + 22, v_offs + 14),
 397                       size=(60, 60),
 398                       texture=self._menu_icon_texture,
 399                       opacity=0.8)
 400        self._restart_button = restart_button = ba.buttonwidget(
 401            parent=rootc,
 402            autoselect=True,
 403            position=(h_offs - 60, v_offs),
 404            size=(110, 85),
 405            label='',
 406            on_activate_call=ba.WeakCall(self._ui_restart))
 407        ba.imagewidget(parent=rootc,
 408                       draw_controller=restart_button,
 409                       position=(h_offs - 60 + 19, v_offs + 7),
 410                       size=(70, 70),
 411                       texture=self._replay_icon_texture,
 412                       opacity=0.8)
 413
 414        next_button: ba.Widget | None = None
 415
 416        # Our 'next' button is disabled if we haven't unlocked the next
 417        # level yet and invisible if there is none.
 418        if show_next_button:
 419            if self._is_complete:
 420                call = ba.WeakCall(self._ui_next)
 421                button_sound = True
 422                image_opacity = 0.8
 423                color = None
 424            else:
 425                call = ba.WeakCall(self._ui_error)
 426                button_sound = False
 427                image_opacity = 0.2
 428                color = (0.3, 0.3, 0.3)
 429            next_button = ba.buttonwidget(parent=rootc,
 430                                          autoselect=True,
 431                                          position=(h_offs + 130 - 60, v_offs),
 432                                          size=(110, 85),
 433                                          label='',
 434                                          on_activate_call=call,
 435                                          color=color,
 436                                          enable_sound=button_sound)
 437            ba.imagewidget(parent=rootc,
 438                           draw_controller=next_button,
 439                           position=(h_offs + 130 - 60 + 12, v_offs + 5),
 440                           size=(80, 80),
 441                           texture=self._next_level_icon_texture,
 442                           opacity=image_opacity)
 443
 444        x_offs_extra = 0 if show_next_button else -100
 445        self._corner_button_offs = (h_offs + 300.0 + 100.0 + x_offs_extra,
 446                                    v_offs + 560.0)
 447
 448        if ba.app.demo_mode or ba.app.arcade_mode:
 449            self._league_rank_button = None
 450            self._store_button_instance = None
 451        else:
 452            self._league_rank_button = LeagueRankButton(
 453                parent=rootc,
 454                position=(h_offs + 300 + 100 + x_offs_extra, v_offs + 560),
 455                size=(100, 60),
 456                scale=0.9,
 457                color=(0.4, 0.4, 0.9),
 458                textcolor=(0.9, 0.9, 2.0),
 459                transition_delay=0.0,
 460                smooth_update_delay=5.0)
 461            self._store_button_instance = StoreButton(
 462                parent=rootc,
 463                position=(h_offs + 400 + 100 + x_offs_extra, v_offs + 560),
 464                show_tickets=True,
 465                sale_scale=0.85,
 466                size=(100, 60),
 467                scale=0.9,
 468                button_type='square',
 469                color=(0.35, 0.25, 0.45),
 470                textcolor=(0.9, 0.7, 1.0),
 471                transition_delay=0.0)
 472
 473        ba.containerwidget(edit=rootc,
 474                           selected_child=next_button if
 475                           (self._newly_complete and self._victory
 476                            and show_next_button) else restart_button,
 477                           on_cancel_call=menu_button.activate)
 478
 479        self._update_corner_button_positions()
 480        self._update_corner_button_positions_timer = ba.Timer(
 481            1.0,
 482            ba.WeakCall(self._update_corner_button_positions),
 483            repeat=True,
 484            timetype=ba.TimeType.REAL)
 485
 486    def _update_corner_button_positions(self) -> None:
 487        offs = -55 if _ba.is_party_icon_visible() else 0
 488        assert self._corner_button_offs is not None
 489        pos_x = self._corner_button_offs[0] + offs
 490        pos_y = self._corner_button_offs[1]
 491        if self._league_rank_button is not None:
 492            self._league_rank_button.set_position((pos_x, pos_y))
 493        if self._store_button_instance is not None:
 494            self._store_button_instance.set_position((pos_x + 100, pos_y))
 495
 496    def _player_press(self) -> None:
 497        # (Only for headless builds).
 498
 499        # If this activity is a good 'end point', ask server-mode just once if
 500        # it wants to do anything special like switch sessions or kill the app.
 501        if (self._allow_server_transition and _ba.app.server is not None
 502                and self._server_transitioning is None):
 503            self._server_transitioning = _ba.app.server.handle_transition()
 504            assert isinstance(self._server_transitioning, bool)
 505
 506        # If server-mode is handling this, don't do anything ourself.
 507        if self._server_transitioning is True:
 508            return
 509
 510        # Otherwise restart current level.
 511        self._campaign.set_selected_level(self._level_name)
 512        with ba.Context(self):
 513            self.end({'outcome': 'restart'})
 514
 515    def _safe_assign(self, player: ba.Player) -> None:
 516        # (Only for headless builds).
 517
 518        # Just to be extra careful, don't assign if we're transitioning out.
 519        # (though theoretically that should be ok).
 520        if not self.is_transitioning_out() and player:
 521            player.assigninput(
 522                (ba.InputType.JUMP_PRESS, ba.InputType.PUNCH_PRESS,
 523                 ba.InputType.BOMB_PRESS, ba.InputType.PICK_UP_PRESS),
 524                self._player_press)
 525
 526    def on_player_join(self, player: ba.Player) -> None:
 527        super().on_player_join(player)
 528
 529        if ba.app.server is not None:
 530            # Host can't press retry button, so anyone can do it instead.
 531            time_till_assign = max(
 532                0, self._birth_time + self._min_view_time - _ba.time())
 533
 534            ba.timer(time_till_assign, ba.WeakCall(self._safe_assign, player))
 535
 536    def on_begin(self) -> None:
 537        # FIXME: Clean this up.
 538        # pylint: disable=too-many-statements
 539        # pylint: disable=too-many-branches
 540        # pylint: disable=too-many-locals
 541        super().on_begin()
 542
 543        self._begin_time = ba.time()
 544
 545        # Calc whether the level is complete and other stuff.
 546        levels = self._campaign.levels
 547        level = self._campaign.getlevel(self._level_name)
 548        self._was_complete = level.complete
 549        self._is_complete = (self._was_complete or self._victory)
 550        self._newly_complete = (self._is_complete and not self._was_complete)
 551        self._is_more_levels = ((level.index < len(levels) - 1)
 552                                and self._campaign.sequential)
 553
 554        # Any time we complete a level, set the next one as unlocked.
 555        if self._is_complete and self._is_more_levels:
 556            _ba.add_transaction({
 557                'type': 'COMPLETE_LEVEL',
 558                'campaign': self._campaign.name,
 559                'level': self._level_name
 560            })
 561            self._next_level_name = levels[level.index + 1].name
 562
 563            # If this is the first time we completed it, set the next one
 564            # as current.
 565            if self._newly_complete:
 566                cfg = ba.app.config
 567                cfg['Selected Coop Game'] = (self._campaign.name + ':' +
 568                                             self._next_level_name)
 569                cfg.commit()
 570                self._campaign.set_selected_level(self._next_level_name)
 571
 572        ba.timer(1.0, ba.WeakCall(self.request_ui))
 573
 574        if (self._is_complete and self._victory and self._is_more_levels
 575                and not (ba.app.demo_mode or ba.app.arcade_mode)):
 576            Text(ba.Lstr(value='${A}:\n',
 577                         subs=[('${A}', ba.Lstr(resource='levelUnlockedText'))
 578                               ]) if self._newly_complete else
 579                 ba.Lstr(value='${A}:\n',
 580                         subs=[('${A}', ba.Lstr(resource='nextLevelText'))]),
 581                 transition=Text.Transition.IN_RIGHT,
 582                 transition_delay=5.2,
 583                 flash=self._newly_complete,
 584                 scale=0.54,
 585                 h_align=Text.HAlign.CENTER,
 586                 maxwidth=270,
 587                 color=(0.5, 0.7, 0.5, 1),
 588                 position=(270, -235)).autoretain()
 589            assert self._next_level_name is not None
 590            Text(ba.Lstr(translate=('coopLevelNames', self._next_level_name)),
 591                 transition=Text.Transition.IN_RIGHT,
 592                 transition_delay=5.2,
 593                 flash=self._newly_complete,
 594                 scale=0.7,
 595                 h_align=Text.HAlign.CENTER,
 596                 maxwidth=205,
 597                 color=(0.5, 0.7, 0.5, 1),
 598                 position=(270, -255)).autoretain()
 599            if self._newly_complete:
 600                ba.timer(5.2, ba.Call(ba.playsound, self._cashregistersound))
 601                ba.timer(5.2, ba.Call(ba.playsound, self._dingsound))
 602
 603        offs_x = -195
 604        if len(self._playerinfos) > 1:
 605            pstr = ba.Lstr(value='- ${A} -',
 606                           subs=[('${A}',
 607                                  ba.Lstr(resource='multiPlayerCountText',
 608                                          subs=[('${COUNT}',
 609                                                 str(len(self._playerinfos)))
 610                                                ]))])
 611        else:
 612            pstr = ba.Lstr(value='- ${A} -',
 613                           subs=[('${A}',
 614                                  ba.Lstr(resource='singlePlayerCountText'))])
 615        ZoomText(self._campaign.getlevel(self._level_name).displayname,
 616                 maxwidth=800,
 617                 flash=False,
 618                 trail=False,
 619                 color=(0.5, 1, 0.5, 1),
 620                 h_align='center',
 621                 scale=0.4,
 622                 position=(0, 292),
 623                 jitter=1.0).autoretain()
 624        Text(pstr,
 625             maxwidth=300,
 626             transition=Text.Transition.FADE_IN,
 627             scale=0.7,
 628             h_align=Text.HAlign.CENTER,
 629             v_align=Text.VAlign.CENTER,
 630             color=(0.5, 0.7, 0.5, 1),
 631             position=(0, 230)).autoretain()
 632
 633        if ba.app.server is None:
 634            # If we're running in normal non-headless build, show this text
 635            # because only host can continue the game.
 636            adisp = _ba.get_v1_account_display_string()
 637            txt = Text(ba.Lstr(resource='waitingForHostText',
 638                               subs=[('${HOST}', adisp)]),
 639                       maxwidth=300,
 640                       transition=Text.Transition.FADE_IN,
 641                       transition_delay=8.0,
 642                       scale=0.85,
 643                       h_align=Text.HAlign.CENTER,
 644                       v_align=Text.VAlign.CENTER,
 645                       color=(1, 1, 0, 1),
 646                       position=(0, -230)).autoretain()
 647            assert txt.node
 648            txt.node.client_only = True
 649        else:
 650            # In headless build, anyone can continue the game.
 651            sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
 652            Text(sval,
 653                 v_attach=Text.VAttach.BOTTOM,
 654                 h_align=Text.HAlign.CENTER,
 655                 flash=True,
 656                 vr_depth=50,
 657                 position=(0, 60),
 658                 scale=0.8,
 659                 color=(0.5, 0.7, 0.5, 0.5),
 660                 transition=Text.Transition.IN_BOTTOM_SLOW,
 661                 transition_delay=self._min_view_time).autoretain()
 662
 663        if self._score is not None:
 664            ba.timer(0.35,
 665                     ba.Call(ba.playsound, self._score_display_sound_small))
 666
 667        # Vestigial remain; this stuff should just be instance vars.
 668        self._show_info = {}
 669
 670        if self._score is not None:
 671            ba.timer(0.8, ba.WeakCall(self._show_score_val, offs_x))
 672        else:
 673            ba.pushcall(ba.WeakCall(self._show_fail))
 674
 675        self._name_str = name_str = ', '.join(
 676            [p.name for p in self._playerinfos])
 677
 678        if self._show_friend_scores:
 679            self._friends_loading_status = Text(
 680                ba.Lstr(value='${A}...',
 681                        subs=[('${A}', ba.Lstr(resource='loadingText'))]),
 682                position=(-405, 150 + 30),
 683                color=(1, 1, 1, 0.4),
 684                transition=Text.Transition.FADE_IN,
 685                scale=0.7,
 686                transition_delay=2.0)
 687        self._score_loading_status = Text(ba.Lstr(
 688            value='${A}...', subs=[('${A}', ba.Lstr(resource='loadingText'))]),
 689                                          position=(280, 150 + 30),
 690                                          color=(1, 1, 1, 0.4),
 691                                          transition=Text.Transition.FADE_IN,
 692                                          scale=0.7,
 693                                          transition_delay=2.0)
 694
 695        if self._score is not None:
 696            ba.timer(0.4, ba.WeakCall(self._play_drumroll))
 697
 698        # Add us to high scores, filter, and store.
 699        our_high_scores_all = self._campaign.getlevel(
 700            self._level_name).get_high_scores()
 701
 702        our_high_scores = our_high_scores_all.setdefault(
 703            str(len(self._playerinfos)) + ' Player', [])
 704
 705        if self._score is not None:
 706            our_score: list | None = [
 707                self._score, {
 708                    'players': [{
 709                        'name': p.name,
 710                        'character': p.character
 711                    } for p in self._playerinfos]
 712                }
 713            ]
 714            our_high_scores.append(our_score)
 715        else:
 716            our_score = None
 717
 718        try:
 719            our_high_scores.sort(reverse=self._score_order == 'increasing',
 720                                 key=lambda x: x[0])
 721        except Exception:
 722            ba.print_exception('Error sorting scores.')
 723            print(f'our_high_scores: {our_high_scores}')
 724
 725        del our_high_scores[10:]
 726
 727        if self._score is not None:
 728            sver = (self._campaign.getlevel(
 729                self._level_name).get_score_version_string())
 730            _ba.add_transaction({
 731                'type': 'SET_LEVEL_LOCAL_HIGH_SCORES',
 732                'campaign': self._campaign.name,
 733                'level': self._level_name,
 734                'scoreVersion': sver,
 735                'scores': our_high_scores_all
 736            })
 737        if _ba.get_v1_account_state() != 'signed_in':
 738            # We expect this only in kiosk mode; complain otherwise.
 739            if not (ba.app.demo_mode or ba.app.arcade_mode):
 740                print('got not-signed-in at score-submit; unexpected')
 741            if self._show_friend_scores:
 742                ba.pushcall(ba.WeakCall(self._got_friend_score_results, None))
 743            ba.pushcall(ba.WeakCall(self._got_score_results, None))
 744        else:
 745            assert self._game_name_str is not None
 746            assert self._game_config_str is not None
 747            _ba.submit_score(self._game_name_str,
 748                             self._game_config_str,
 749                             name_str,
 750                             self._score,
 751                             ba.WeakCall(self._got_score_results),
 752                             ba.WeakCall(self._got_friend_score_results)
 753                             if self._show_friend_scores else None,
 754                             order=self._score_order,
 755                             tournament_id=self.session.tournament_id,
 756                             score_type=self._score_type,
 757                             campaign=self._campaign.name,
 758                             level=self._level_name)
 759
 760        # Apply the transactions we've been adding locally.
 761        _ba.run_transactions()
 762
 763        # If we're not doing the world's-best button, just show a title
 764        # instead.
 765        ts_height = 300
 766        ts_h_offs = 210
 767        v_offs = 40
 768        txt = Text(ba.Lstr(resource='tournamentStandingsText')
 769                   if self.session.tournament_id is not None else ba.Lstr(
 770                       resource='worldsBestScoresText') if self._score_type
 771                   == 'points' else ba.Lstr(resource='worldsBestTimesText'),
 772                   maxwidth=210,
 773                   position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 774                   transition=Text.Transition.IN_LEFT,
 775                   v_align=Text.VAlign.CENTER,
 776                   scale=1.2,
 777                   transition_delay=2.2).autoretain()
 778
 779        # If we've got a button on the server, only show this on clients.
 780        if self._should_show_worlds_best_button():
 781            assert txt.node
 782            txt.node.client_only = True
 783
 784        # If we have no friend scores, display local best scores.
 785        if self._show_friend_scores:
 786
 787            # Host has a button, so we need client-only text.
 788            ts_height = 300
 789            ts_h_offs = -480
 790            v_offs = 40
 791            txt = Text(ba.Lstr(resource='topFriendsText'),
 792                       maxwidth=210,
 793                       position=(ts_h_offs - 10,
 794                                 ts_height / 2 + 25 + v_offs + 20),
 795                       transition=Text.Transition.IN_RIGHT,
 796                       v_align=Text.VAlign.CENTER,
 797                       scale=1.2,
 798                       transition_delay=1.8).autoretain()
 799            assert txt.node
 800            txt.node.client_only = True
 801        else:
 802
 803            ts_height = 300
 804            ts_h_offs = -480
 805            v_offs = 40
 806            Text(ba.Lstr(resource='yourBestScoresText') if self._score_type
 807                 == 'points' else ba.Lstr(resource='yourBestTimesText'),
 808                 maxwidth=210,
 809                 position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 810                 transition=Text.Transition.IN_RIGHT,
 811                 v_align=Text.VAlign.CENTER,
 812                 scale=1.2,
 813                 transition_delay=1.8).autoretain()
 814
 815            display_scores = list(our_high_scores)
 816            display_count = 5
 817
 818            while len(display_scores) < display_count:
 819                display_scores.append((0, None))
 820
 821            showed_ours = False
 822            h_offs_extra = 85 if self._score_type == 'points' else 130
 823            v_offs_extra = 20
 824            v_offs_names = 0
 825            scale = 1.0
 826            p_count = len(self._playerinfos)
 827            h_offs_extra -= 75
 828            if p_count > 1:
 829                h_offs_extra -= 20
 830            if p_count == 2:
 831                scale = 0.9
 832            elif p_count == 3:
 833                scale = 0.65
 834            elif p_count == 4:
 835                scale = 0.5
 836            times: list[tuple[float, float]] = []
 837            for i in range(display_count):
 838                times.insert(random.randrange(0,
 839                                              len(times) + 1),
 840                             (1.9 + i * 0.05, 2.3 + i * 0.05))
 841            for i in range(display_count):
 842                try:
 843                    if display_scores[i][1] is None:
 844                        name_str = '-'
 845                    else:
 846                        # noinspection PyUnresolvedReferences
 847                        name_str = ', '.join([
 848                            p['name'] for p in display_scores[i][1]['players']
 849                        ])
 850                except Exception:
 851                    ba.print_exception(
 852                        f'Error calcing name_str for {display_scores}')
 853                    name_str = '-'
 854                if display_scores[i] == our_score and not showed_ours:
 855                    flash = True
 856                    color0 = (0.6, 0.4, 0.1, 1.0)
 857                    color1 = (0.6, 0.6, 0.6, 1.0)
 858                    tdelay1 = 3.7
 859                    tdelay2 = 3.7
 860                    showed_ours = True
 861                else:
 862                    flash = False
 863                    color0 = (0.6, 0.4, 0.1, 1.0)
 864                    color1 = (0.6, 0.6, 0.6, 1.0)
 865                    tdelay1 = times[i][0]
 866                    tdelay2 = times[i][1]
 867                Text(str(display_scores[i][0]) if self._score_type == 'points'
 868                     else ba.timestring(display_scores[i][0] * 10,
 869                                        timeformat=ba.TimeFormat.MILLISECONDS,
 870                                        suppress_format_warning=True),
 871                     position=(ts_h_offs + 20 + h_offs_extra,
 872                               v_offs_extra + ts_height / 2 + -ts_height *
 873                               (i + 1) / 10 + v_offs + 11.0),
 874                     h_align=Text.HAlign.RIGHT,
 875                     v_align=Text.VAlign.CENTER,
 876                     color=color0,
 877                     flash=flash,
 878                     transition=Text.Transition.IN_RIGHT,
 879                     transition_delay=tdelay1).autoretain()
 880
 881                Text(ba.Lstr(value=name_str),
 882                     position=(ts_h_offs + 35 + h_offs_extra,
 883                               v_offs_extra + ts_height / 2 + -ts_height *
 884                               (i + 1) / 10 + v_offs_names + v_offs + 11.0),
 885                     maxwidth=80.0 + 100.0 * len(self._playerinfos),
 886                     v_align=Text.VAlign.CENTER,
 887                     color=color1,
 888                     flash=flash,
 889                     scale=scale,
 890                     transition=Text.Transition.IN_RIGHT,
 891                     transition_delay=tdelay2).autoretain()
 892
 893        # Show achievements for this level.
 894        ts_height = -150
 895        ts_h_offs = -480
 896        v_offs = 40
 897
 898        # Only make this if we don't have the button
 899        # (never want clients to see it so no need for client-only
 900        # version, etc).
 901        if self._have_achievements:
 902            if not self._account_has_achievements:
 903                Text(ba.Lstr(resource='achievementsText'),
 904                     position=(ts_h_offs - 10,
 905                               ts_height / 2 + 25 + v_offs + 3),
 906                     maxwidth=210,
 907                     host_only=True,
 908                     transition=Text.Transition.IN_RIGHT,
 909                     v_align=Text.VAlign.CENTER,
 910                     scale=1.2,
 911                     transition_delay=2.8).autoretain()
 912
 913            assert self._game_name_str is not None
 914            achievements = ba.app.ach.achievements_for_coop_level(
 915                self._game_name_str)
 916            hval = -455
 917            vval = -100
 918            tdelay = 0.0
 919            for ach in achievements:
 920                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
 921                vval -= 55
 922                tdelay += 0.250
 923
 924        ba.timer(5.0, ba.WeakCall(self._show_tips))
 925
 926    def _play_drumroll(self) -> None:
 927        ba.NodeActor(
 928            ba.newnode('sound',
 929                       attrs={
 930                           'sound': self.drum_roll_sound,
 931                           'positional': False,
 932                           'loop': False
 933                       })).autoretain()
 934
 935    def _got_friend_score_results(self, results: list[Any] | None) -> None:
 936
 937        # FIXME: tidy this up
 938        # pylint: disable=too-many-locals
 939        # pylint: disable=too-many-branches
 940        # pylint: disable=too-many-statements
 941        from efro.util import asserttype
 942        # delay a bit if results come in too fast
 943        assert self._begin_time is not None
 944        base_delay = max(0, 1.9 - (ba.time() - self._begin_time))
 945        ts_height = 300
 946        ts_h_offs = -550
 947        v_offs = 30
 948
 949        # Report in case of error.
 950        if results is None:
 951            self._friends_loading_status = Text(
 952                ba.Lstr(resource='friendScoresUnavailableText'),
 953                maxwidth=330,
 954                position=(-475, 150 + v_offs),
 955                color=(1, 1, 1, 0.4),
 956                transition=Text.Transition.FADE_IN,
 957                transition_delay=base_delay + 0.8,
 958                scale=0.7)
 959            return
 960
 961        self._friends_loading_status = None
 962
 963        # Ok, it looks like we aren't able to reliably get a just-submitted
 964        # result returned in the score list, so we need to look for our score
 965        # in this list and replace it if ours is better or add ours otherwise.
 966        if self._score is not None:
 967            our_score_entry = [self._score, 'Me', True]
 968            for score in results:
 969                if score[2]:
 970                    if self._score_order == 'increasing':
 971                        our_score_entry[0] = max(score[0], self._score)
 972                    else:
 973                        our_score_entry[0] = min(score[0], self._score)
 974                    results.remove(score)
 975                    break
 976            results.append(our_score_entry)
 977            results.sort(reverse=self._score_order == 'increasing',
 978                         key=lambda x: asserttype(x[0], int))
 979
 980        # If we're not submitting our own score, we still want to change the
 981        # name of our own score to 'Me'.
 982        else:
 983            for score in results:
 984                if score[2]:
 985                    score[1] = 'Me'
 986                    break
 987        h_offs_extra = 80 if self._score_type == 'points' else 130
 988        v_offs_extra = 20
 989        v_offs_names = 0
 990        scale = 1.0
 991
 992        # Make sure there's at least 5.
 993        while len(results) < 5:
 994            results.append([0, '-', False])
 995        results = results[:5]
 996        times: list[tuple[float, float]] = []
 997        for i in range(len(results)):
 998            times.insert(random.randrange(0,
 999                                          len(times) + 1),
1000                         (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05))
1001        for i, tval in enumerate(results):
1002            score = int(tval[0])
1003            name_str = tval[1]
1004            is_me = tval[2]
1005            if is_me and score == self._score:
1006                flash = True
1007                color0 = (0.6, 0.4, 0.1, 1.0)
1008                color1 = (0.6, 0.6, 0.6, 1.0)
1009                tdelay1 = base_delay + 1.0
1010                tdelay2 = base_delay + 1.0
1011            else:
1012                flash = False
1013                if is_me:
1014                    color0 = (0.6, 0.4, 0.1, 1.0)
1015                    color1 = (0.9, 1.0, 0.9, 1.0)
1016                else:
1017                    color0 = (0.6, 0.4, 0.1, 1.0)
1018                    color1 = (0.6, 0.6, 0.6, 1.0)
1019                tdelay1 = times[i][0]
1020                tdelay2 = times[i][1]
1021            if name_str != '-':
1022                Text(str(score) if self._score_type == 'points' else
1023                     ba.timestring(score * 10,
1024                                   timeformat=ba.TimeFormat.MILLISECONDS),
1025                     position=(ts_h_offs + 20 + h_offs_extra,
1026                               v_offs_extra + ts_height / 2 + -ts_height *
1027                               (i + 1) / 10 + v_offs + 11.0),
1028                     h_align=Text.HAlign.RIGHT,
1029                     v_align=Text.VAlign.CENTER,
1030                     color=color0,
1031                     flash=flash,
1032                     transition=Text.Transition.IN_RIGHT,
1033                     transition_delay=tdelay1).autoretain()
1034            else:
1035                if is_me:
1036                    print('Error: got empty name_str on score result:', tval)
1037
1038            Text(ba.Lstr(value=name_str),
1039                 position=(ts_h_offs + 35 + h_offs_extra,
1040                           v_offs_extra + ts_height / 2 + -ts_height *
1041                           (i + 1) / 10 + v_offs_names + v_offs + 11.0),
1042                 color=color1,
1043                 maxwidth=160.0,
1044                 v_align=Text.VAlign.CENTER,
1045                 flash=flash,
1046                 scale=scale,
1047                 transition=Text.Transition.IN_RIGHT,
1048                 transition_delay=tdelay2).autoretain()
1049
1050    def _got_score_results(self, results: dict[str, Any] | None) -> None:
1051
1052        # FIXME: tidy this up
1053        # pylint: disable=too-many-locals
1054        # pylint: disable=too-many-branches
1055        # pylint: disable=too-many-statements
1056
1057        # We need to manually run this in the context of our activity
1058        # and only if we aren't shutting down.
1059        # (really should make the submit_score call handle that stuff itself)
1060        if self.expired:
1061            return
1062        with ba.Context(self):
1063            # Delay a bit if results come in too fast.
1064            assert self._begin_time is not None
1065            base_delay = max(0, 2.7 - (ba.time() - self._begin_time))
1066            v_offs = 20
1067            if results is None:
1068                self._score_loading_status = Text(
1069                    ba.Lstr(resource='worldScoresUnavailableText'),
1070                    position=(230, 150 + v_offs),
1071                    color=(1, 1, 1, 0.4),
1072                    transition=Text.Transition.FADE_IN,
1073                    transition_delay=base_delay + 0.3,
1074                    scale=0.7)
1075            else:
1076                self._score_link = results['link']
1077                assert self._score_link is not None
1078                if not self._score_link.startswith('http://'):
1079                    self._score_link = (_ba.get_master_server_address() + '/' +
1080                                        self._score_link)
1081                self._score_loading_status = None
1082                if 'tournamentSecondsRemaining' in results:
1083                    secs_remaining = results['tournamentSecondsRemaining']
1084                    assert isinstance(secs_remaining, int)
1085                    self._tournament_time_remaining = secs_remaining
1086                    self._tournament_time_remaining_text_timer = ba.Timer(
1087                        1.0,
1088                        ba.WeakCall(
1089                            self._update_tournament_time_remaining_text),
1090                        repeat=True,
1091                        timetype=ba.TimeType.BASE)
1092
1093            assert self._show_info is not None
1094            self._show_info['results'] = results
1095            if results is not None:
1096                if results['tops'] != '':
1097                    self._show_info['tops'] = results['tops']
1098                else:
1099                    self._show_info['tops'] = []
1100            offs_x = -195
1101            available = (self._show_info['results'] is not None)
1102            if self._score is not None:
1103                ba.timer((1.5 + base_delay),
1104                         ba.WeakCall(self._show_world_rank, offs_x),
1105                         timetype=ba.TimeType.BASE)
1106            ts_h_offs = 200
1107            ts_height = 300
1108
1109            # Show world tops.
1110            if available:
1111
1112                # Show the number of games represented by this
1113                # list (except for in tournaments).
1114                if self.session.tournament_id is None:
1115                    Text(ba.Lstr(resource='lastGamesText',
1116                                 subs=[
1117                                     ('${COUNT}',
1118                                      str(self._show_info['results']['total']))
1119                                 ]),
1120                         position=(ts_h_offs - 35 + 95,
1121                                   ts_height / 2 + 6 + v_offs),
1122                         color=(0.4, 0.4, 0.4, 1.0),
1123                         scale=0.7,
1124                         transition=Text.Transition.IN_RIGHT,
1125                         transition_delay=base_delay + 0.3).autoretain()
1126                else:
1127                    v_offs += 20
1128
1129                h_offs_extra = 0
1130                v_offs_names = 0
1131                scale = 1.0
1132                p_count = len(self._playerinfos)
1133                if p_count > 1:
1134                    h_offs_extra -= 40
1135                if self._score_type != 'points':
1136                    h_offs_extra += 60
1137                if p_count == 2:
1138                    scale = 0.9
1139                elif p_count == 3:
1140                    scale = 0.65
1141                elif p_count == 4:
1142                    scale = 0.5
1143
1144                # Make sure there's at least 10.
1145                while len(self._show_info['tops']) < 10:
1146                    self._show_info['tops'].append([0, '-'])
1147
1148                times: list[tuple[float, float]] = []
1149                for i in range(len(self._show_info['tops'])):
1150                    times.insert(
1151                        random.randrange(0,
1152                                         len(times) + 1),
1153                        (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05))
1154                for i, tval in enumerate(self._show_info['tops']):
1155                    score = int(tval[0])
1156                    name_str = tval[1]
1157                    if self._name_str == name_str and self._score == score:
1158                        flash = True
1159                        color0 = (0.6, 0.4, 0.1, 1.0)
1160                        color1 = (0.6, 0.6, 0.6, 1.0)
1161                        tdelay1 = base_delay + 1.0
1162                        tdelay2 = base_delay + 1.0
1163                    else:
1164                        flash = False
1165                        if self._name_str == name_str:
1166                            color0 = (0.6, 0.4, 0.1, 1.0)
1167                            color1 = (0.9, 1.0, 0.9, 1.0)
1168                        else:
1169                            color0 = (0.6, 0.4, 0.1, 1.0)
1170                            color1 = (0.6, 0.6, 0.6, 1.0)
1171                        tdelay1 = times[i][0]
1172                        tdelay2 = times[i][1]
1173
1174                    if name_str != '-':
1175                        Text(str(score) if self._score_type == 'points' else
1176                             ba.timestring(
1177                                 score * 10,
1178                                 timeformat=ba.TimeFormat.MILLISECONDS),
1179                             position=(ts_h_offs + 20 + h_offs_extra,
1180                                       ts_height / 2 + -ts_height *
1181                                       (i + 1) / 10 + v_offs + 11.0),
1182                             h_align=Text.HAlign.RIGHT,
1183                             v_align=Text.VAlign.CENTER,
1184                             color=color0,
1185                             flash=flash,
1186                             transition=Text.Transition.IN_LEFT,
1187                             transition_delay=tdelay1).autoretain()
1188                    Text(ba.Lstr(value=name_str),
1189                         position=(ts_h_offs + 35 + h_offs_extra,
1190                                   ts_height / 2 + -ts_height * (i + 1) / 10 +
1191                                   v_offs_names + v_offs + 11.0),
1192                         maxwidth=80.0 + 100.0 * len(self._playerinfos),
1193                         v_align=Text.VAlign.CENTER,
1194                         color=color1,
1195                         flash=flash,
1196                         scale=scale,
1197                         transition=Text.Transition.IN_LEFT,
1198                         transition_delay=tdelay2).autoretain()
1199
1200    def _show_tips(self) -> None:
1201        from bastd.actor.tipstext import TipsText
1202        TipsText(offs_y=30).autoretain()
1203
1204    def _update_tournament_time_remaining_text(self) -> None:
1205        if self._tournament_time_remaining is None:
1206            return
1207        self._tournament_time_remaining = max(
1208            0, self._tournament_time_remaining - 1)
1209        if self._tournament_time_remaining_text is not None:
1210            val = ba.timestring(self._tournament_time_remaining,
1211                                suppress_format_warning=True,
1212                                centi=False)
1213            self._tournament_time_remaining_text.node.text = val
1214
1215    def _show_world_rank(self, offs_x: float) -> None:
1216        # FIXME: Tidy this up.
1217        # pylint: disable=too-many-locals
1218        # pylint: disable=too-many-branches
1219        # pylint: disable=too-many-statements
1220        from ba.internal import get_tournament_prize_strings
1221        assert self._show_info is not None
1222        available = (self._show_info['results'] is not None)
1223
1224        if available:
1225            error = (self._show_info['results']['error']
1226                     if 'error' in self._show_info['results'] else None)
1227            rank = self._show_info['results']['rank']
1228            total = self._show_info['results']['total']
1229            rating = (10.0 if total == 1 else 10.0 * (1.0 - (float(rank - 1) /
1230                                                             (total - 1))))
1231            player_rank = self._show_info['results']['playerRank']
1232            best_player_rank = self._show_info['results']['bestPlayerRank']
1233        else:
1234            error = False
1235            rating = None
1236            player_rank = None
1237            best_player_rank = None
1238
1239        # If we've got tournament-seconds-remaining, show it.
1240        if self._tournament_time_remaining is not None:
1241            Text(ba.Lstr(resource='coopSelectWindow.timeRemainingText'),
1242                 position=(-360, -70 - 100),
1243                 color=(1, 1, 1, 0.7),
1244                 h_align=Text.HAlign.CENTER,
1245                 v_align=Text.VAlign.CENTER,
1246                 transition=Text.Transition.FADE_IN,
1247                 scale=0.8,
1248                 maxwidth=300,
1249                 transition_delay=2.0).autoretain()
1250            self._tournament_time_remaining_text = Text(
1251                '',
1252                position=(-360, -110 - 100),
1253                color=(1, 1, 1, 0.7),
1254                h_align=Text.HAlign.CENTER,
1255                v_align=Text.VAlign.CENTER,
1256                transition=Text.Transition.FADE_IN,
1257                scale=1.6,
1258                maxwidth=150,
1259                transition_delay=2.0)
1260
1261        # If we're a tournament, show prizes.
1262        try:
1263            tournament_id = self.session.tournament_id
1264            if tournament_id is not None:
1265                if tournament_id in ba.app.accounts_v1.tournament_info:
1266                    tourney_info = ba.app.accounts_v1.tournament_info[
1267                        tournament_id]
1268                    # pylint: disable=unbalanced-tuple-unpacking
1269                    pr1, pv1, pr2, pv2, pr3, pv3 = (
1270                        get_tournament_prize_strings(tourney_info))
1271                    # pylint: enable=unbalanced-tuple-unpacking
1272                    Text(ba.Lstr(resource='coopSelectWindow.prizesText'),
1273                         position=(-360, -70 + 77),
1274                         color=(1, 1, 1, 0.7),
1275                         h_align=Text.HAlign.CENTER,
1276                         v_align=Text.VAlign.CENTER,
1277                         transition=Text.Transition.FADE_IN,
1278                         scale=1.0,
1279                         maxwidth=300,
1280                         transition_delay=2.0).autoretain()
1281                    vval = -107 + 70
1282                    for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)):
1283                        Text(rng,
1284                             position=(-410 + 10, vval),
1285                             color=(1, 1, 1, 0.7),
1286                             h_align=Text.HAlign.RIGHT,
1287                             v_align=Text.VAlign.CENTER,
1288                             transition=Text.Transition.FADE_IN,
1289                             scale=0.6,
1290                             maxwidth=300,
1291                             transition_delay=2.0).autoretain()
1292                        Text(val,
1293                             position=(-390 + 10, vval),
1294                             color=(0.7, 0.7, 0.7, 1.0),
1295                             h_align=Text.HAlign.LEFT,
1296                             v_align=Text.VAlign.CENTER,
1297                             transition=Text.Transition.FADE_IN,
1298                             scale=0.8,
1299                             maxwidth=300,
1300                             transition_delay=2.0).autoretain()
1301                        vval -= 35
1302        except Exception:
1303            ba.print_exception('Error showing prize ranges.')
1304
1305        if self._do_new_rating:
1306            if error:
1307                ZoomText(ba.Lstr(resource='failText'),
1308                         flash=True,
1309                         trail=True,
1310                         scale=1.0 if available else 0.333,
1311                         tilt_translate=0.11,
1312                         h_align='center',
1313                         position=(190 + offs_x, -60),
1314                         maxwidth=200,
1315                         jitter=1.0).autoretain()
1316                Text(ba.Lstr(translate=('serverResponses', error)),
1317                     position=(0, -140),
1318                     color=(1, 1, 1, 0.7),
1319                     h_align=Text.HAlign.CENTER,
1320                     v_align=Text.VAlign.CENTER,
1321                     transition=Text.Transition.FADE_IN,
1322                     scale=0.9,
1323                     maxwidth=400,
1324                     transition_delay=1.0).autoretain()
1325            else:
1326                ZoomText((('#' + str(player_rank)) if player_rank is not None
1327                          else ba.Lstr(resource='unavailableText')),
1328                         flash=True,
1329                         trail=True,
1330                         scale=1.0 if available else 0.333,
1331                         tilt_translate=0.11,
1332                         h_align='center',
1333                         position=(190 + offs_x, -60),
1334                         maxwidth=200,
1335                         jitter=1.0).autoretain()
1336
1337                Text(ba.Lstr(value='${A}:',
1338                             subs=[('${A}', ba.Lstr(resource='rankText'))]),
1339                     position=(0, 36),
1340                     maxwidth=300,
1341                     transition=Text.Transition.FADE_IN,
1342                     h_align=Text.HAlign.CENTER,
1343                     v_align=Text.VAlign.CENTER,
1344                     transition_delay=0).autoretain()
1345                if best_player_rank is not None:
1346                    Text(ba.Lstr(resource='currentStandingText',
1347                                 fallback_resource='bestRankText',
1348                                 subs=[('${RANK}', str(best_player_rank))]),
1349                         position=(0, -155),
1350                         color=(1, 1, 1, 0.7),
1351                         h_align=Text.HAlign.CENTER,
1352                         transition=Text.Transition.FADE_IN,
1353                         scale=0.7,
1354                         transition_delay=1.0).autoretain()
1355        else:
1356            ZoomText((f'{rating:.1f}' if available else ba.Lstr(
1357                resource='unavailableText')),
1358                     flash=True,
1359                     trail=True,
1360                     scale=0.6 if available else 0.333,
1361                     tilt_translate=0.11,
1362                     h_align='center',
1363                     position=(190 + offs_x, -94),
1364                     maxwidth=200,
1365                     jitter=1.0).autoretain()
1366
1367            if available:
1368                if rating >= 9.5:
1369                    stars = 3
1370                elif rating >= 7.5:
1371                    stars = 2
1372                elif rating > 0.0:
1373                    stars = 1
1374                else:
1375                    stars = 0
1376                star_tex = ba.gettexture('star')
1377                star_x = 135 + offs_x
1378                for _i in range(stars):
1379                    img = ba.NodeActor(
1380                        ba.newnode('image',
1381                                   attrs={
1382                                       'texture': star_tex,
1383                                       'position': (star_x, -16),
1384                                       'scale': (62, 62),
1385                                       'opacity': 1.0,
1386                                       'color': (2.2, 1.2, 0.3),
1387                                       'absolute_scale': True
1388                                   })).autoretain()
1389
1390                    assert img.node
1391                    ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1392                    star_x += 60
1393                for _i in range(3 - stars):
1394                    img = ba.NodeActor(
1395                        ba.newnode('image',
1396                                   attrs={
1397                                       'texture': star_tex,
1398                                       'position': (star_x, -16),
1399                                       'scale': (62, 62),
1400                                       'opacity': 1.0,
1401                                       'color': (0.3, 0.3, 0.3),
1402                                       'absolute_scale': True
1403                                   })).autoretain()
1404                    assert img.node
1405                    ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1406                    star_x += 60
1407
1408                def dostar(count: int, xval: float, offs_y: float,
1409                           score: str) -> None:
1410                    Text(score + ' =',
1411                         position=(xval, -64 + offs_y),
1412                         color=(0.6, 0.6, 0.6, 0.6),
1413                         h_align=Text.HAlign.CENTER,
1414                         v_align=Text.VAlign.CENTER,
1415                         transition=Text.Transition.FADE_IN,
1416                         scale=0.4,
1417                         transition_delay=1.0).autoretain()
1418                    stx = xval + 20
1419                    for _i2 in range(count):
1420                        img2 = ba.NodeActor(
1421                            ba.newnode('image',
1422                                       attrs={
1423                                           'texture': star_tex,
1424                                           'position': (stx, -64 + offs_y),
1425                                           'scale': (12, 12),
1426                                           'opacity': 0.7,
1427                                           'color': (2.2, 1.2, 0.3),
1428                                           'absolute_scale': True
1429                                       })).autoretain()
1430                        assert img2.node
1431                        ba.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
1432                        stx += 13.0
1433
1434                dostar(1, -44 - 30, -112, '0.0')
1435                dostar(2, 10 - 30, -112, '7.5')
1436                dostar(3, 77 - 30, -112, '9.5')
1437            try:
1438                best_rank = self._campaign.getlevel(self._level_name).rating
1439            except Exception:
1440                best_rank = 0.0
1441
1442            if available:
1443                Text(ba.Lstr(
1444                    resource='outOfText',
1445                    subs=[('${RANK}',
1446                           str(int(self._show_info['results']['rank']))),
1447                          ('${ALL}', str(self._show_info['results']['total']))
1448                          ]),
1449                     position=(0, -155 if self._newly_complete else -145),
1450                     color=(1, 1, 1, 0.7),
1451                     h_align=Text.HAlign.CENTER,
1452                     transition=Text.Transition.FADE_IN,
1453                     scale=0.55,
1454                     transition_delay=1.0).autoretain()
1455
1456            new_best = (best_rank > self._old_best_rank and best_rank > 0.0)
1457            was_string = ba.Lstr(value=' ${A}',
1458                                 subs=[('${A}',
1459                                        ba.Lstr(resource='scoreWasText')),
1460                                       ('${COUNT}', str(self._old_best_rank))])
1461            if not self._newly_complete:
1462                Text(ba.Lstr(value='${A}${B}',
1463                             subs=[('${A}',
1464                                    ba.Lstr(resource='newPersonalBestText')),
1465                                   ('${B}', was_string)]) if new_best else
1466                     ba.Lstr(resource='bestRatingText',
1467                             subs=[('${RATING}', str(best_rank))]),
1468                     position=(0, -165),
1469                     color=(1, 1, 1, 0.7),
1470                     flash=new_best,
1471                     h_align=Text.HAlign.CENTER,
1472                     transition=(Text.Transition.IN_RIGHT
1473                                 if new_best else Text.Transition.FADE_IN),
1474                     scale=0.5,
1475                     transition_delay=1.0).autoretain()
1476
1477            Text(ba.Lstr(value='${A}:',
1478                         subs=[('${A}', ba.Lstr(resource='ratingText'))]),
1479                 position=(0, 36),
1480                 maxwidth=300,
1481                 transition=Text.Transition.FADE_IN,
1482                 h_align=Text.HAlign.CENTER,
1483                 v_align=Text.VAlign.CENTER,
1484                 transition_delay=0).autoretain()
1485
1486        ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))
1487        if not error:
1488            ba.timer(0.35, ba.Call(ba.playsound, self.cymbal_sound))
1489
1490    def _show_fail(self) -> None:
1491        ZoomText(ba.Lstr(resource='failText'),
1492                 maxwidth=300,
1493                 flash=False,
1494                 trail=True,
1495                 h_align='center',
1496                 tilt_translate=0.11,
1497                 position=(0, 40),
1498                 jitter=1.0).autoretain()
1499        if self._fail_message is not None:
1500            Text(self._fail_message,
1501                 h_align=Text.HAlign.CENTER,
1502                 position=(0, -130),
1503                 maxwidth=300,
1504                 color=(1, 1, 1, 0.5),
1505                 transition=Text.Transition.FADE_IN,
1506                 transition_delay=1.0).autoretain()
1507        ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))
1508
1509    def _show_score_val(self, offs_x: float) -> None:
1510        assert self._score_type is not None
1511        assert self._score is not None
1512        ZoomText((str(self._score) if self._score_type == 'points' else
1513                  ba.timestring(self._score * 10,
1514                                timeformat=ba.TimeFormat.MILLISECONDS)),
1515                 maxwidth=300,
1516                 flash=True,
1517                 trail=True,
1518                 scale=1.0 if self._score_type == 'points' else 0.6,
1519                 h_align='center',
1520                 tilt_translate=0.11,
1521                 position=(190 + offs_x, 115),
1522                 jitter=1.0).autoretain()
1523        Text(ba.Lstr(
1524            value='${A}:', subs=[('${A}', ba.Lstr(
1525                resource='finalScoreText'))]) if self._score_type == 'points'
1526             else ba.Lstr(value='${A}:',
1527                          subs=[('${A}', ba.Lstr(resource='finalTimeText'))]),
1528             maxwidth=300,
1529             position=(0, 200),
1530             transition=Text.Transition.FADE_IN,
1531             h_align=Text.HAlign.CENTER,
1532             v_align=Text.VAlign.CENTER,
1533             transition_delay=0).autoretain()
1534        ba.timer(0.35, ba.Call(ba.playsound, self._score_display_sound))

Score screen showing the results of a cooperative game.

CoopScoreScreen(settings: dict)
 26    def __init__(self, settings: dict):
 27        # pylint: disable=too-many-statements
 28        super().__init__(settings)
 29
 30        # Keep prev activity alive while we fade in
 31        self.transition_time = 0.5
 32        self.inherits_tint = True
 33        self.inherits_vr_camera_offset = True
 34        self.inherits_music = True
 35        self.use_fixed_vr_overlay = True
 36
 37        self._do_new_rating: bool = self.session.tournament_id is not None
 38
 39        self._score_display_sound = ba.getsound('scoreHit01')
 40        self._score_display_sound_small = ba.getsound('scoreHit02')
 41        self.drum_roll_sound = ba.getsound('drumRoll')
 42        self.cymbal_sound = ba.getsound('cymbal')
 43
 44        # These get used in UI bits so need to load them in the UI context.
 45        with ba.Context('ui'):
 46            self._replay_icon_texture = ba.gettexture('replayIcon')
 47            self._menu_icon_texture = ba.gettexture('menuIcon')
 48            self._next_level_icon_texture = ba.gettexture('nextLevelIcon')
 49
 50        self._campaign: ba.Campaign = settings['campaign']
 51
 52        self._have_achievements = bool(
 53            ba.app.ach.achievements_for_coop_level(self._campaign.name + ':' +
 54                                                   settings['level']))
 55
 56        self._account_type = (_ba.get_v1_account_type()
 57                              if _ba.get_v1_account_state() == 'signed_in' else
 58                              None)
 59
 60        self._game_service_icon_color: Sequence[float] | None
 61        self._game_service_achievements_texture: ba.Texture | None
 62        self._game_service_leaderboards_texture: ba.Texture | None
 63
 64        with ba.Context('ui'):
 65            if self._account_type == 'Game Center':
 66                self._game_service_icon_color = (1.0, 1.0, 1.0)
 67                icon = ba.gettexture('gameCenterIcon')
 68                self._game_service_achievements_texture = icon
 69                self._game_service_leaderboards_texture = icon
 70                self._account_has_achievements = True
 71            elif self._account_type == 'Game Circle':
 72                icon = ba.gettexture('gameCircleIcon')
 73                self._game_service_icon_color = (1, 1, 1)
 74                self._game_service_achievements_texture = icon
 75                self._game_service_leaderboards_texture = icon
 76                self._account_has_achievements = True
 77            elif self._account_type == 'Google Play':
 78                self._game_service_icon_color = (0.8, 1.0, 0.6)
 79                self._game_service_achievements_texture = (
 80                    ba.gettexture('googlePlayAchievementsIcon'))
 81                self._game_service_leaderboards_texture = (
 82                    ba.gettexture('googlePlayLeaderboardsIcon'))
 83                self._account_has_achievements = True
 84            else:
 85                self._game_service_icon_color = None
 86                self._game_service_achievements_texture = None
 87                self._game_service_leaderboards_texture = None
 88                self._account_has_achievements = False
 89
 90        self._cashregistersound = ba.getsound('cashRegister')
 91        self._gun_cocking_sound = ba.getsound('gunCocking')
 92        self._dingsound = ba.getsound('ding')
 93        self._score_link: str | None = None
 94        self._root_ui: ba.Widget | None = None
 95        self._background: ba.Actor | None = None
 96        self._old_best_rank = 0.0
 97        self._game_name_str: str | None = None
 98        self._game_config_str: str | None = None
 99
100        # Ui bits.
101        self._corner_button_offs: tuple[float, float] | None = None
102        self._league_rank_button: LeagueRankButton | None = None
103        self._store_button_instance: StoreButton | None = None
104        self._restart_button: ba.Widget | None = None
105        self._update_corner_button_positions_timer: ba.Timer | None = None
106        self._next_level_error: ba.Actor | None = None
107
108        # Score/gameplay bits.
109        self._was_complete: bool | None = None
110        self._is_complete: bool | None = None
111        self._newly_complete: bool | None = None
112        self._is_more_levels: bool | None = None
113        self._next_level_name: str | None = None
114        self._show_friend_scores: bool | None = None
115        self._show_info: dict[str, Any] | None = None
116        self._name_str: str | None = None
117        self._friends_loading_status: ba.Actor | None = None
118        self._score_loading_status: ba.Actor | None = None
119        self._tournament_time_remaining: float | None = None
120        self._tournament_time_remaining_text: Text | None = None
121        self._tournament_time_remaining_text_timer: ba.Timer | None = None
122
123        # Stuff for activity skip by pressing button
124        self._birth_time = ba.time()
125        self._min_view_time = 5.0
126        self._allow_server_transition = False
127        self._server_transitioning: bool | None = None
128
129        self._playerinfos: list[ba.PlayerInfo] = settings['playerinfos']
130        assert isinstance(self._playerinfos, list)
131        assert (isinstance(i, ba.PlayerInfo) for i in self._playerinfos)
132
133        self._score: int | None = settings['score']
134        assert isinstance(self._score, (int, type(None)))
135
136        self._fail_message: ba.Lstr | None = settings['fail_message']
137        assert isinstance(self._fail_message, (ba.Lstr, type(None)))
138
139        self._begin_time: float | None = None
140
141        self._score_order: str
142        if 'score_order' in settings:
143            if not settings['score_order'] in ['increasing', 'decreasing']:
144                raise ValueError('Invalid score order: ' +
145                                 settings['score_order'])
146            self._score_order = settings['score_order']
147        else:
148            self._score_order = 'increasing'
149        assert isinstance(self._score_order, str)
150
151        self._score_type: str
152        if 'score_type' in settings:
153            if not settings['score_type'] in ['points', 'time']:
154                raise ValueError('Invalid score type: ' +
155                                 settings['score_type'])
156            self._score_type = settings['score_type']
157        else:
158            self._score_type = 'points'
159        assert isinstance(self._score_type, str)
160
161        self._level_name: str = settings['level']
162        assert isinstance(self._level_name, str)
163
164        self._game_name_str = self._campaign.name + ':' + self._level_name
165        self._game_config_str = str(len(
166            self._playerinfos)) + 'p' + self._campaign.getlevel(
167                self._level_name).get_score_version_string().replace(' ', '_')
168
169        # If game-center/etc scores are available we show our friends'
170        # scores. Otherwise we show our local high scores.
171        self._show_friend_scores = _ba.game_service_has_leaderboard(
172            self._game_name_str, self._game_config_str)
173
174        try:
175            self._old_best_rank = self._campaign.getlevel(
176                self._level_name).rating
177        except Exception:
178            self._old_best_rank = 0.0
179
180        self._victory: bool = settings['outcome'] == 'victory'

Creates an Activity in the current ba.Session.

The activity will not be actually run until ba.Session.setactivity is called. 'settings' should be a dict of key/value pairs specific to the activity.

Activities should preload as much of their media/etc as possible in their constructor, but none of it should actually be used until they are transitioned in.

def on_transition_in(self) -> None:
190    def on_transition_in(self) -> None:
191        from bastd.actor import background  # FIXME NO BSSTD
192        ba.set_analytics_screen('Coop Score Screen')
193        super().on_transition_in()
194        self._background = background.Background(fade_time=0.45,
195                                                 start_faded=False,
196                                                 show_logo=True)

Called when the Activity is first becoming visible.

Upon this call, the Activity should fade in backgrounds, start playing music, etc. It does not yet have access to players or teams, however. They remain owned by the previous Activity up until ba.Activity.on_begin() is called.

def request_ui(self) -> None:
303    def request_ui(self) -> None:
304        """Set up a callback to show our UI at the next opportune time."""
305        # We don't want to just show our UI in case the user already has the
306        # main menu up, so instead we add a callback for when the menu
307        # closes; if we're still alive, we'll come up then.
308        # If there's no main menu this gets called immediately.
309        ba.app.add_main_menu_close_callback(ba.WeakCall(self.show_ui))

Set up a callback to show our UI at the next opportune time.

def show_ui(self) -> None:
311    def show_ui(self) -> None:
312        """Show the UI for restarting, playing the next Level, etc."""
313        # pylint: disable=too-many-locals
314        from bastd.ui.store.button import StoreButton
315        from bastd.ui.league.rankbutton import LeagueRankButton
316
317        delay = 0.7 if (self._score is not None) else 0.0
318
319        # If there's no players left in the game, lets not show the UI
320        # (that would allow restarting the game with zero players, etc).
321        if not self.players:
322            return
323
324        rootc = self._root_ui = ba.containerwidget(size=(0, 0),
325                                                   transition='in_right')
326
327        h_offs = 7.0
328        v_offs = -280.0
329
330        # We wanna prevent controllers users from popping up browsers
331        # or game-center widgets in cases where they can't easily get back
332        # to the game (like on mac).
333        can_select_extra_buttons = ba.app.platform == 'android'
334
335        _ba.set_ui_input_device(None)  # Menu is up for grabs.
336
337        if self._show_friend_scores:
338            ba.buttonwidget(parent=rootc,
339                            color=(0.45, 0.4, 0.5),
340                            position=(h_offs - 520, v_offs + 480),
341                            size=(300, 60),
342                            label=ba.Lstr(resource='topFriendsText'),
343                            on_activate_call=ba.WeakCall(self._ui_gc),
344                            transition_delay=delay + 0.5,
345                            icon=self._game_service_leaderboards_texture,
346                            icon_color=self._game_service_icon_color,
347                            autoselect=True,
348                            selectable=can_select_extra_buttons)
349
350        if self._have_achievements and self._account_has_achievements:
351            ba.buttonwidget(parent=rootc,
352                            color=(0.45, 0.4, 0.5),
353                            position=(h_offs - 520, v_offs + 450 - 235 + 40),
354                            size=(300, 60),
355                            label=ba.Lstr(resource='achievementsText'),
356                            on_activate_call=ba.WeakCall(
357                                self._ui_show_achievements),
358                            transition_delay=delay + 1.5,
359                            icon=self._game_service_achievements_texture,
360                            icon_color=self._game_service_icon_color,
361                            autoselect=True,
362                            selectable=can_select_extra_buttons)
363
364        if self._should_show_worlds_best_button():
365            ba.buttonwidget(
366                parent=rootc,
367                color=(0.45, 0.4, 0.5),
368                position=(160, v_offs + 480),
369                size=(350, 62),
370                label=ba.Lstr(resource='tournamentStandingsText')
371                if self.session.tournament_id is not None else ba.Lstr(
372                    resource='worldsBestScoresText') if self._score_type
373                == 'points' else ba.Lstr(resource='worldsBestTimesText'),
374                autoselect=True,
375                on_activate_call=ba.WeakCall(self._ui_worlds_best),
376                transition_delay=delay + 1.9,
377                selectable=can_select_extra_buttons)
378        else:
379            pass
380
381        show_next_button = self._is_more_levels and not (ba.app.demo_mode
382                                                         or ba.app.arcade_mode)
383
384        if not show_next_button:
385            h_offs += 70
386
387        menu_button = ba.buttonwidget(parent=rootc,
388                                      autoselect=True,
389                                      position=(h_offs - 130 - 60, v_offs),
390                                      size=(110, 85),
391                                      label='',
392                                      on_activate_call=ba.WeakCall(
393                                          self._ui_menu))
394        ba.imagewidget(parent=rootc,
395                       draw_controller=menu_button,
396                       position=(h_offs - 130 - 60 + 22, v_offs + 14),
397                       size=(60, 60),
398                       texture=self._menu_icon_texture,
399                       opacity=0.8)
400        self._restart_button = restart_button = ba.buttonwidget(
401            parent=rootc,
402            autoselect=True,
403            position=(h_offs - 60, v_offs),
404            size=(110, 85),
405            label='',
406            on_activate_call=ba.WeakCall(self._ui_restart))
407        ba.imagewidget(parent=rootc,
408                       draw_controller=restart_button,
409                       position=(h_offs - 60 + 19, v_offs + 7),
410                       size=(70, 70),
411                       texture=self._replay_icon_texture,
412                       opacity=0.8)
413
414        next_button: ba.Widget | None = None
415
416        # Our 'next' button is disabled if we haven't unlocked the next
417        # level yet and invisible if there is none.
418        if show_next_button:
419            if self._is_complete:
420                call = ba.WeakCall(self._ui_next)
421                button_sound = True
422                image_opacity = 0.8
423                color = None
424            else:
425                call = ba.WeakCall(self._ui_error)
426                button_sound = False
427                image_opacity = 0.2
428                color = (0.3, 0.3, 0.3)
429            next_button = ba.buttonwidget(parent=rootc,
430                                          autoselect=True,
431                                          position=(h_offs + 130 - 60, v_offs),
432                                          size=(110, 85),
433                                          label='',
434                                          on_activate_call=call,
435                                          color=color,
436                                          enable_sound=button_sound)
437            ba.imagewidget(parent=rootc,
438                           draw_controller=next_button,
439                           position=(h_offs + 130 - 60 + 12, v_offs + 5),
440                           size=(80, 80),
441                           texture=self._next_level_icon_texture,
442                           opacity=image_opacity)
443
444        x_offs_extra = 0 if show_next_button else -100
445        self._corner_button_offs = (h_offs + 300.0 + 100.0 + x_offs_extra,
446                                    v_offs + 560.0)
447
448        if ba.app.demo_mode or ba.app.arcade_mode:
449            self._league_rank_button = None
450            self._store_button_instance = None
451        else:
452            self._league_rank_button = LeagueRankButton(
453                parent=rootc,
454                position=(h_offs + 300 + 100 + x_offs_extra, v_offs + 560),
455                size=(100, 60),
456                scale=0.9,
457                color=(0.4, 0.4, 0.9),
458                textcolor=(0.9, 0.9, 2.0),
459                transition_delay=0.0,
460                smooth_update_delay=5.0)
461            self._store_button_instance = StoreButton(
462                parent=rootc,
463                position=(h_offs + 400 + 100 + x_offs_extra, v_offs + 560),
464                show_tickets=True,
465                sale_scale=0.85,
466                size=(100, 60),
467                scale=0.9,
468                button_type='square',
469                color=(0.35, 0.25, 0.45),
470                textcolor=(0.9, 0.7, 1.0),
471                transition_delay=0.0)
472
473        ba.containerwidget(edit=rootc,
474                           selected_child=next_button if
475                           (self._newly_complete and self._victory
476                            and show_next_button) else restart_button,
477                           on_cancel_call=menu_button.activate)
478
479        self._update_corner_button_positions()
480        self._update_corner_button_positions_timer = ba.Timer(
481            1.0,
482            ba.WeakCall(self._update_corner_button_positions),
483            repeat=True,
484            timetype=ba.TimeType.REAL)

Show the UI for restarting, playing the next Level, etc.

def on_player_join(self, player: ba._player.Player) -> None:
526    def on_player_join(self, player: ba.Player) -> None:
527        super().on_player_join(player)
528
529        if ba.app.server is not None:
530            # Host can't press retry button, so anyone can do it instead.
531            time_till_assign = max(
532                0, self._birth_time + self._min_view_time - _ba.time())
533
534            ba.timer(time_till_assign, ba.WeakCall(self._safe_assign, player))

Called when a new ba.Player has joined the Activity.

(including the initial set of Players)

def on_begin(self) -> None:
536    def on_begin(self) -> None:
537        # FIXME: Clean this up.
538        # pylint: disable=too-many-statements
539        # pylint: disable=too-many-branches
540        # pylint: disable=too-many-locals
541        super().on_begin()
542
543        self._begin_time = ba.time()
544
545        # Calc whether the level is complete and other stuff.
546        levels = self._campaign.levels
547        level = self._campaign.getlevel(self._level_name)
548        self._was_complete = level.complete
549        self._is_complete = (self._was_complete or self._victory)
550        self._newly_complete = (self._is_complete and not self._was_complete)
551        self._is_more_levels = ((level.index < len(levels) - 1)
552                                and self._campaign.sequential)
553
554        # Any time we complete a level, set the next one as unlocked.
555        if self._is_complete and self._is_more_levels:
556            _ba.add_transaction({
557                'type': 'COMPLETE_LEVEL',
558                'campaign': self._campaign.name,
559                'level': self._level_name
560            })
561            self._next_level_name = levels[level.index + 1].name
562
563            # If this is the first time we completed it, set the next one
564            # as current.
565            if self._newly_complete:
566                cfg = ba.app.config
567                cfg['Selected Coop Game'] = (self._campaign.name + ':' +
568                                             self._next_level_name)
569                cfg.commit()
570                self._campaign.set_selected_level(self._next_level_name)
571
572        ba.timer(1.0, ba.WeakCall(self.request_ui))
573
574        if (self._is_complete and self._victory and self._is_more_levels
575                and not (ba.app.demo_mode or ba.app.arcade_mode)):
576            Text(ba.Lstr(value='${A}:\n',
577                         subs=[('${A}', ba.Lstr(resource='levelUnlockedText'))
578                               ]) if self._newly_complete else
579                 ba.Lstr(value='${A}:\n',
580                         subs=[('${A}', ba.Lstr(resource='nextLevelText'))]),
581                 transition=Text.Transition.IN_RIGHT,
582                 transition_delay=5.2,
583                 flash=self._newly_complete,
584                 scale=0.54,
585                 h_align=Text.HAlign.CENTER,
586                 maxwidth=270,
587                 color=(0.5, 0.7, 0.5, 1),
588                 position=(270, -235)).autoretain()
589            assert self._next_level_name is not None
590            Text(ba.Lstr(translate=('coopLevelNames', self._next_level_name)),
591                 transition=Text.Transition.IN_RIGHT,
592                 transition_delay=5.2,
593                 flash=self._newly_complete,
594                 scale=0.7,
595                 h_align=Text.HAlign.CENTER,
596                 maxwidth=205,
597                 color=(0.5, 0.7, 0.5, 1),
598                 position=(270, -255)).autoretain()
599            if self._newly_complete:
600                ba.timer(5.2, ba.Call(ba.playsound, self._cashregistersound))
601                ba.timer(5.2, ba.Call(ba.playsound, self._dingsound))
602
603        offs_x = -195
604        if len(self._playerinfos) > 1:
605            pstr = ba.Lstr(value='- ${A} -',
606                           subs=[('${A}',
607                                  ba.Lstr(resource='multiPlayerCountText',
608                                          subs=[('${COUNT}',
609                                                 str(len(self._playerinfos)))
610                                                ]))])
611        else:
612            pstr = ba.Lstr(value='- ${A} -',
613                           subs=[('${A}',
614                                  ba.Lstr(resource='singlePlayerCountText'))])
615        ZoomText(self._campaign.getlevel(self._level_name).displayname,
616                 maxwidth=800,
617                 flash=False,
618                 trail=False,
619                 color=(0.5, 1, 0.5, 1),
620                 h_align='center',
621                 scale=0.4,
622                 position=(0, 292),
623                 jitter=1.0).autoretain()
624        Text(pstr,
625             maxwidth=300,
626             transition=Text.Transition.FADE_IN,
627             scale=0.7,
628             h_align=Text.HAlign.CENTER,
629             v_align=Text.VAlign.CENTER,
630             color=(0.5, 0.7, 0.5, 1),
631             position=(0, 230)).autoretain()
632
633        if ba.app.server is None:
634            # If we're running in normal non-headless build, show this text
635            # because only host can continue the game.
636            adisp = _ba.get_v1_account_display_string()
637            txt = Text(ba.Lstr(resource='waitingForHostText',
638                               subs=[('${HOST}', adisp)]),
639                       maxwidth=300,
640                       transition=Text.Transition.FADE_IN,
641                       transition_delay=8.0,
642                       scale=0.85,
643                       h_align=Text.HAlign.CENTER,
644                       v_align=Text.VAlign.CENTER,
645                       color=(1, 1, 0, 1),
646                       position=(0, -230)).autoretain()
647            assert txt.node
648            txt.node.client_only = True
649        else:
650            # In headless build, anyone can continue the game.
651            sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
652            Text(sval,
653                 v_attach=Text.VAttach.BOTTOM,
654                 h_align=Text.HAlign.CENTER,
655                 flash=True,
656                 vr_depth=50,
657                 position=(0, 60),
658                 scale=0.8,
659                 color=(0.5, 0.7, 0.5, 0.5),
660                 transition=Text.Transition.IN_BOTTOM_SLOW,
661                 transition_delay=self._min_view_time).autoretain()
662
663        if self._score is not None:
664            ba.timer(0.35,
665                     ba.Call(ba.playsound, self._score_display_sound_small))
666
667        # Vestigial remain; this stuff should just be instance vars.
668        self._show_info = {}
669
670        if self._score is not None:
671            ba.timer(0.8, ba.WeakCall(self._show_score_val, offs_x))
672        else:
673            ba.pushcall(ba.WeakCall(self._show_fail))
674
675