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

transition_time = 0.0

If the activity fades or transitions in, it should set the length of time here so that previous activities will be kept alive for that long (avoiding 'holes' in the screen) This value is given in real-time seconds.

inherits_tint = False

Set this to true to inherit screen tint/vignette colors from the previous activity (useful to prevent sudden color changes during transitions).

inherits_vr_camera_offset = False

Set this to true to inherit VR camera offsets from the previous activity (useful for preventing sporadic camera movement during transitions).

inherits_music = False

Set this to True to keep playing the music from the previous activity (without even restarting it).

use_fixed_vr_overlay = False

In vr mode, this determines whether overlay nodes (text, images, etc) are created at a fixed position in space or one that moves based on the current map. Generally this should be on for games and off for transitions/score-screens/etc. that persist between maps.

def on_transition_in(self) -> None:
197    def on_transition_in(self) -> None:
198        from bastd.actor import background  # FIXME NO BSSTD
199
200        ba.set_analytics_screen('Coop Score Screen')
201        super().on_transition_in()
202        self._background = background.Background(
203            fade_time=0.45, start_faded=False, show_logo=True
204        )

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

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

def on_player_join(self, player: ba._player.Player) -> None:
568    def on_player_join(self, player: ba.Player) -> None:
569        super().on_player_join(player)
570
571        if ba.app.server is not None:
572            # Host can't press retry button, so anyone can do it instead.
573            time_till_assign = max(
574                0, self._birth_time + self._min_view_time - ba.time()
575            )
576
577            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:
 579    def on_begin(self) -> None:
 580        # FIXME: Clean this up.
 581        # pylint: disable=too-many-statements
 582        # pylint: disable=too-many-branches
 583        # pylint: disable=too-many-locals
 584        super().on_begin()
 585
 586        self._begin_time = ba.time()
 587
 588        # Calc whether the level is complete and other stuff.
 589        levels = self._campaign.levels
 590        level = self._campaign.getlevel(self._level_name)
 591        self._was_complete = level.complete
 592        self._is_complete = self._was_complete or self._victory
 593        self._newly_complete = self._is_complete and not self._was_complete
 594        self._is_more_levels = (
 595            level.index < len(levels) - 1
 596        ) and self._campaign.sequential
 597
 598        # Any time we complete a level, set the next one as unlocked.
 599        if self._is_complete and self._is_more_levels:
 600            ba.internal.add_transaction(
 601                {
 602                    'type': 'COMPLETE_LEVEL',
 603                    'campaign': self._campaign.name,
 604                    'level': self._level_name,
 605                }
 606            )
 607            self._next_level_name = levels[level.index + 1].name
 608
 609            # If this is the first time we completed it, set the next one
 610            # as current.
 611            if self._newly_complete:
 612                cfg = ba.app.config
 613                cfg['Selected Coop Game'] = (
 614                    self._campaign.name + ':' + self._next_level_name
 615                )
 616                cfg.commit()
 617                self._campaign.set_selected_level(self._next_level_name)
 618
 619        ba.timer(1.0, ba.WeakCall(self.request_ui))
 620
 621        if (
 622            self._is_complete
 623            and self._victory
 624            and self._is_more_levels
 625            and not (ba.app.demo_mode or ba.app.arcade_mode)
 626        ):
 627            Text(
 628                ba.Lstr(
 629                    value='${A}:\n',
 630                    subs=[('${A}', ba.Lstr(resource='levelUnlockedText'))],
 631                )
 632                if self._newly_complete
 633                else ba.Lstr(
 634                    value='${A}:\n',
 635                    subs=[('${A}', ba.Lstr(resource='nextLevelText'))],
 636                ),
 637                transition=Text.Transition.IN_RIGHT,
 638                transition_delay=5.2,
 639                flash=self._newly_complete,
 640                scale=0.54,
 641                h_align=Text.HAlign.CENTER,
 642                maxwidth=270,
 643                color=(0.5, 0.7, 0.5, 1),
 644                position=(270, -235),
 645            ).autoretain()
 646            assert self._next_level_name is not None
 647            Text(
 648                ba.Lstr(translate=('coopLevelNames', self._next_level_name)),
 649                transition=Text.Transition.IN_RIGHT,
 650                transition_delay=5.2,
 651                flash=self._newly_complete,
 652                scale=0.7,
 653                h_align=Text.HAlign.CENTER,
 654                maxwidth=205,
 655                color=(0.5, 0.7, 0.5, 1),
 656                position=(270, -255),
 657            ).autoretain()
 658            if self._newly_complete:
 659                ba.timer(5.2, ba.Call(ba.playsound, self._cashregistersound))
 660                ba.timer(5.2, ba.Call(ba.playsound, self._dingsound))
 661
 662        offs_x = -195
 663        if len(self._playerinfos) > 1:
 664            pstr = ba.Lstr(
 665                value='- ${A} -',
 666                subs=[
 667                    (
 668                        '${A}',
 669                        ba.Lstr(
 670                            resource='multiPlayerCountText',
 671                            subs=[('${COUNT}', str(len(self._playerinfos)))],
 672                        ),
 673                    )
 674                ],
 675            )
 676        else:
 677            pstr = ba.Lstr(
 678                value='- ${A} -',
 679                subs=[('${A}', ba.Lstr(resource='singlePlayerCountText'))],
 680            )
 681        ZoomText(
 682            self._campaign.getlevel(self._level_name).displayname,
 683            maxwidth=800,
 684            flash=False,
 685            trail=False,
 686            color=(0.5, 1, 0.5, 1),
 687            h_align='center',
 688            scale=0.4,
 689            position=(0, 292),
 690            jitter=1.0,
 691        ).autoretain()
 692        Text(
 693            pstr,
 694            maxwidth=300,
 695            transition=Text.Transition.FADE_IN,
 696            scale=0.7,
 697            h_align=Text.HAlign.CENTER,
 698            v_align=Text.VAlign.CENTER,
 699            color=(0.5, 0.7, 0.5, 1),
 700            position=(0, 230),
 701        ).autoretain()
 702
 703        if ba.app.server is None:
 704            # If we're running in normal non-headless build, show this text
 705            # because only host can continue the game.
 706            adisp = ba.internal.get_v1_account_display_string()
 707            txt = Text(
 708                ba.Lstr(
 709                    resource='waitingForHostText', subs=[('${HOST}', adisp)]
 710                ),
 711                maxwidth=300,
 712                transition=Text.Transition.FADE_IN,
 713                transition_delay=8.0,
 714                scale=0.85,
 715                h_align=Text.HAlign.CENTER,
 716                v_align=Text.VAlign.CENTER,
 717                color=(1, 1, 0, 1),
 718                position=(0, -230),
 719            ).autoretain()
 720            assert txt.node
 721            txt.node.client_only = True
 722        else:
 723            # In headless build, anyone can continue the game.
 724            sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
 725            Text(
 726                sval,
 727                v_attach=Text.VAttach.BOTTOM,
 728                h_align=Text.HAlign.CENTER,
 729                flash=True,
 730                vr_depth=50,
 731                position=(0, 60),
 732                scale=0.8,
 733                color=(0.5, 0.7, 0.5, 0.5),
 734                transition=Text.Transition.IN_BOTTOM_SLOW,
 735                transition_delay=self._min_view_time,
 736            ).autoretain()
 737
 738        if self._score is not None:
 739            ba.timer(
 740                0.35, ba.Call(ba.playsound, self._score_display_sound_small)
 741            )
 742
 743        # Vestigial remain; this stuff should just be instance vars.
 744        self._show_info = {}
 745
 746        if self._score is not None:
 747            ba.timer(0.8, ba.WeakCall(self._show_score_val, offs_x))
 748        else:
 749            ba.pushcall(ba.WeakCall(self._show_fail))
 750
 751        self._name_str = name_str = ', '.join(
 752            [p.name for p in self._playerinfos]
 753        )
 754
 755        self._score_loading_status = Text(
 756            ba.Lstr(
 757                value='${A}...',
 758                subs=[('${A}', ba.Lstr(resource='loadingText'))],
 759            ),
 760            position=(280, 150 + 30),
 761            color=(1, 1, 1, 0.4),
 762            transition=Text.Transition.FADE_IN,
 763            scale=0.7,
 764            transition_delay=2.0,
 765        )
 766
 767        if self._score is not None:
 768            ba.timer(0.4, ba.WeakCall(self._play_drumroll))
 769
 770        # Add us to high scores, filter, and store.
 771        our_high_scores_all = self._campaign.getlevel(
 772            self._level_name
 773        ).get_high_scores()
 774
 775        our_high_scores = our_high_scores_all.setdefault(
 776            str(len(self._playerinfos)) + ' Player', []
 777        )
 778
 779        if self._score is not None:
 780            our_score: list | None = [
 781                self._score,
 782                {
 783                    'players': [
 784                        {'name': p.name, 'character': p.character}
 785                        for p in self._playerinfos
 786                    ]
 787                },
 788            ]
 789            our_high_scores.append(our_score)
 790        else:
 791            our_score = None
 792
 793        try:
 794            our_high_scores.sort(
 795                reverse=self._score_order == 'increasing', key=lambda x: x[0]
 796            )
 797        except Exception:
 798            ba.print_exception('Error sorting scores.')
 799            print(f'our_high_scores: {our_high_scores}')
 800
 801        del our_high_scores[10:]
 802
 803        if self._score is not None:
 804            sver = self._campaign.getlevel(
 805                self._level_name
 806            ).get_score_version_string()
 807            ba.internal.add_transaction(
 808                {
 809                    'type': 'SET_LEVEL_LOCAL_HIGH_SCORES',
 810                    'campaign': self._campaign.name,
 811                    'level': self._level_name,
 812                    'scoreVersion': sver,
 813                    'scores': our_high_scores_all,
 814                }
 815            )
 816        if ba.internal.get_v1_account_state() != 'signed_in':
 817            # We expect this only in kiosk mode; complain otherwise.
 818            if not (ba.app.demo_mode or ba.app.arcade_mode):
 819                print('got not-signed-in at score-submit; unexpected')
 820            ba.pushcall(ba.WeakCall(self._got_score_results, None))
 821        else:
 822            assert self._game_name_str is not None
 823            assert self._game_config_str is not None
 824            ba.internal.submit_score(
 825                self._game_name_str,
 826                self._game_config_str,
 827                name_str,
 828                self._score,
 829                ba.WeakCall(self._got_score_results),
 830                order=self._score_order,
 831                tournament_id=self.session.tournament_id,
 832                score_type=self._score_type,
 833                campaign=self._campaign.name,
 834                level=self._level_name,
 835            )
 836
 837        # Apply the transactions we've been adding locally.
 838        ba.internal.run_transactions()
 839
 840        # If we're not doing the world's-best button, just show a title
 841        # instead.
 842        ts_height = 300
 843        ts_h_offs = 210
 844        v_offs = 40
 845        txt = Text(
 846            ba.Lstr(resource='tournamentStandingsText')
 847            if self.session.tournament_id is not None
 848            else ba.Lstr(resource='worldsBestScoresText')
 849            if self._score_type == 'points'
 850            else ba.Lstr(resource='worldsBestTimesText'),
 851            maxwidth=210,
 852            position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 853            transition=Text.Transition.IN_LEFT,
 854            v_align=Text.VAlign.CENTER,
 855            scale=1.2,
 856            transition_delay=2.2,
 857        ).autoretain()
 858
 859        # If we've got a button on the server, only show this on clients.
 860        if self._should_show_worlds_best_button():
 861            assert txt.node
 862            txt.node.client_only = True
 863
 864        ts_height = 300
 865        ts_h_offs = -480
 866        v_offs = 40
 867        Text(
 868            ba.Lstr(resource='yourBestScoresText')
 869            if self._score_type == 'points'
 870            else ba.Lstr(resource='yourBestTimesText'),
 871            maxwidth=210,
 872            position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 873            transition=Text.Transition.IN_RIGHT,
 874            v_align=Text.VAlign.CENTER,
 875            scale=1.2,
 876            transition_delay=1.8,
 877        ).autoretain()
 878
 879        display_scores = list(our_high_scores)
 880        display_count = 5
 881
 882        while len(display_scores) < display_count:
 883            display_scores.append((0, None))
 884
 885        showed_ours = False
 886        h_offs_extra = 85 if self._score_type == 'points' else 130
 887        v_offs_extra = 20
 888        v_offs_names = 0
 889        scale = 1.0
 890        p_count = len(self._playerinfos)
 891        h_offs_extra -= 75
 892        if p_count > 1:
 893            h_offs_extra -= 20
 894        if p_count == 2:
 895            scale = 0.9
 896        elif p_count == 3:
 897            scale = 0.65
 898        elif p_count == 4:
 899            scale = 0.5
 900        times: list[tuple[float, float]] = []
 901        for i in range(display_count):
 902            times.insert(
 903                random.randrange(0, len(times) + 1),
 904                (1.9 + i * 0.05, 2.3 + i * 0.05),
 905            )
 906        for i in range(display_count):
 907            try:
 908                if display_scores[i][1] is None:
 909                    name_str = '-'
 910                else:
 911                    # noinspection PyUnresolvedReferences
 912                    name_str = ', '.join(
 913                        [p['name'] for p in display_scores[i][1]['players']]
 914                    )
 915            except Exception:
 916                ba.print_exception(
 917                    f'Error calcing name_str for {display_scores}'
 918                )
 919                name_str = '-'
 920            if display_scores[i] == our_score and not showed_ours:
 921                flash = True
 922                color0 = (0.6, 0.4, 0.1, 1.0)
 923                color1 = (0.6, 0.6, 0.6, 1.0)
 924                tdelay1 = 3.7
 925                tdelay2 = 3.7
 926                showed_ours = True
 927            else:
 928                flash = False
 929                color0 = (0.6, 0.4, 0.1, 1.0)
 930                color1 = (0.6, 0.6, 0.6, 1.0)
 931                tdelay1 = times[i][0]
 932                tdelay2 = times[i][1]
 933            Text(
 934                str(display_scores[i][0])
 935                if self._score_type == 'points'
 936                else ba.timestring(
 937                    display_scores[i][0] * 10,
 938                    timeformat=ba.TimeFormat.MILLISECONDS,
 939                    suppress_format_warning=True,
 940                ),
 941                position=(
 942                    ts_h_offs + 20 + h_offs_extra,
 943                    v_offs_extra
 944                    + ts_height / 2
 945                    + -ts_height * (i + 1) / 10
 946                    + v_offs
 947                    + 11.0,
 948                ),
 949                h_align=Text.HAlign.RIGHT,
 950                v_align=Text.VAlign.CENTER,
 951                color=color0,
 952                flash=flash,
 953                transition=Text.Transition.IN_RIGHT,
 954                transition_delay=tdelay1,
 955            ).autoretain()
 956
 957            Text(
 958                ba.Lstr(value=name_str),
 959                position=(
 960                    ts_h_offs + 35 + h_offs_extra,
 961                    v_offs_extra
 962                    + ts_height / 2
 963                    + -ts_height * (i + 1) / 10
 964                    + v_offs_names
 965                    + v_offs
 966                    + 11.0,
 967                ),
 968                maxwidth=80.0 + 100.0 * len(self._playerinfos),
 969                v_align=Text.VAlign.CENTER,
 970                color=color1,
 971                flash=flash,
 972                scale=scale,
 973                transition=Text.Transition.IN_RIGHT,
 974                transition_delay=tdelay2,
 975            ).autoretain()
 976
 977        # Show achievements for this level.
 978        ts_height = -150
 979        ts_h_offs = -480
 980        v_offs = 40
 981
 982        # Only make this if we don't have the button
 983        # (never want clients to see it so no need for client-only
 984        # version, etc).
 985        if self._have_achievements:
 986            if not self._account_has_achievements:
 987                Text(
 988                    ba.Lstr(resource='achievementsText'),
 989                    position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
 990                    maxwidth=210,
 991                    host_only=True,
 992                    transition=Text.Transition.IN_RIGHT,
 993                    v_align=Text.VAlign.CENTER,
 994                    scale=1.2,
 995                    transition_delay=2.8,
 996                ).autoretain()
 997
 998            assert self._game_name_str is not None
 999            achievements = ba.app.ach.achievements_for_coop_level(
1000                self._game_name_str
1001            )
1002            hval = -455
1003            vval = -100
1004            tdelay = 0.0
1005            for ach in achievements:
1006                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
1007                vval -= 55
1008                tdelay += 0.250
1009
1010        ba.timer(5.0, ba.WeakCall(self._show_tips))

Called once the previous ba.Activity has finished transitioning out.

At this point the activity's initial players and teams are filled in and it should begin its actual game logic.

Inherited Members
ba._activity.Activity
settings_raw
teams
players
announce_player_deaths
is_joining_activity
allow_pausing
allow_kick_idle_players
slow_motion
inherits_slow_motion
inherits_vr_overlay_center
allow_mid_activity_joins
can_show_ad_on_death
globalsnode
stats
on_expire
customdata
expired
playertype
teamtype
retain_actor
add_actor_weak_ref
session
on_player_leave
on_team_join
on_team_leave
on_transition_out
handlemessage
has_transitioned_in
has_begun
has_ended
is_transitioning_out
transition_out
end
create_player
create_team
ba._dependency.DependencyComponent
dep_is_present
get_dynamic_deps