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

Score screen showing the results of a cooperative game.

CoopScoreScreen(settings: dict)
 29    def __init__(self, settings: dict):
 30        # pylint: disable=too-many-statements
 31        super().__init__(settings)
 32
 33        plus = bs.app.plus
 34        assert plus is not None
 35
 36        # Keep prev activity alive while we fade in
 37        self.transition_time = 0.5
 38        self.inherits_tint = True
 39        self.inherits_vr_camera_offset = True
 40        self.inherits_music = True
 41        self.use_fixed_vr_overlay = True
 42
 43        self._do_new_rating: bool = self.session.tournament_id is not None
 44
 45        self._score_display_sound = bs.getsound('scoreHit01')
 46        self._score_display_sound_small = bs.getsound('scoreHit02')
 47        self.drum_roll_sound = bs.getsound('drumRoll')
 48        self.cymbal_sound = bs.getsound('cymbal')
 49
 50        self._replay_icon_texture = bui.gettexture('replayIcon')
 51        self._menu_icon_texture = bui.gettexture('menuIcon')
 52        self._next_level_icon_texture = bui.gettexture('nextLevelIcon')
 53
 54        self._campaign: bs.Campaign = settings['campaign']
 55
 56        self._have_achievements = (
 57            bs.app.classic is not None
 58            and bs.app.classic.ach.achievements_for_coop_level(
 59                self._campaign.name + ':' + settings['level']
 60            )
 61        )
 62
 63        self._game_service_icon_color: Sequence[float] | None
 64        self._game_service_achievements_texture: bui.Texture | None
 65        self._game_service_leaderboards_texture: bui.Texture | None
 66
 67        # Tie in to specific game services if they are active.
 68        adapter = plus.accounts.login_adapters.get(LoginType.GPGS)
 69        gpgs_active = adapter is not None and adapter.is_back_end_active()
 70        adapter = plus.accounts.login_adapters.get(LoginType.GAME_CENTER)
 71        game_center_active = (
 72            adapter is not None and adapter.is_back_end_active()
 73        )
 74
 75        if game_center_active:
 76            self._game_service_icon_color = (1.0, 1.0, 1.0)
 77            icon = bui.gettexture('gameCenterIcon')
 78            self._game_service_achievements_texture = icon
 79            self._game_service_leaderboards_texture = icon
 80            self._account_has_achievements = True
 81        elif gpgs_active:
 82            self._game_service_icon_color = (0.8, 1.0, 0.6)
 83            self._game_service_achievements_texture = bui.gettexture(
 84                'googlePlayAchievementsIcon'
 85            )
 86            self._game_service_leaderboards_texture = bui.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 = bs.getsound('cashRegister')
 97        self._gun_cocking_sound = bs.getsound('gunCocking')
 98        self._dingsound = bs.getsound('ding')
 99        self._score_link: str | None = None
100        self._root_ui: bui.Widget | None = None
101        self._background: bs.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._restart_button: bui.Widget | None = None
109        self._next_level_error: bs.Actor | None = None
110
111        # Score/gameplay bits.
112        self._was_complete: bool | None = None
113        self._is_complete: bool | None = None
114        self._newly_complete: bool | None = None
115        self._is_more_levels: bool | None = None
116        self._next_level_name: str | None = None
117        self._show_info: dict[str, Any] | None = None
118        self._name_str: str | None = None
119        self._friends_loading_status: bs.Actor | None = None
120        self._score_loading_status: bs.Actor | None = None
121        self._tournament_time_remaining: float | None = None
122        self._tournament_time_remaining_text: Text | None = None
123        self._tournament_time_remaining_text_timer: bs.BaseTimer | None = None
124        self._submit_score = self.session.submit_score
125
126        # Stuff for activity skip by pressing button
127        self._birth_time = bs.time()
128        self._min_view_time = 5.0
129        self._allow_server_transition = False
130        self._server_transitioning: bool | None = None
131
132        self._playerinfos: list[bs.PlayerInfo] = settings['playerinfos']
133        assert isinstance(self._playerinfos, list)
134        assert all(isinstance(i, bs.PlayerInfo) for i in self._playerinfos)
135
136        self._score: int | None = settings['score']
137        assert isinstance(self._score, (int, type(None)))
138
139        self._fail_message: bs.Lstr | None = settings['fail_message']
140        assert isinstance(self._fail_message, (bs.Lstr, type(None)))
141
142        self._begin_time: float | None = None
143
144        self._score_order: str
145        if 'score_order' in settings:
146            if not settings['score_order'] in ['increasing', 'decreasing']:
147                raise ValueError(
148                    'Invalid score order: ' + settings['score_order']
149                )
150            self._score_order = settings['score_order']
151        else:
152            self._score_order = 'increasing'
153        assert isinstance(self._score_order, str)
154
155        self._score_type: str
156        if 'score_type' in settings:
157            if not settings['score_type'] in ['points', 'time']:
158                raise ValueError(
159                    'Invalid score type: ' + settings['score_type']
160                )
161            self._score_type = settings['score_type']
162        else:
163            self._score_type = 'points'
164        assert isinstance(self._score_type, str)
165
166        self._level_name: str = settings['level']
167        assert isinstance(self._level_name, str)
168
169        self._game_name_str = self._campaign.name + ':' + self._level_name
170        self._game_config_str = (
171            str(len(self._playerinfos))
172            + 'p'
173            + self._campaign.getlevel(self._level_name)
174            .get_score_version_string()
175            .replace(' ', '_')
176        )
177
178        try:
179            self._old_best_rank = self._campaign.getlevel(
180                self._level_name
181            ).rating
182        except Exception:
183            self._old_best_rank = 0.0
184
185        self._victory: bool = settings['outcome'] == 'victory'

Creates an Activity in the current bascenev1.Session.

The activity will not be actually run until bascenev1.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.

drum_roll_sound
cymbal_sound
@override
def on_transition_in(self) -> None:
196    @override
197    def on_transition_in(self) -> None:
198        from bascenev1lib.actor import background  # FIXME NO BSSTD
199
200        bs.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 bascenev1.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        classic = bui.app.classic
331        assert classic is not None
332        # We don't want to just show our UI in case the user already has the
333        # main menu up, so instead we add a callback for when the menu
334        # closes; if we're still alive, we'll come up then.
335        # If there's no main menu this gets called immediately.
336        classic.add_main_menu_close_callback(bui.WeakCall(self.show_ui))

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

def show_ui(self) -> None:
338    def show_ui(self) -> None:
339        """Show the UI for restarting, playing the next Level, etc."""
340        # pylint: disable=too-many-locals
341        # pylint: disable=too-many-statements
342        # pylint: disable=too-many-branches
343
344        assert bui.app.classic is not None
345
346        env = bui.app.env
347
348        delay = 0.7 if (self._score is not None) else 0.0
349
350        # If there's no players left in the game, lets not show the UI
351        # (that would allow restarting the game with zero players, etc).
352        if not self.players:
353            return
354
355        rootc = self._root_ui = bui.containerwidget(
356            size=(0, 0),
357            transition='in_right',
358            toolbar_visibility='no_menu_minimal',
359        )
360
361        h_offs = 7.0
362        v_offs = -280.0
363        v_offs2 = -236.0
364
365        # We wanna prevent controllers users from popping up browsers
366        # or game-center widgets in cases where they can't easily get back
367        # to the game (like on mac).
368        can_select_extra_buttons = bui.app.classic.platform == 'android'
369
370        bui.set_ui_input_device(None)  # Menu is up for grabs.
371
372        if self._have_achievements and self._account_has_achievements:
373            bui.buttonwidget(
374                parent=rootc,
375                color=(0.45, 0.4, 0.5),
376                position=(h_offs - 520, v_offs + 450 - 235 + 40),
377                size=(300, 60),
378                label=bui.Lstr(resource='achievementsText'),
379                on_activate_call=bui.WeakCall(self._ui_show_achievements),
380                transition_delay=delay + 1.5,
381                icon=self._game_service_achievements_texture,
382                icon_color=self._game_service_icon_color,
383                autoselect=True,
384                selectable=can_select_extra_buttons,
385            )
386
387        if self._should_show_worlds_best_button():
388            bui.buttonwidget(
389                parent=rootc,
390                color=(0.45, 0.4, 0.5),
391                position=(240, v_offs2 + 439),
392                size=(350, 62),
393                label=(
394                    bui.Lstr(resource='tournamentStandingsText')
395                    if self.session.tournament_id is not None
396                    else (
397                        bui.Lstr(resource='worldsBestScoresText')
398                        if self._score_type == 'points'
399                        else bui.Lstr(resource='worldsBestTimesText')
400                    )
401                ),
402                autoselect=True,
403                on_activate_call=bui.WeakCall(self._ui_worlds_best),
404                transition_delay=delay + 1.9,
405                selectable=can_select_extra_buttons,
406            )
407        else:
408            pass
409
410        show_next_button = self._is_more_levels and not (env.demo or env.arcade)
411
412        if not show_next_button:
413            h_offs += 60
414
415        # Due to virtual-bounds changes, have to squish buttons a bit to
416        # avoid overlapping with tips at bottom. Could look nicer to
417        # rework things in the middle to get more space, but would
418        # rather not touch this old code more than necessary.
419        small_buttons = False
420
421        if small_buttons:
422            menu_button = bui.buttonwidget(
423                parent=rootc,
424                autoselect=True,
425                position=(h_offs - 130 - 45, v_offs + 40),
426                size=(100, 50),
427                label='',
428                button_type='square',
429                on_activate_call=bui.WeakCall(self._ui_menu),
430            )
431            bui.imagewidget(
432                parent=rootc,
433                draw_controller=menu_button,
434                position=(h_offs - 130 - 60 + 43, v_offs + 43),
435                size=(45, 45),
436                texture=self._menu_icon_texture,
437                opacity=0.8,
438            )
439        else:
440            menu_button = bui.buttonwidget(
441                parent=rootc,
442                autoselect=True,
443                position=(h_offs - 130 - 60, v_offs),
444                size=(110, 85),
445                label='',
446                on_activate_call=bui.WeakCall(self._ui_menu),
447            )
448            bui.imagewidget(
449                parent=rootc,
450                draw_controller=menu_button,
451                position=(h_offs - 130 - 60 + 22, v_offs + 14),
452                size=(60, 60),
453                texture=self._menu_icon_texture,
454                opacity=0.8,
455            )
456
457        if small_buttons:
458            self._restart_button = restart_button = bui.buttonwidget(
459                parent=rootc,
460                autoselect=True,
461                position=(h_offs - 60, v_offs + 40),
462                size=(100, 50),
463                label='',
464                button_type='square',
465                on_activate_call=bui.WeakCall(self._ui_restart),
466            )
467            bui.imagewidget(
468                parent=rootc,
469                draw_controller=restart_button,
470                position=(h_offs - 60 + 25, v_offs + 42),
471                size=(47, 47),
472                texture=self._replay_icon_texture,
473                opacity=0.8,
474            )
475        else:
476            self._restart_button = restart_button = bui.buttonwidget(
477                parent=rootc,
478                autoselect=True,
479                position=(h_offs - 60, v_offs),
480                size=(110, 85),
481                label='',
482                on_activate_call=bui.WeakCall(self._ui_restart),
483            )
484            bui.imagewidget(
485                parent=rootc,
486                draw_controller=restart_button,
487                position=(h_offs - 60 + 19, v_offs + 7),
488                size=(70, 70),
489                texture=self._replay_icon_texture,
490                opacity=0.8,
491            )
492
493        next_button: bui.Widget | None = None
494
495        # Our 'next' button is disabled if we haven't unlocked the next
496        # level yet and invisible if there is none.
497        if show_next_button:
498            if self._is_complete:
499                call = bui.WeakCall(self._ui_next)
500                button_sound = True
501                image_opacity = 0.8
502                color = None
503            else:
504                call = bui.WeakCall(self._ui_error)
505                button_sound = False
506                image_opacity = 0.2
507                color = (0.3, 0.3, 0.3)
508
509            if small_buttons:
510                next_button = bui.buttonwidget(
511                    parent=rootc,
512                    autoselect=True,
513                    position=(h_offs + 130 - 75, v_offs + 40),
514                    size=(100, 50),
515                    label='',
516                    button_type='square',
517                    on_activate_call=call,
518                    color=color,
519                    enable_sound=button_sound,
520                )
521                bui.imagewidget(
522                    parent=rootc,
523                    draw_controller=next_button,
524                    position=(h_offs + 130 - 60 + 12, v_offs + 40),
525                    size=(50, 50),
526                    texture=self._next_level_icon_texture,
527                    opacity=image_opacity,
528                )
529            else:
530                next_button = bui.buttonwidget(
531                    parent=rootc,
532                    autoselect=True,
533                    position=(h_offs + 130 - 60, v_offs),
534                    size=(110, 85),
535                    label='',
536                    on_activate_call=call,
537                    color=color,
538                    enable_sound=button_sound,
539                )
540                bui.imagewidget(
541                    parent=rootc,
542                    draw_controller=next_button,
543                    position=(h_offs + 130 - 60 + 12, v_offs + 5),
544                    size=(80, 80),
545                    texture=self._next_level_icon_texture,
546                    opacity=image_opacity,
547                )
548
549        x_offs_extra = 0 if show_next_button else -100
550        self._corner_button_offs = (
551            h_offs + 300.0 + x_offs_extra,
552            v_offs + 519.0,
553        )
554
555        bui.containerwidget(
556            edit=rootc,
557            selected_child=(
558                next_button
559                if (self._newly_complete and self._victory and show_next_button)
560                else restart_button
561            ),
562            on_cancel_call=menu_button.activate,
563        )

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

@override
def on_player_join(self, player: bascenev1.Player) -> None:
607    @override
608    def on_player_join(self, player: bs.Player) -> None:
609        super().on_player_join(player)
610
611        if bs.app.classic is not None and bs.app.classic.server is not None:
612            # Host can't press retry button, so anyone can do it instead.
613            time_till_assign = max(
614                0, self._birth_time + self._min_view_time - bs.time()
615            )
616
617            bs.timer(time_till_assign, bs.WeakCall(self._safe_assign, player))

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

(including the initial set of Players)

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

Called once the previous 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.