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

Score screen showing the results of a cooperative game.

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

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

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

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