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        v_offs2 = -236.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=(240, v_offs2 + 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 += 60
 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        # 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 = 290
 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                    name_str = ', '.join(
 960                        [p['name'] for p in display_scores[i][1]['players']]
 961                    )
 962            except Exception:
 963                logging.exception(
 964                    'Error calcing name_str for %s.', display_scores
 965                )
 966                name_str = '-'
 967            if display_scores[i] == our_score and not showed_ours:
 968                flash = True
 969                color0 = (0.6, 0.4, 0.1, 1.0)
 970                color1 = (0.6, 0.6, 0.6, 1.0)
 971                tdelay1 = 3.7
 972                tdelay2 = 3.7
 973                showed_ours = True
 974            else:
 975                flash = False
 976                color0 = (0.6, 0.4, 0.1, 1.0)
 977                color1 = (0.6, 0.6, 0.6, 1.0)
 978                tdelay1 = times[i][0]
 979                tdelay2 = times[i][1]
 980            Text(
 981                (
 982                    str(display_scores[i][0])
 983                    if self._score_type == 'points'
 984                    else bs.timestring((display_scores[i][0] * 10) / 1000.0)
 985                ),
 986                position=(
 987                    ts_h_offs + 20 + h_offs_extra,
 988                    v_offs_extra
 989                    + ts_height / 2
 990                    + -ts_height * (i + 1) / 10
 991                    + v_offs
 992                    + 11.0,
 993                ),
 994                h_align=Text.HAlign.RIGHT,
 995                v_align=Text.VAlign.CENTER,
 996                color=color0,
 997                flash=flash,
 998                transition=Text.Transition.IN_RIGHT,
 999                transition_delay=tdelay1,
1000            ).autoretain()
1001
1002            Text(
1003                bs.Lstr(value=name_str),
1004                position=(
1005                    ts_h_offs + 35 + h_offs_extra,
1006                    v_offs_extra
1007                    + ts_height / 2
1008                    + -ts_height * (i + 1) / 10
1009                    + v_offs_names
1010                    + v_offs
1011                    + 11.0,
1012                ),
1013                maxwidth=80.0 + 100.0 * len(self._playerinfos),
1014                v_align=Text.VAlign.CENTER,
1015                color=color1,
1016                flash=flash,
1017                scale=scale,
1018                transition=Text.Transition.IN_RIGHT,
1019                transition_delay=tdelay2,
1020            ).autoretain()
1021
1022        # Show achievements for this level.
1023        ts_height = -150
1024        ts_h_offs = -480
1025        v_offs = 40
1026
1027        # Only make this if we don't have the button (never want clients
1028        # to see it so no need for client-only version, etc).
1029        if self._have_achievements:
1030            if not self._account_has_achievements:
1031                Text(
1032                    bs.Lstr(resource='achievementsText'),
1033                    position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
1034                    maxwidth=210,
1035                    host_only=True,
1036                    transition=Text.Transition.IN_RIGHT,
1037                    v_align=Text.VAlign.CENTER,
1038                    scale=1.2,
1039                    transition_delay=2.8,
1040                ).autoretain()
1041
1042            assert self._game_name_str is not None
1043            assert bs.app.classic is not None
1044            achievements = bs.app.classic.ach.achievements_for_coop_level(
1045                self._game_name_str
1046            )
1047            hval = -455
1048            vval = -100
1049            tdelay = 0.0
1050            for ach in achievements:
1051                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
1052                vval -= 55
1053                tdelay += 0.250
1054
1055        bs.timer(5.0, bs.WeakCall(self._show_tips))
1056
1057    def _play_drumroll(self) -> None:
1058        bs.NodeActor(
1059            bs.newnode(
1060                'sound',
1061                attrs={
1062                    'sound': self.drum_roll_sound,
1063                    'positional': False,
1064                    'loop': False,
1065                },
1066            )
1067        ).autoretain()
1068
1069    def _got_friend_score_results(self, results: list[Any] | None) -> None:
1070        # pylint: disable=too-many-locals
1071        # pylint: disable=too-many-branches
1072        # pylint: disable=too-many-statements
1073        from efro.util import asserttype
1074
1075        # delay a bit if results come in too fast
1076        assert self._begin_time is not None
1077        base_delay = max(0, 1.9 - (bs.time() - self._begin_time))
1078        ts_height = 300
1079        ts_h_offs = -550
1080        v_offs = 30
1081
1082        # Report in case of error.
1083        if results is None:
1084            self._friends_loading_status = Text(
1085                bs.Lstr(resource='friendScoresUnavailableText'),
1086                maxwidth=330,
1087                position=(-475, 150 + v_offs),
1088                color=(1, 1, 1, 0.4),
1089                transition=Text.Transition.FADE_IN,
1090                transition_delay=base_delay + 0.8,
1091                scale=0.7,
1092            )
1093            return
1094
1095        self._friends_loading_status = None
1096
1097        # Ok, it looks like we aren't able to reliably get a just-submitted
1098        # result returned in the score list, so we need to look for our score
1099        # in this list and replace it if ours is better or add ours otherwise.
1100        if self._score is not None:
1101            our_score_entry = [self._score, 'Me', True]
1102            for score in results:
1103                if score[2]:
1104                    if self._score_order == 'increasing':
1105                        our_score_entry[0] = max(score[0], self._score)
1106                    else:
1107                        our_score_entry[0] = min(score[0], self._score)
1108                    results.remove(score)
1109                    break
1110            results.append(our_score_entry)
1111            results.sort(
1112                reverse=self._score_order == 'increasing',
1113                key=lambda x: asserttype(x[0], int),
1114            )
1115
1116        # If we're not submitting our own score, we still want to change the
1117        # name of our own score to 'Me'.
1118        else:
1119            for score in results:
1120                if score[2]:
1121                    score[1] = 'Me'
1122                    break
1123        h_offs_extra = 80 if self._score_type == 'points' else 130
1124        v_offs_extra = 20
1125        v_offs_names = 0
1126        scale = 1.0
1127
1128        # Make sure there's at least 5.
1129        while len(results) < 5:
1130            results.append([0, '-', False])
1131        results = results[:5]
1132        times: list[tuple[float, float]] = []
1133        for i in range(len(results)):
1134            times.insert(
1135                random.randrange(0, len(times) + 1),
1136                (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05),
1137            )
1138        for i, tval in enumerate(results):
1139            score = int(tval[0])
1140            name_str = tval[1]
1141            is_me = tval[2]
1142            if is_me and score == self._score:
1143                flash = True
1144                color0 = (0.6, 0.4, 0.1, 1.0)
1145                color1 = (0.6, 0.6, 0.6, 1.0)
1146                tdelay1 = base_delay + 1.0
1147                tdelay2 = base_delay + 1.0
1148            else:
1149                flash = False
1150                if is_me:
1151                    color0 = (0.6, 0.4, 0.1, 1.0)
1152                    color1 = (0.9, 1.0, 0.9, 1.0)
1153                else:
1154                    color0 = (0.6, 0.4, 0.1, 1.0)
1155                    color1 = (0.6, 0.6, 0.6, 1.0)
1156                tdelay1 = times[i][0]
1157                tdelay2 = times[i][1]
1158            if name_str != '-':
1159                Text(
1160                    (
1161                        str(score)
1162                        if self._score_type == 'points'
1163                        else bs.timestring((score * 10) / 1000.0)
1164                    ),
1165                    position=(
1166                        ts_h_offs + 20 + h_offs_extra,
1167                        v_offs_extra
1168                        + ts_height / 2
1169                        + -ts_height * (i + 1) / 10
1170                        + v_offs
1171                        + 11.0,
1172                    ),
1173                    h_align=Text.HAlign.RIGHT,
1174                    v_align=Text.VAlign.CENTER,
1175                    color=color0,
1176                    flash=flash,
1177                    transition=Text.Transition.IN_RIGHT,
1178                    transition_delay=tdelay1,
1179                ).autoretain()
1180            else:
1181                if is_me:
1182                    print('Error: got empty name_str on score result:', tval)
1183
1184            Text(
1185                bs.Lstr(value=name_str),
1186                position=(
1187                    ts_h_offs + 35 + h_offs_extra,
1188                    v_offs_extra
1189                    + ts_height / 2
1190                    + -ts_height * (i + 1) / 10
1191                    + v_offs_names
1192                    + v_offs
1193                    + 11.0,
1194                ),
1195                color=color1,
1196                maxwidth=160.0,
1197                v_align=Text.VAlign.CENTER,
1198                flash=flash,
1199                scale=scale,
1200                transition=Text.Transition.IN_RIGHT,
1201                transition_delay=tdelay2,
1202            ).autoretain()
1203
1204    def _got_score_results(self, results: dict[str, Any] | None) -> None:
1205        # pylint: disable=too-many-locals
1206        # pylint: disable=too-many-branches
1207        # pylint: disable=too-many-statements
1208
1209        plus = bs.app.plus
1210        assert plus is not None
1211
1212        # We need to manually run this in the context of our activity
1213        # and only if we aren't shutting down.
1214        # (really should make the submit_score call handle that stuff itself)
1215        if self.expired:
1216            return
1217        with self.context:
1218            # Delay a bit if results come in too fast.
1219            assert self._begin_time is not None
1220            base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
1221            # v_offs = 20
1222            v_offs = 64
1223            if results is None:
1224                self._score_loading_status = Text(
1225                    bs.Lstr(resource='worldScoresUnavailableText'),
1226                    position=(280, 130 + v_offs),
1227                    color=(1, 1, 1, 0.4),
1228                    transition=Text.Transition.FADE_IN,
1229                    transition_delay=base_delay + 0.3,
1230                    scale=0.7,
1231                )
1232            else:
1233                self._score_link = results['link']
1234                assert self._score_link is not None
1235                # Prepend our master-server addr if its a relative addr.
1236                if not self._score_link.startswith(
1237                    'http://'
1238                ) and not self._score_link.startswith('https://'):
1239                    self._score_link = (
1240                        plus.get_master_server_address()
1241                        + '/'
1242                        + self._score_link
1243                    )
1244                self._score_loading_status = None
1245                if 'tournamentSecondsRemaining' in results:
1246                    secs_remaining = results['tournamentSecondsRemaining']
1247                    assert isinstance(secs_remaining, int)
1248                    self._tournament_time_remaining = secs_remaining
1249                    self._tournament_time_remaining_text_timer = bs.BaseTimer(
1250                        1.0,
1251                        bs.WeakCall(
1252                            self._update_tournament_time_remaining_text
1253                        ),
1254                        repeat=True,
1255                    )
1256
1257            assert self._show_info is not None
1258            self._show_info['results'] = results
1259            if results is not None:
1260                if results['tops'] != '':
1261                    self._show_info['tops'] = results['tops']
1262                else:
1263                    self._show_info['tops'] = []
1264            offs_x = -195
1265            available = self._show_info['results'] is not None
1266            if self._score is not None:
1267                bs.basetimer(
1268                    (1.5 + base_delay),
1269                    bs.WeakCall(self._show_world_rank, offs_x),
1270                )
1271            ts_h_offs = 280
1272            ts_height = 300
1273
1274            # Show world tops.
1275            if available:
1276                # Show the number of games represented by this
1277                # list (except for in tournaments).
1278                if self.session.tournament_id is None:
1279                    Text(
1280                        bs.Lstr(
1281                            resource='lastGamesText',
1282                            subs=[
1283                                (
1284                                    '${COUNT}',
1285                                    str(self._show_info['results']['total']),
1286                                )
1287                            ],
1288                        ),
1289                        position=(
1290                            ts_h_offs - 35 + 95,
1291                            ts_height / 2 + 6 + v_offs - 41,
1292                        ),
1293                        color=(0.4, 0.4, 0.4, 1.0),
1294                        scale=0.7,
1295                        transition=Text.Transition.IN_RIGHT,
1296                        transition_delay=base_delay + 0.3,
1297                    ).autoretain()
1298                else:
1299                    v_offs += 40
1300
1301                h_offs_extra = 0
1302                v_offs_names = 0
1303                scale = 1.0
1304                p_count = len(self._playerinfos)
1305                if p_count > 1:
1306                    h_offs_extra -= 40
1307                if self._score_type != 'points':
1308                    h_offs_extra += 60
1309                if p_count == 2:
1310                    scale = 0.9
1311                elif p_count == 3:
1312                    scale = 0.65
1313                elif p_count == 4:
1314                    scale = 0.5
1315
1316                # Make sure there's at least 10.
1317                while len(self._show_info['tops']) < 10:
1318                    self._show_info['tops'].append([0, '-'])
1319
1320                times: list[tuple[float, float]] = []
1321                for i in range(len(self._show_info['tops'])):
1322                    times.insert(
1323                        random.randrange(0, len(times) + 1),
1324                        (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
1325                    )
1326
1327                # Conundrum: We want to place line numbers to the
1328                # left of our score column based on the largest
1329                # score width. However scores may use Lstrs and thus
1330                # may have different widths in different languages.
1331                # We don't want to bake down the Lstrs we display
1332                # because then clients can't view scores in their
1333                # own language. So as a compromise lets measure
1334                # max-width based on baked down Lstrs but then
1335                # display regular Lstrs with max-width set based on
1336                # that. Hopefully that'll look reasonable for most
1337                # languages.
1338                max_score_width = 10.0
1339                for tval in self._show_info['tops']:
1340                    score = int(tval[0])
1341                    name_str = tval[1]
1342                    if name_str != '-':
1343                        max_score_width = max(
1344                            max_score_width,
1345                            bui.get_string_width(
1346                                (
1347                                    str(score)
1348                                    if self._score_type == 'points'
1349                                    else bs.timestring(
1350                                        (score * 10) / 1000.0
1351                                    ).evaluate()
1352                                ),
1353                                suppress_warning=True,
1354                            ),
1355                        )
1356
1357                for i, tval in enumerate(self._show_info['tops']):
1358                    score = int(tval[0])
1359                    name_str = tval[1]
1360                    if self._name_str == name_str and self._score == score:
1361                        flash = True
1362                        color0 = (0.6, 0.4, 0.1, 1.0)
1363                        color1 = (0.6, 0.6, 0.6, 1.0)
1364                        tdelay1 = base_delay + 1.0
1365                        tdelay2 = base_delay + 1.0
1366                    else:
1367                        flash = False
1368                        if self._name_str == name_str:
1369                            color0 = (0.6, 0.4, 0.1, 1.0)
1370                            color1 = (0.9, 1.0, 0.9, 1.0)
1371                        else:
1372                            color0 = (0.6, 0.4, 0.1, 1.0)
1373                            color1 = (0.6, 0.6, 0.6, 1.0)
1374                        tdelay1 = times[i][0]
1375                        tdelay2 = times[i][1]
1376
1377                    if name_str != '-':
1378                        sstr = (
1379                            str(score)
1380                            if self._score_type == 'points'
1381                            else bs.timestring((score * 10) / 1000.0)
1382                        )
1383
1384                        # Line number.
1385                        Text(
1386                            str(i + 1),
1387                            position=(
1388                                ts_h_offs
1389                                + 20
1390                                + h_offs_extra
1391                                - max_score_width
1392                                - 8.0,
1393                                ts_height / 2
1394                                + -ts_height * (i + 1) / 10
1395                                + v_offs
1396                                - 30.0,
1397                            ),
1398                            scale=0.5,
1399                            h_align=Text.HAlign.RIGHT,
1400                            v_align=Text.VAlign.CENTER,
1401                            color=(0.3, 0.3, 0.3),
1402                            transition=Text.Transition.IN_LEFT,
1403                            transition_delay=tdelay1,
1404                        ).autoretain()
1405
1406                        # Score.
1407                        Text(
1408                            sstr,
1409                            position=(
1410                                ts_h_offs + 20 + h_offs_extra,
1411                                ts_height / 2
1412                                + -ts_height * (i + 1) / 10
1413                                + v_offs
1414                                - 30.0,
1415                            ),
1416                            maxwidth=max_score_width,
1417                            h_align=Text.HAlign.RIGHT,
1418                            v_align=Text.VAlign.CENTER,
1419                            color=color0,
1420                            flash=flash,
1421                            transition=Text.Transition.IN_LEFT,
1422                            transition_delay=tdelay1,
1423                        ).autoretain()
1424                    # Player name.
1425                    Text(
1426                        bs.Lstr(value=name_str),
1427                        position=(
1428                            ts_h_offs + 35 + h_offs_extra,
1429                            ts_height / 2
1430                            + -ts_height * (i + 1) / 10
1431                            + v_offs_names
1432                            + v_offs
1433                            - 30.0,
1434                        ),
1435                        maxwidth=80.0 + 100.0 * len(self._playerinfos),
1436                        v_align=Text.VAlign.CENTER,
1437                        color=color1,
1438                        flash=flash,
1439                        scale=scale,
1440                        transition=Text.Transition.IN_LEFT,
1441                        transition_delay=tdelay2,
1442                    ).autoretain()
1443
1444    def _show_tips(self) -> None:
1445        from bascenev1lib.actor.tipstext import TipsText
1446
1447        TipsText(offs_y=30).autoretain()
1448
1449    def _update_tournament_time_remaining_text(self) -> None:
1450        if self._tournament_time_remaining is None:
1451            return
1452        self._tournament_time_remaining = max(
1453            0, self._tournament_time_remaining - 1
1454        )
1455        if self._tournament_time_remaining_text is not None:
1456            val = bs.timestring(
1457                self._tournament_time_remaining,
1458                centi=False,
1459            )
1460            self._tournament_time_remaining_text.node.text = val
1461
1462    def _show_world_rank(self, offs_x: float) -> None:
1463        # FIXME: Tidy this up.
1464        # pylint: disable=too-many-locals
1465        # pylint: disable=too-many-branches
1466        # pylint: disable=too-many-statements
1467        assert bs.app.classic is not None
1468        assert self._show_info is not None
1469        available = self._show_info['results'] is not None
1470
1471        if available and self._submit_score:
1472            error = (
1473                self._show_info['results']['error']
1474                if 'error' in self._show_info['results']
1475                else None
1476            )
1477            rank = self._show_info['results']['rank']
1478            total = self._show_info['results']['total']
1479            rating = (
1480                10.0
1481                if total == 1
1482                else 10.0 * (1.0 - (float(rank - 1) / (total - 1)))
1483            )
1484            player_rank = self._show_info['results']['playerRank']
1485            best_player_rank = self._show_info['results']['bestPlayerRank']
1486        else:
1487            error = False
1488            rating = None
1489            player_rank = None
1490            best_player_rank = None
1491
1492        # If we've got tournament-seconds-remaining, show it.
1493        if self._tournament_time_remaining is not None:
1494            Text(
1495                bs.Lstr(resource='coopSelectWindow.timeRemainingText'),
1496                position=(-360, -70 - 100),
1497                color=(1, 1, 1, 0.7),
1498                h_align=Text.HAlign.CENTER,
1499                v_align=Text.VAlign.CENTER,
1500                transition=Text.Transition.FADE_IN,
1501                scale=0.8,
1502                maxwidth=300,
1503                transition_delay=2.0,
1504            ).autoretain()
1505            self._tournament_time_remaining_text = Text(
1506                '',
1507                position=(-360, -110 - 100),
1508                color=(1, 1, 1, 0.7),
1509                h_align=Text.HAlign.CENTER,
1510                v_align=Text.VAlign.CENTER,
1511                transition=Text.Transition.FADE_IN,
1512                scale=1.6,
1513                maxwidth=150,
1514                transition_delay=2.0,
1515            )
1516
1517        # If we're a tournament, show prizes.
1518        try:
1519            assert bs.app.classic is not None
1520            tournament_id = self.session.tournament_id
1521            if tournament_id is not None:
1522                if tournament_id in bs.app.classic.accounts.tournament_info:
1523                    tourney_info = bs.app.classic.accounts.tournament_info[
1524                        tournament_id
1525                    ]
1526                    # pylint: disable=useless-suppression
1527                    # pylint: disable=unbalanced-tuple-unpacking
1528                    (pr1, pv1, pr2, pv2, pr3, pv3) = (
1529                        bs.app.classic.get_tournament_prize_strings(
1530                            tourney_info, include_tickets=False
1531                        )
1532                    )
1533
1534                    # pylint: enable=unbalanced-tuple-unpacking
1535                    # pylint: enable=useless-suppression
1536
1537                    Text(
1538                        bs.Lstr(resource='coopSelectWindow.prizesText'),
1539                        position=(-360, -70 + 77),
1540                        color=(1, 1, 1, 0.7),
1541                        h_align=Text.HAlign.CENTER,
1542                        v_align=Text.VAlign.CENTER,
1543                        transition=Text.Transition.FADE_IN,
1544                        scale=1.0,
1545                        maxwidth=300,
1546                        transition_delay=2.0,
1547                    ).autoretain()
1548                    vval = -107 + 70
1549                    for i, rng, val in (
1550                        (0, pr1, pv1),
1551                        (1, pr2, pv2),
1552                        (2, pr3, pv3),
1553                    ):
1554                        Text(
1555                            rng,
1556                            position=(-430 + 10, vval),
1557                            color=(1, 1, 1, 0.7),
1558                            h_align=Text.HAlign.RIGHT,
1559                            v_align=Text.VAlign.CENTER,
1560                            transition=Text.Transition.FADE_IN,
1561                            scale=0.6,
1562                            maxwidth=300,
1563                            transition_delay=2.0,
1564                        ).autoretain()
1565                        Text(
1566                            val,
1567                            position=(-410 + 10, vval),
1568                            color=(0.7, 0.7, 0.7, 1.0),
1569                            h_align=Text.HAlign.LEFT,
1570                            v_align=Text.VAlign.CENTER,
1571                            transition=Text.Transition.FADE_IN,
1572                            scale=0.8,
1573                            maxwidth=300,
1574                            transition_delay=2.0,
1575                        ).autoretain()
1576                        bs.app.classic.create_in_game_tournament_prize_image(
1577                            tourney_info, i, (-410 + 70, vval)
1578                        )
1579                        vval -= 35
1580        except Exception:
1581            logging.exception('Error showing prize ranges.')
1582
1583        if self._do_new_rating:
1584            if error:
1585                ZoomText(
1586                    bs.Lstr(resource='failText'),
1587                    flash=True,
1588                    trail=True,
1589                    scale=1.0 if available else 0.333,
1590                    tilt_translate=0.11,
1591                    h_align='center',
1592                    position=(190 + offs_x, -60),
1593                    maxwidth=200,
1594                    jitter=1.0,
1595                ).autoretain()
1596                Text(
1597                    bs.Lstr(translate=('serverResponses', error)),
1598                    position=(0, -140),
1599                    color=(1, 1, 1, 0.7),
1600                    h_align=Text.HAlign.CENTER,
1601                    v_align=Text.VAlign.CENTER,
1602                    transition=Text.Transition.FADE_IN,
1603                    scale=0.9,
1604                    maxwidth=400,
1605                    transition_delay=1.0,
1606                ).autoretain()
1607            elif self._submit_score:
1608                ZoomText(
1609                    (
1610                        ('#' + str(player_rank))
1611                        if player_rank is not None
1612                        else bs.Lstr(resource='unavailableText')
1613                    ),
1614                    flash=True,
1615                    trail=True,
1616                    scale=1.0 if available else 0.333,
1617                    tilt_translate=0.11,
1618                    h_align='center',
1619                    position=(190 + offs_x, -60),
1620                    maxwidth=200,
1621                    jitter=1.0,
1622                ).autoretain()
1623
1624                Text(
1625                    bs.Lstr(
1626                        value='${A}:',
1627                        subs=[('${A}', bs.Lstr(resource='rankText'))],
1628                    ),
1629                    position=(0, 36),
1630                    maxwidth=300,
1631                    transition=Text.Transition.FADE_IN,
1632                    h_align=Text.HAlign.CENTER,
1633                    v_align=Text.VAlign.CENTER,
1634                    transition_delay=0,
1635                ).autoretain()
1636                if best_player_rank is not None:
1637                    Text(
1638                        bs.Lstr(
1639                            resource='currentStandingText',
1640                            fallback_resource='bestRankText',
1641                            subs=[('${RANK}', str(best_player_rank))],
1642                        ),
1643                        position=(0, -155),
1644                        color=(1, 1, 1, 0.7),
1645                        h_align=Text.HAlign.CENTER,
1646                        transition=Text.Transition.FADE_IN,
1647                        scale=0.7,
1648                        transition_delay=1.0,
1649                    ).autoretain()
1650        else:
1651            assert rating is not None
1652            ZoomText(
1653                (
1654                    f'{rating:.1f}'
1655                    if available
1656                    else bs.Lstr(resource='unavailableText')
1657                ),
1658                flash=True,
1659                trail=True,
1660                scale=0.6 if available else 0.333,
1661                tilt_translate=0.11,
1662                h_align='center',
1663                position=(190 + offs_x, -94),
1664                maxwidth=200,
1665                jitter=1.0,
1666            ).autoretain()
1667
1668            if available:
1669                if rating >= 9.5:
1670                    stars = 3
1671                elif rating >= 7.5:
1672                    stars = 2
1673                elif rating > 0.0:
1674                    stars = 1
1675                else:
1676                    stars = 0
1677                star_tex = bs.gettexture('star')
1678                star_x = 135 + offs_x
1679                for _i in range(stars):
1680                    img = bs.NodeActor(
1681                        bs.newnode(
1682                            'image',
1683                            attrs={
1684                                'texture': star_tex,
1685                                'position': (star_x, -16),
1686                                'scale': (62, 62),
1687                                'opacity': 1.0,
1688                                'color': (2.2, 1.2, 0.3),
1689                                'absolute_scale': True,
1690                            },
1691                        )
1692                    ).autoretain()
1693
1694                    assert img.node
1695                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1696                    star_x += 60
1697                for _i in range(3 - stars):
1698                    img = bs.NodeActor(
1699                        bs.newnode(
1700                            'image',
1701                            attrs={
1702                                'texture': star_tex,
1703                                'position': (star_x, -16),
1704                                'scale': (62, 62),
1705                                'opacity': 1.0,
1706                                'color': (0.3, 0.3, 0.3),
1707                                'absolute_scale': True,
1708                            },
1709                        )
1710                    ).autoretain()
1711                    assert img.node
1712                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1713                    star_x += 60
1714
1715                def dostar(
1716                    count: int, xval: float, offs_y: float, score: str
1717                ) -> None:
1718                    Text(
1719                        score + ' =',
1720                        position=(xval, -64 + offs_y),
1721                        color=(0.6, 0.6, 0.6, 0.6),
1722                        h_align=Text.HAlign.CENTER,
1723                        v_align=Text.VAlign.CENTER,
1724                        transition=Text.Transition.FADE_IN,
1725                        scale=0.4,
1726                        transition_delay=1.0,
1727                    ).autoretain()
1728                    stx = xval + 20
1729                    for _i2 in range(count):
1730                        img2 = bs.NodeActor(
1731                            bs.newnode(
1732                                'image',
1733                                attrs={
1734                                    'texture': star_tex,
1735                                    'position': (stx, -64 + offs_y),
1736                                    'scale': (12, 12),
1737                                    'opacity': 0.7,
1738                                    'color': (2.2, 1.2, 0.3),
1739                                    'absolute_scale': True,
1740                                },
1741                            )
1742                        ).autoretain()
1743                        assert img2.node
1744                        bs.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
1745                        stx += 13.0
1746
1747                dostar(1, -44 - 30, -112, '0.0')
1748                dostar(2, 10 - 30, -112, '7.5')
1749                dostar(3, 77 - 30, -112, '9.5')
1750            try:
1751                best_rank = self._campaign.getlevel(self._level_name).rating
1752            except Exception:
1753                best_rank = 0.0
1754
1755            if available:
1756                Text(
1757                    bs.Lstr(
1758                        resource='outOfText',
1759                        subs=[
1760                            (
1761                                '${RANK}',
1762                                str(int(self._show_info['results']['rank'])),
1763                            ),
1764                            (
1765                                '${ALL}',
1766                                str(self._show_info['results']['total']),
1767                            ),
1768                        ],
1769                    ),
1770                    position=(0, -155 if self._newly_complete else -145),
1771                    color=(1, 1, 1, 0.7),
1772                    h_align=Text.HAlign.CENTER,
1773                    transition=Text.Transition.FADE_IN,
1774                    scale=0.55,
1775                    transition_delay=1.0,
1776                ).autoretain()
1777
1778            new_best = best_rank > self._old_best_rank and best_rank > 0.0
1779            was_string = bs.Lstr(
1780                value=' ${A}',
1781                subs=[
1782                    ('${A}', bs.Lstr(resource='scoreWasText')),
1783                    ('${COUNT}', str(self._old_best_rank)),
1784                ],
1785            )
1786            if not self._newly_complete:
1787                Text(
1788                    (
1789                        bs.Lstr(
1790                            value='${A}${B}',
1791                            subs=[
1792                                (
1793                                    '${A}',
1794                                    bs.Lstr(resource='newPersonalBestText'),
1795                                ),
1796                                ('${B}', was_string),
1797                            ],
1798                        )
1799                        if new_best
1800                        else bs.Lstr(
1801                            resource='bestRatingText',
1802                            subs=[('${RATING}', str(best_rank))],
1803                        )
1804                    ),
1805                    position=(0, -165),
1806                    color=(1, 1, 1, 0.7),
1807                    flash=new_best,
1808                    h_align=Text.HAlign.CENTER,
1809                    transition=(
1810                        Text.Transition.IN_RIGHT
1811                        if new_best
1812                        else Text.Transition.FADE_IN
1813                    ),
1814                    scale=0.5,
1815                    transition_delay=1.0,
1816                ).autoretain()
1817
1818            Text(
1819                bs.Lstr(
1820                    value='${A}:',
1821                    subs=[('${A}', bs.Lstr(resource='ratingText'))],
1822                ),
1823                position=(0, 36),
1824                maxwidth=300,
1825                transition=Text.Transition.FADE_IN,
1826                h_align=Text.HAlign.CENTER,
1827                v_align=Text.VAlign.CENTER,
1828                transition_delay=0,
1829            ).autoretain()
1830
1831        if self._submit_score:
1832            bs.timer(0.35, self._score_display_sound.play)
1833            if not error:
1834                bs.timer(0.35, self.cymbal_sound.play)
1835
1836    def _show_fail(self) -> None:
1837        ZoomText(
1838            bs.Lstr(resource='failText'),
1839            maxwidth=300,
1840            flash=False,
1841            trail=True,
1842            h_align='center',
1843            tilt_translate=0.11,
1844            position=(0, 40),
1845            jitter=1.0,
1846        ).autoretain()
1847        if self._fail_message is not None:
1848            Text(
1849                self._fail_message,
1850                h_align=Text.HAlign.CENTER,
1851                position=(0, -130),
1852                maxwidth=300,
1853                color=(1, 1, 1, 0.5),
1854                transition=Text.Transition.FADE_IN,
1855                transition_delay=1.0,
1856            ).autoretain()
1857        bs.timer(0.35, self._score_display_sound.play)
1858
1859    def _show_score_val(self, offs_x: float) -> None:
1860        assert self._score_type is not None
1861        assert self._score is not None
1862        ZoomText(
1863            (
1864                str(self._score)
1865                if self._score_type == 'points'
1866                else bs.timestring((self._score * 10) / 1000.0)
1867            ),
1868            maxwidth=300,
1869            flash=True,
1870            trail=True,
1871            scale=1.0 if self._score_type == 'points' else 0.6,
1872            h_align='center',
1873            tilt_translate=0.11,
1874            position=(190 + offs_x, 115),
1875            jitter=1.0,
1876        ).autoretain()
1877        Text(
1878            (
1879                bs.Lstr(
1880                    value='${A}:',
1881                    subs=[('${A}', bs.Lstr(resource='finalScoreText'))],
1882                )
1883                if self._score_type == 'points'
1884                else bs.Lstr(
1885                    value='${A}:',
1886                    subs=[('${A}', bs.Lstr(resource='finalTimeText'))],
1887                )
1888            ),
1889            maxwidth=300,
1890            position=(0, 200),
1891            transition=Text.Transition.FADE_IN,
1892            h_align=Text.HAlign.CENTER,
1893            v_align=Text.VAlign.CENTER,
1894            transition_delay=0,
1895        ).autoretain()
1896        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        v_offs2 = -236.0
 362
 363        # We wanna prevent controllers users from popping up browsers
 364        # or game-center widgets in cases where they can't easily get back
 365        # to the game (like on mac).
 366        can_select_extra_buttons = bui.app.classic.platform == 'android'
 367
 368        bui.set_ui_input_device(None)  # Menu is up for grabs.
 369
 370        if self._have_achievements and self._account_has_achievements:
 371            bui.buttonwidget(
 372                parent=rootc,
 373                color=(0.45, 0.4, 0.5),
 374                position=(h_offs - 520, v_offs + 450 - 235 + 40),
 375                size=(300, 60),
 376                label=bui.Lstr(resource='achievementsText'),
 377                on_activate_call=bui.WeakCall(self._ui_show_achievements),
 378                transition_delay=delay + 1.5,
 379                icon=self._game_service_achievements_texture,
 380                icon_color=self._game_service_icon_color,
 381                autoselect=True,
 382                selectable=can_select_extra_buttons,
 383            )
 384
 385        if self._should_show_worlds_best_button():
 386            bui.buttonwidget(
 387                parent=rootc,
 388                color=(0.45, 0.4, 0.5),
 389                position=(240, v_offs2 + 439),
 390                size=(350, 62),
 391                label=(
 392                    bui.Lstr(resource='tournamentStandingsText')
 393                    if self.session.tournament_id is not None
 394                    else (
 395                        bui.Lstr(resource='worldsBestScoresText')
 396                        if self._score_type == 'points'
 397                        else bui.Lstr(resource='worldsBestTimesText')
 398                    )
 399                ),
 400                autoselect=True,
 401                on_activate_call=bui.WeakCall(self._ui_worlds_best),
 402                transition_delay=delay + 1.9,
 403                selectable=can_select_extra_buttons,
 404            )
 405        else:
 406            pass
 407
 408        show_next_button = self._is_more_levels and not (env.demo or env.arcade)
 409
 410        if not show_next_button:
 411            h_offs += 60
 412
 413        # Due to virtual-bounds changes, have to squish buttons a bit to
 414        # avoid overlapping with tips at bottom. Could look nicer to
 415        # rework things in the middle to get more space, but would
 416        # rather not touch this old code more than necessary.
 417        small_buttons = True
 418
 419        if small_buttons:
 420            menu_button = bui.buttonwidget(
 421                parent=rootc,
 422                autoselect=True,
 423                position=(h_offs - 130 - 45, v_offs + 40),
 424                size=(100, 50),
 425                label='',
 426                button_type='square',
 427                on_activate_call=bui.WeakCall(self._ui_menu),
 428            )
 429            bui.imagewidget(
 430                parent=rootc,
 431                draw_controller=menu_button,
 432                position=(h_offs - 130 - 60 + 43, v_offs + 43),
 433                size=(45, 45),
 434                texture=self._menu_icon_texture,
 435                opacity=0.8,
 436            )
 437        else:
 438            menu_button = bui.buttonwidget(
 439                parent=rootc,
 440                autoselect=True,
 441                position=(h_offs - 130 - 60, v_offs),
 442                size=(110, 85),
 443                label='',
 444                on_activate_call=bui.WeakCall(self._ui_menu),
 445            )
 446            bui.imagewidget(
 447                parent=rootc,
 448                draw_controller=menu_button,
 449                position=(h_offs - 130 - 60 + 22, v_offs + 14),
 450                size=(60, 60),
 451                texture=self._menu_icon_texture,
 452                opacity=0.8,
 453            )
 454
 455        if small_buttons:
 456            self._restart_button = restart_button = bui.buttonwidget(
 457                parent=rootc,
 458                autoselect=True,
 459                position=(h_offs - 60, v_offs + 40),
 460                size=(100, 50),
 461                label='',
 462                button_type='square',
 463                on_activate_call=bui.WeakCall(self._ui_restart),
 464            )
 465            bui.imagewidget(
 466                parent=rootc,
 467                draw_controller=restart_button,
 468                position=(h_offs - 60 + 25, v_offs + 42),
 469                size=(47, 47),
 470                texture=self._replay_icon_texture,
 471                opacity=0.8,
 472            )
 473        else:
 474            self._restart_button = restart_button = bui.buttonwidget(
 475                parent=rootc,
 476                autoselect=True,
 477                position=(h_offs - 60, v_offs),
 478                size=(110, 85),
 479                label='',
 480                on_activate_call=bui.WeakCall(self._ui_restart),
 481            )
 482            bui.imagewidget(
 483                parent=rootc,
 484                draw_controller=restart_button,
 485                position=(h_offs - 60 + 19, v_offs + 7),
 486                size=(70, 70),
 487                texture=self._replay_icon_texture,
 488                opacity=0.8,
 489            )
 490
 491        next_button: bui.Widget | None = None
 492
 493        # Our 'next' button is disabled if we haven't unlocked the next
 494        # level yet and invisible if there is none.
 495        if show_next_button:
 496            if self._is_complete:
 497                call = bui.WeakCall(self._ui_next)
 498                button_sound = True
 499                image_opacity = 0.8
 500                color = None
 501            else:
 502                call = bui.WeakCall(self._ui_error)
 503                button_sound = False
 504                image_opacity = 0.2
 505                color = (0.3, 0.3, 0.3)
 506
 507            if small_buttons:
 508                next_button = bui.buttonwidget(
 509                    parent=rootc,
 510                    autoselect=True,
 511                    position=(h_offs + 130 - 75, v_offs + 40),
 512                    size=(100, 50),
 513                    label='',
 514                    button_type='square',
 515                    on_activate_call=call,
 516                    color=color,
 517                    enable_sound=button_sound,
 518                )
 519                bui.imagewidget(
 520                    parent=rootc,
 521                    draw_controller=next_button,
 522                    position=(h_offs + 130 - 60 + 12, v_offs + 40),
 523                    size=(50, 50),
 524                    texture=self._next_level_icon_texture,
 525                    opacity=image_opacity,
 526                )
 527            else:
 528                next_button = bui.buttonwidget(
 529                    parent=rootc,
 530                    autoselect=True,
 531                    position=(h_offs + 130 - 60, v_offs),
 532                    size=(110, 85),
 533                    label='',
 534                    on_activate_call=call,
 535                    color=color,
 536                    enable_sound=button_sound,
 537                )
 538                bui.imagewidget(
 539                    parent=rootc,
 540                    draw_controller=next_button,
 541                    position=(h_offs + 130 - 60 + 12, v_offs + 5),
 542                    size=(80, 80),
 543                    texture=self._next_level_icon_texture,
 544                    opacity=image_opacity,
 545                )
 546
 547        x_offs_extra = 0 if show_next_button else -100
 548        self._corner_button_offs = (
 549            h_offs + 300.0 + x_offs_extra,
 550            v_offs + 519.0,
 551        )
 552
 553        bui.containerwidget(
 554            edit=rootc,
 555            selected_child=(
 556                next_button
 557                if (self._newly_complete and self._victory and show_next_button)
 558                else restart_button
 559            ),
 560            on_cancel_call=menu_button.activate,
 561        )
 562
 563    def _player_press(self) -> None:
 564        # (Only for headless builds).
 565
 566        # If this activity is a good 'end point', ask server-mode just
 567        # once if it wants to do anything special like switch sessions
 568        # or kill the app.
 569        if (
 570            self._allow_server_transition
 571            and bs.app.classic is not None
 572            and bs.app.classic.server is not None
 573            and self._server_transitioning is None
 574        ):
 575            self._server_transitioning = (
 576                bs.app.classic.server.handle_transition()
 577            )
 578            assert isinstance(self._server_transitioning, bool)
 579
 580        # If server-mode is handling this, don't do anything ourself.
 581        if self._server_transitioning is True:
 582            return
 583
 584        # Otherwise restart current level.
 585        self._campaign.set_selected_level(self._level_name)
 586        with self.context:
 587            self.end({'outcome': 'restart'})
 588
 589    def _safe_assign(self, player: bs.Player) -> None:
 590        # (Only for headless builds).
 591
 592        # Just to be extra careful, don't assign if we're transitioning out.
 593        # (though theoretically that should be ok).
 594        if not self.is_transitioning_out() and player:
 595            player.assigninput(
 596                (
 597                    bs.InputType.JUMP_PRESS,
 598                    bs.InputType.PUNCH_PRESS,
 599                    bs.InputType.BOMB_PRESS,
 600                    bs.InputType.PICK_UP_PRESS,
 601                ),
 602                self._player_press,
 603            )
 604
 605    @override
 606    def on_player_join(self, player: bs.Player) -> None:
 607        super().on_player_join(player)
 608
 609        if bs.app.classic is not None and bs.app.classic.server is not None:
 610            # Host can't press retry button, so anyone can do it instead.
 611            time_till_assign = max(
 612                0, self._birth_time + self._min_view_time - bs.time()
 613            )
 614
 615            bs.timer(time_till_assign, bs.WeakCall(self._safe_assign, player))
 616
 617    @override
 618    def on_begin(self) -> None:
 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 = 290
 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                    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 (never want clients
1029        # to see it so no need for client-only version, etc).
1030        if self._have_achievements:
1031            if not self._account_has_achievements:
1032                Text(
1033                    bs.Lstr(resource='achievementsText'),
1034                    position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
1035                    maxwidth=210,
1036                    host_only=True,
1037                    transition=Text.Transition.IN_RIGHT,
1038                    v_align=Text.VAlign.CENTER,
1039                    scale=1.2,
1040                    transition_delay=2.8,
1041                ).autoretain()
1042
1043            assert self._game_name_str is not None
1044            assert bs.app.classic is not None
1045            achievements = bs.app.classic.ach.achievements_for_coop_level(
1046                self._game_name_str
1047            )
1048            hval = -455
1049            vval = -100
1050            tdelay = 0.0
1051            for ach in achievements:
1052                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
1053                vval -= 55
1054                tdelay += 0.250
1055
1056        bs.timer(5.0, bs.WeakCall(self._show_tips))
1057
1058    def _play_drumroll(self) -> None:
1059        bs.NodeActor(
1060            bs.newnode(
1061                'sound',
1062                attrs={
1063                    'sound': self.drum_roll_sound,
1064                    'positional': False,
1065                    'loop': False,
1066                },
1067            )
1068        ).autoretain()
1069
1070    def _got_friend_score_results(self, results: list[Any] | None) -> None:
1071        # pylint: disable=too-many-locals
1072        # pylint: disable=too-many-branches
1073        # pylint: disable=too-many-statements
1074        from efro.util import asserttype
1075
1076        # delay a bit if results come in too fast
1077        assert self._begin_time is not None
1078        base_delay = max(0, 1.9 - (bs.time() - self._begin_time))
1079        ts_height = 300
1080        ts_h_offs = -550
1081        v_offs = 30
1082
1083        # Report in case of error.
1084        if results is None:
1085            self._friends_loading_status = Text(
1086                bs.Lstr(resource='friendScoresUnavailableText'),
1087                maxwidth=330,
1088                position=(-475, 150 + v_offs),
1089                color=(1, 1, 1, 0.4),
1090                transition=Text.Transition.FADE_IN,
1091                transition_delay=base_delay + 0.8,
1092                scale=0.7,
1093            )
1094            return
1095
1096        self._friends_loading_status = None
1097
1098        # Ok, it looks like we aren't able to reliably get a just-submitted
1099        # result returned in the score list, so we need to look for our score
1100        # in this list and replace it if ours is better or add ours otherwise.
1101        if self._score is not None:
1102            our_score_entry = [self._score, 'Me', True]
1103            for score in results:
1104                if score[2]:
1105                    if self._score_order == 'increasing':
1106                        our_score_entry[0] = max(score[0], self._score)
1107                    else:
1108                        our_score_entry[0] = min(score[0], self._score)
1109                    results.remove(score)
1110                    break
1111            results.append(our_score_entry)
1112            results.sort(
1113                reverse=self._score_order == 'increasing',
1114                key=lambda x: asserttype(x[0], int),
1115            )
1116
1117        # If we're not submitting our own score, we still want to change the
1118        # name of our own score to 'Me'.
1119        else:
1120            for score in results:
1121                if score[2]:
1122                    score[1] = 'Me'
1123                    break
1124        h_offs_extra = 80 if self._score_type == 'points' else 130
1125        v_offs_extra = 20
1126        v_offs_names = 0
1127        scale = 1.0
1128
1129        # Make sure there's at least 5.
1130        while len(results) < 5:
1131            results.append([0, '-', False])
1132        results = results[:5]
1133        times: list[tuple[float, float]] = []
1134        for i in range(len(results)):
1135            times.insert(
1136                random.randrange(0, len(times) + 1),
1137                (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05),
1138            )
1139        for i, tval in enumerate(results):
1140            score = int(tval[0])
1141            name_str = tval[1]
1142            is_me = tval[2]
1143            if is_me and score == self._score:
1144                flash = True
1145                color0 = (0.6, 0.4, 0.1, 1.0)
1146                color1 = (0.6, 0.6, 0.6, 1.0)
1147                tdelay1 = base_delay + 1.0
1148                tdelay2 = base_delay + 1.0
1149            else:
1150                flash = False
1151                if is_me:
1152                    color0 = (0.6, 0.4, 0.1, 1.0)
1153                    color1 = (0.9, 1.0, 0.9, 1.0)
1154                else:
1155                    color0 = (0.6, 0.4, 0.1, 1.0)
1156                    color1 = (0.6, 0.6, 0.6, 1.0)
1157                tdelay1 = times[i][0]
1158                tdelay2 = times[i][1]
1159            if name_str != '-':
1160                Text(
1161                    (
1162                        str(score)
1163                        if self._score_type == 'points'
1164                        else bs.timestring((score * 10) / 1000.0)
1165                    ),
1166                    position=(
1167                        ts_h_offs + 20 + h_offs_extra,
1168                        v_offs_extra
1169                        + ts_height / 2
1170                        + -ts_height * (i + 1) / 10
1171                        + v_offs
1172                        + 11.0,
1173                    ),
1174                    h_align=Text.HAlign.RIGHT,
1175                    v_align=Text.VAlign.CENTER,
1176                    color=color0,
1177                    flash=flash,
1178                    transition=Text.Transition.IN_RIGHT,
1179                    transition_delay=tdelay1,
1180                ).autoretain()
1181            else:
1182                if is_me:
1183                    print('Error: got empty name_str on score result:', tval)
1184
1185            Text(
1186                bs.Lstr(value=name_str),
1187                position=(
1188                    ts_h_offs + 35 + h_offs_extra,
1189                    v_offs_extra
1190                    + ts_height / 2
1191                    + -ts_height * (i + 1) / 10
1192                    + v_offs_names
1193                    + v_offs
1194                    + 11.0,
1195                ),
1196                color=color1,
1197                maxwidth=160.0,
1198                v_align=Text.VAlign.CENTER,
1199                flash=flash,
1200                scale=scale,
1201                transition=Text.Transition.IN_RIGHT,
1202                transition_delay=tdelay2,
1203            ).autoretain()
1204
1205    def _got_score_results(self, results: dict[str, Any] | None) -> None:
1206        # pylint: disable=too-many-locals
1207        # pylint: disable=too-many-branches
1208        # pylint: disable=too-many-statements
1209
1210        plus = bs.app.plus
1211        assert plus is not None
1212
1213        # We need to manually run this in the context of our activity
1214        # and only if we aren't shutting down.
1215        # (really should make the submit_score call handle that stuff itself)
1216        if self.expired:
1217            return
1218        with self.context:
1219            # Delay a bit if results come in too fast.
1220            assert self._begin_time is not None
1221            base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
1222            # v_offs = 20
1223            v_offs = 64
1224            if results is None:
1225                self._score_loading_status = Text(
1226                    bs.Lstr(resource='worldScoresUnavailableText'),
1227                    position=(280, 130 + v_offs),
1228                    color=(1, 1, 1, 0.4),
1229                    transition=Text.Transition.FADE_IN,
1230                    transition_delay=base_delay + 0.3,
1231                    scale=0.7,
1232                )
1233            else:
1234                self._score_link = results['link']
1235                assert self._score_link is not None
1236                # Prepend our master-server addr if its a relative addr.
1237                if not self._score_link.startswith(
1238                    'http://'
1239                ) and not self._score_link.startswith('https://'):
1240                    self._score_link = (
1241                        plus.get_master_server_address()
1242                        + '/'
1243                        + self._score_link
1244                    )
1245                self._score_loading_status = None
1246                if 'tournamentSecondsRemaining' in results:
1247                    secs_remaining = results['tournamentSecondsRemaining']
1248                    assert isinstance(secs_remaining, int)
1249                    self._tournament_time_remaining = secs_remaining
1250                    self._tournament_time_remaining_text_timer = bs.BaseTimer(
1251                        1.0,
1252                        bs.WeakCall(
1253                            self._update_tournament_time_remaining_text
1254                        ),
1255                        repeat=True,
1256                    )
1257
1258            assert self._show_info is not None
1259            self._show_info['results'] = results
1260            if results is not None:
1261                if results['tops'] != '':
1262                    self._show_info['tops'] = results['tops']
1263                else:
1264                    self._show_info['tops'] = []
1265            offs_x = -195
1266            available = self._show_info['results'] is not None
1267            if self._score is not None:
1268                bs.basetimer(
1269                    (1.5 + base_delay),
1270                    bs.WeakCall(self._show_world_rank, offs_x),
1271                )
1272            ts_h_offs = 280
1273            ts_height = 300
1274
1275            # Show world tops.
1276            if available:
1277                # Show the number of games represented by this
1278                # list (except for in tournaments).
1279                if self.session.tournament_id is None:
1280                    Text(
1281                        bs.Lstr(
1282                            resource='lastGamesText',
1283                            subs=[
1284                                (
1285                                    '${COUNT}',
1286                                    str(self._show_info['results']['total']),
1287                                )
1288                            ],
1289                        ),
1290                        position=(
1291                            ts_h_offs - 35 + 95,
1292                            ts_height / 2 + 6 + v_offs - 41,
1293                        ),
1294                        color=(0.4, 0.4, 0.4, 1.0),
1295                        scale=0.7,
1296                        transition=Text.Transition.IN_RIGHT,
1297                        transition_delay=base_delay + 0.3,
1298                    ).autoretain()
1299                else:
1300                    v_offs += 40
1301
1302                h_offs_extra = 0
1303                v_offs_names = 0
1304                scale = 1.0
1305                p_count = len(self._playerinfos)
1306                if p_count > 1:
1307                    h_offs_extra -= 40
1308                if self._score_type != 'points':
1309                    h_offs_extra += 60
1310                if p_count == 2:
1311                    scale = 0.9
1312                elif p_count == 3:
1313                    scale = 0.65
1314                elif p_count == 4:
1315                    scale = 0.5
1316
1317                # Make sure there's at least 10.
1318                while len(self._show_info['tops']) < 10:
1319                    self._show_info['tops'].append([0, '-'])
1320
1321                times: list[tuple[float, float]] = []
1322                for i in range(len(self._show_info['tops'])):
1323                    times.insert(
1324                        random.randrange(0, len(times) + 1),
1325                        (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
1326                    )
1327
1328                # Conundrum: We want to place line numbers to the
1329                # left of our score column based on the largest
1330                # score width. However scores may use Lstrs and thus
1331                # may have different widths in different languages.
1332                # We don't want to bake down the Lstrs we display
1333                # because then clients can't view scores in their
1334                # own language. So as a compromise lets measure
1335                # max-width based on baked down Lstrs but then
1336                # display regular Lstrs with max-width set based on
1337                # that. Hopefully that'll look reasonable for most
1338                # languages.
1339                max_score_width = 10.0
1340                for tval in self._show_info['tops']:
1341                    score = int(tval[0])
1342                    name_str = tval[1]
1343                    if name_str != '-':
1344                        max_score_width = max(
1345                            max_score_width,
1346                            bui.get_string_width(
1347                                (
1348                                    str(score)
1349                                    if self._score_type == 'points'
1350                                    else bs.timestring(
1351                                        (score * 10) / 1000.0
1352                                    ).evaluate()
1353                                ),
1354                                suppress_warning=True,
1355                            ),
1356                        )
1357
1358                for i, tval in enumerate(self._show_info['tops']):
1359                    score = int(tval[0])
1360                    name_str = tval[1]
1361                    if self._name_str == name_str and self._score == score:
1362                        flash = True
1363                        color0 = (0.6, 0.4, 0.1, 1.0)
1364                        color1 = (0.6, 0.6, 0.6, 1.0)
1365                        tdelay1 = base_delay + 1.0
1366                        tdelay2 = base_delay + 1.0
1367                    else:
1368                        flash = False
1369                        if self._name_str == name_str:
1370                            color0 = (0.6, 0.4, 0.1, 1.0)
1371                            color1 = (0.9, 1.0, 0.9, 1.0)
1372                        else:
1373                            color0 = (0.6, 0.4, 0.1, 1.0)
1374                            color1 = (0.6, 0.6, 0.6, 1.0)
1375                        tdelay1 = times[i][0]
1376                        tdelay2 = times[i][1]
1377
1378                    if name_str != '-':
1379                        sstr = (
1380                            str(score)
1381                            if self._score_type == 'points'
1382                            else bs.timestring((score * 10) / 1000.0)
1383                        )
1384
1385                        # Line number.
1386                        Text(
1387                            str(i + 1),
1388                            position=(
1389                                ts_h_offs
1390                                + 20
1391                                + h_offs_extra
1392                                - max_score_width
1393                                - 8.0,
1394                                ts_height / 2
1395                                + -ts_height * (i + 1) / 10
1396                                + v_offs
1397                                - 30.0,
1398                            ),
1399                            scale=0.5,
1400                            h_align=Text.HAlign.RIGHT,
1401                            v_align=Text.VAlign.CENTER,
1402                            color=(0.3, 0.3, 0.3),
1403                            transition=Text.Transition.IN_LEFT,
1404                            transition_delay=tdelay1,
1405                        ).autoretain()
1406
1407                        # Score.
1408                        Text(
1409                            sstr,
1410                            position=(
1411                                ts_h_offs + 20 + h_offs_extra,
1412                                ts_height / 2
1413                                + -ts_height * (i + 1) / 10
1414                                + v_offs
1415                                - 30.0,
1416                            ),
1417                            maxwidth=max_score_width,
1418                            h_align=Text.HAlign.RIGHT,
1419                            v_align=Text.VAlign.CENTER,
1420                            color=color0,
1421                            flash=flash,
1422                            transition=Text.Transition.IN_LEFT,
1423                            transition_delay=tdelay1,
1424                        ).autoretain()
1425                    # Player name.
1426                    Text(
1427                        bs.Lstr(value=name_str),
1428                        position=(
1429                            ts_h_offs + 35 + h_offs_extra,
1430                            ts_height / 2
1431                            + -ts_height * (i + 1) / 10
1432                            + v_offs_names
1433                            + v_offs
1434                            - 30.0,
1435                        ),
1436                        maxwidth=80.0 + 100.0 * len(self._playerinfos),
1437                        v_align=Text.VAlign.CENTER,
1438                        color=color1,
1439                        flash=flash,
1440                        scale=scale,
1441                        transition=Text.Transition.IN_LEFT,
1442                        transition_delay=tdelay2,
1443                    ).autoretain()
1444
1445    def _show_tips(self) -> None:
1446        from bascenev1lib.actor.tipstext import TipsText
1447
1448        TipsText(offs_y=30).autoretain()
1449
1450    def _update_tournament_time_remaining_text(self) -> None:
1451        if self._tournament_time_remaining is None:
1452            return
1453        self._tournament_time_remaining = max(
1454            0, self._tournament_time_remaining - 1
1455        )
1456        if self._tournament_time_remaining_text is not None:
1457            val = bs.timestring(
1458                self._tournament_time_remaining,
1459                centi=False,
1460            )
1461            self._tournament_time_remaining_text.node.text = val
1462
1463    def _show_world_rank(self, offs_x: float) -> None:
1464        # FIXME: Tidy this up.
1465        # pylint: disable=too-many-locals
1466        # pylint: disable=too-many-branches
1467        # pylint: disable=too-many-statements
1468        assert bs.app.classic is not None
1469        assert self._show_info is not None
1470        available = self._show_info['results'] is not None
1471
1472        if available and self._submit_score:
1473            error = (
1474                self._show_info['results']['error']
1475                if 'error' in self._show_info['results']
1476                else None
1477            )
1478            rank = self._show_info['results']['rank']
1479            total = self._show_info['results']['total']
1480            rating = (
1481                10.0
1482                if total == 1
1483                else 10.0 * (1.0 - (float(rank - 1) / (total - 1)))
1484            )
1485            player_rank = self._show_info['results']['playerRank']
1486            best_player_rank = self._show_info['results']['bestPlayerRank']
1487        else:
1488            error = False
1489            rating = None
1490            player_rank = None
1491            best_player_rank = None
1492
1493        # If we've got tournament-seconds-remaining, show it.
1494        if self._tournament_time_remaining is not None:
1495            Text(
1496                bs.Lstr(resource='coopSelectWindow.timeRemainingText'),
1497                position=(-360, -70 - 100),
1498                color=(1, 1, 1, 0.7),
1499                h_align=Text.HAlign.CENTER,
1500                v_align=Text.VAlign.CENTER,
1501                transition=Text.Transition.FADE_IN,
1502                scale=0.8,
1503                maxwidth=300,
1504                transition_delay=2.0,
1505            ).autoretain()
1506            self._tournament_time_remaining_text = Text(
1507                '',
1508                position=(-360, -110 - 100),
1509                color=(1, 1, 1, 0.7),
1510                h_align=Text.HAlign.CENTER,
1511                v_align=Text.VAlign.CENTER,
1512                transition=Text.Transition.FADE_IN,
1513                scale=1.6,
1514                maxwidth=150,
1515                transition_delay=2.0,
1516            )
1517
1518        # If we're a tournament, show prizes.
1519        try:
1520            assert bs.app.classic is not None
1521            tournament_id = self.session.tournament_id
1522            if tournament_id is not None:
1523                if tournament_id in bs.app.classic.accounts.tournament_info:
1524                    tourney_info = bs.app.classic.accounts.tournament_info[
1525                        tournament_id
1526                    ]
1527                    # pylint: disable=useless-suppression
1528                    # pylint: disable=unbalanced-tuple-unpacking
1529                    (pr1, pv1, pr2, pv2, pr3, pv3) = (
1530                        bs.app.classic.get_tournament_prize_strings(
1531                            tourney_info, include_tickets=False
1532                        )
1533                    )
1534
1535                    # pylint: enable=unbalanced-tuple-unpacking
1536                    # pylint: enable=useless-suppression
1537
1538                    Text(
1539                        bs.Lstr(resource='coopSelectWindow.prizesText'),
1540                        position=(-360, -70 + 77),
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=1.0,
1546                        maxwidth=300,
1547                        transition_delay=2.0,
1548                    ).autoretain()
1549                    vval = -107 + 70
1550                    for i, rng, val in (
1551                        (0, pr1, pv1),
1552                        (1, pr2, pv2),
1553                        (2, pr3, pv3),
1554                    ):
1555                        Text(
1556                            rng,
1557                            position=(-430 + 10, vval),
1558                            color=(1, 1, 1, 0.7),
1559                            h_align=Text.HAlign.RIGHT,
1560                            v_align=Text.VAlign.CENTER,
1561                            transition=Text.Transition.FADE_IN,
1562                            scale=0.6,
1563                            maxwidth=300,
1564                            transition_delay=2.0,
1565                        ).autoretain()
1566                        Text(
1567                            val,
1568                            position=(-410 + 10, vval),
1569                            color=(0.7, 0.7, 0.7, 1.0),
1570                            h_align=Text.HAlign.LEFT,
1571                            v_align=Text.VAlign.CENTER,
1572                            transition=Text.Transition.FADE_IN,
1573                            scale=0.8,
1574                            maxwidth=300,
1575                            transition_delay=2.0,
1576                        ).autoretain()
1577                        bs.app.classic.create_in_game_tournament_prize_image(
1578                            tourney_info, i, (-410 + 70, vval)
1579                        )
1580                        vval -= 35
1581        except Exception:
1582            logging.exception('Error showing prize ranges.')
1583
1584        if self._do_new_rating:
1585            if error:
1586                ZoomText(
1587                    bs.Lstr(resource='failText'),
1588                    flash=True,
1589                    trail=True,
1590                    scale=1.0 if available else 0.333,
1591                    tilt_translate=0.11,
1592                    h_align='center',
1593                    position=(190 + offs_x, -60),
1594                    maxwidth=200,
1595                    jitter=1.0,
1596                ).autoretain()
1597                Text(
1598                    bs.Lstr(translate=('serverResponses', error)),
1599                    position=(0, -140),
1600                    color=(1, 1, 1, 0.7),
1601                    h_align=Text.HAlign.CENTER,
1602                    v_align=Text.VAlign.CENTER,
1603                    transition=Text.Transition.FADE_IN,
1604                    scale=0.9,
1605                    maxwidth=400,
1606                    transition_delay=1.0,
1607                ).autoretain()
1608            elif self._submit_score:
1609                ZoomText(
1610                    (
1611                        ('#' + str(player_rank))
1612                        if player_rank is not None
1613                        else bs.Lstr(resource='unavailableText')
1614                    ),
1615                    flash=True,
1616                    trail=True,
1617                    scale=1.0 if available else 0.333,
1618                    tilt_translate=0.11,
1619                    h_align='center',
1620                    position=(190 + offs_x, -60),
1621                    maxwidth=200,
1622                    jitter=1.0,
1623                ).autoretain()
1624
1625                Text(
1626                    bs.Lstr(
1627                        value='${A}:',
1628                        subs=[('${A}', bs.Lstr(resource='rankText'))],
1629                    ),
1630                    position=(0, 36),
1631                    maxwidth=300,
1632                    transition=Text.Transition.FADE_IN,
1633                    h_align=Text.HAlign.CENTER,
1634                    v_align=Text.VAlign.CENTER,
1635                    transition_delay=0,
1636                ).autoretain()
1637                if best_player_rank is not None:
1638                    Text(
1639                        bs.Lstr(
1640                            resource='currentStandingText',
1641                            fallback_resource='bestRankText',
1642                            subs=[('${RANK}', str(best_player_rank))],
1643                        ),
1644                        position=(0, -155),
1645                        color=(1, 1, 1, 0.7),
1646                        h_align=Text.HAlign.CENTER,
1647                        transition=Text.Transition.FADE_IN,
1648                        scale=0.7,
1649                        transition_delay=1.0,
1650                    ).autoretain()
1651        else:
1652            assert rating is not None
1653            ZoomText(
1654                (
1655                    f'{rating:.1f}'
1656                    if available
1657                    else bs.Lstr(resource='unavailableText')
1658                ),
1659                flash=True,
1660                trail=True,
1661                scale=0.6 if available else 0.333,
1662                tilt_translate=0.11,
1663                h_align='center',
1664                position=(190 + offs_x, -94),
1665                maxwidth=200,
1666                jitter=1.0,
1667            ).autoretain()
1668
1669            if available:
1670                if rating >= 9.5:
1671                    stars = 3
1672                elif rating >= 7.5:
1673                    stars = 2
1674                elif rating > 0.0:
1675                    stars = 1
1676                else:
1677                    stars = 0
1678                star_tex = bs.gettexture('star')
1679                star_x = 135 + offs_x
1680                for _i in range(stars):
1681                    img = bs.NodeActor(
1682                        bs.newnode(
1683                            'image',
1684                            attrs={
1685                                'texture': star_tex,
1686                                'position': (star_x, -16),
1687                                'scale': (62, 62),
1688                                'opacity': 1.0,
1689                                'color': (2.2, 1.2, 0.3),
1690                                'absolute_scale': True,
1691                            },
1692                        )
1693                    ).autoretain()
1694
1695                    assert img.node
1696                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1697                    star_x += 60
1698                for _i in range(3 - stars):
1699                    img = bs.NodeActor(
1700                        bs.newnode(
1701                            'image',
1702                            attrs={
1703                                'texture': star_tex,
1704                                'position': (star_x, -16),
1705                                'scale': (62, 62),
1706                                'opacity': 1.0,
1707                                'color': (0.3, 0.3, 0.3),
1708                                'absolute_scale': True,
1709                            },
1710                        )
1711                    ).autoretain()
1712                    assert img.node
1713                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1714                    star_x += 60
1715
1716                def dostar(
1717                    count: int, xval: float, offs_y: float, score: str
1718                ) -> None:
1719                    Text(
1720                        score + ' =',
1721                        position=(xval, -64 + offs_y),
1722                        color=(0.6, 0.6, 0.6, 0.6),
1723                        h_align=Text.HAlign.CENTER,
1724                        v_align=Text.VAlign.CENTER,
1725                        transition=Text.Transition.FADE_IN,
1726                        scale=0.4,
1727                        transition_delay=1.0,
1728                    ).autoretain()
1729                    stx = xval + 20
1730                    for _i2 in range(count):
1731                        img2 = bs.NodeActor(
1732                            bs.newnode(
1733                                'image',
1734                                attrs={
1735                                    'texture': star_tex,
1736                                    'position': (stx, -64 + offs_y),
1737                                    'scale': (12, 12),
1738                                    'opacity': 0.7,
1739                                    'color': (2.2, 1.2, 0.3),
1740                                    'absolute_scale': True,
1741                                },
1742                            )
1743                        ).autoretain()
1744                        assert img2.node
1745                        bs.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
1746                        stx += 13.0
1747
1748                dostar(1, -44 - 30, -112, '0.0')
1749                dostar(2, 10 - 30, -112, '7.5')
1750                dostar(3, 77 - 30, -112, '9.5')
1751            try:
1752                best_rank = self._campaign.getlevel(self._level_name).rating
1753            except Exception:
1754                best_rank = 0.0
1755
1756            if available:
1757                Text(
1758                    bs.Lstr(
1759                        resource='outOfText',
1760                        subs=[
1761                            (
1762                                '${RANK}',
1763                                str(int(self._show_info['results']['rank'])),
1764                            ),
1765                            (
1766                                '${ALL}',
1767                                str(self._show_info['results']['total']),
1768                            ),
1769                        ],
1770                    ),
1771                    position=(0, -155 if self._newly_complete else -145),
1772                    color=(1, 1, 1, 0.7),
1773                    h_align=Text.HAlign.CENTER,
1774                    transition=Text.Transition.FADE_IN,
1775                    scale=0.55,
1776                    transition_delay=1.0,
1777                ).autoretain()
1778
1779            new_best = best_rank > self._old_best_rank and best_rank > 0.0
1780            was_string = bs.Lstr(
1781                value=' ${A}',
1782                subs=[
1783                    ('${A}', bs.Lstr(resource='scoreWasText')),
1784                    ('${COUNT}', str(self._old_best_rank)),
1785                ],
1786            )
1787            if not self._newly_complete:
1788                Text(
1789                    (
1790                        bs.Lstr(
1791                            value='${A}${B}',
1792                            subs=[
1793                                (
1794                                    '${A}',
1795                                    bs.Lstr(resource='newPersonalBestText'),
1796                                ),
1797                                ('${B}', was_string),
1798                            ],
1799                        )
1800                        if new_best
1801                        else bs.Lstr(
1802                            resource='bestRatingText',
1803                            subs=[('${RATING}', str(best_rank))],
1804                        )
1805                    ),
1806                    position=(0, -165),
1807                    color=(1, 1, 1, 0.7),
1808                    flash=new_best,
1809                    h_align=Text.HAlign.CENTER,
1810                    transition=(
1811                        Text.Transition.IN_RIGHT
1812                        if new_best
1813                        else Text.Transition.FADE_IN
1814                    ),
1815                    scale=0.5,
1816                    transition_delay=1.0,
1817                ).autoretain()
1818
1819            Text(
1820                bs.Lstr(
1821                    value='${A}:',
1822                    subs=[('${A}', bs.Lstr(resource='ratingText'))],
1823                ),
1824                position=(0, 36),
1825                maxwidth=300,
1826                transition=Text.Transition.FADE_IN,
1827                h_align=Text.HAlign.CENTER,
1828                v_align=Text.VAlign.CENTER,
1829                transition_delay=0,
1830            ).autoretain()
1831
1832        if self._submit_score:
1833            bs.timer(0.35, self._score_display_sound.play)
1834            if not error:
1835                bs.timer(0.35, self.cymbal_sound.play)
1836
1837    def _show_fail(self) -> None:
1838        ZoomText(
1839            bs.Lstr(resource='failText'),
1840            maxwidth=300,
1841            flash=False,
1842            trail=True,
1843            h_align='center',
1844            tilt_translate=0.11,
1845            position=(0, 40),
1846            jitter=1.0,
1847        ).autoretain()
1848        if self._fail_message is not None:
1849            Text(
1850                self._fail_message,
1851                h_align=Text.HAlign.CENTER,
1852                position=(0, -130),
1853                maxwidth=300,
1854                color=(1, 1, 1, 0.5),
1855                transition=Text.Transition.FADE_IN,
1856                transition_delay=1.0,
1857            ).autoretain()
1858        bs.timer(0.35, self._score_display_sound.play)
1859
1860    def _show_score_val(self, offs_x: float) -> None:
1861        assert self._score_type is not None
1862        assert self._score is not None
1863        ZoomText(
1864            (
1865                str(self._score)
1866                if self._score_type == 'points'
1867                else bs.timestring((self._score * 10) / 1000.0)
1868            ),
1869            maxwidth=300,
1870            flash=True,
1871            trail=True,
1872            scale=1.0 if self._score_type == 'points' else 0.6,
1873            h_align='center',
1874            tilt_translate=0.11,
1875            position=(190 + offs_x, 115),
1876            jitter=1.0,
1877        ).autoretain()
1878        Text(
1879            (
1880                bs.Lstr(
1881                    value='${A}:',
1882                    subs=[('${A}', bs.Lstr(resource='finalScoreText'))],
1883                )
1884                if self._score_type == 'points'
1885                else bs.Lstr(
1886                    value='${A}:',
1887                    subs=[('${A}', bs.Lstr(resource='finalTimeText'))],
1888                )
1889            ),
1890            maxwidth=300,
1891            position=(0, 200),
1892            transition=Text.Transition.FADE_IN,
1893            h_align=Text.HAlign.CENTER,
1894            v_align=Text.VAlign.CENTER,
1895            transition_delay=0,
1896        ).autoretain()
1897        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        v_offs2 = -236.0
362
363        # We wanna prevent controllers users from popping up browsers
364        # or game-center widgets in cases where they can't easily get back
365        # to the game (like on mac).
366        can_select_extra_buttons = bui.app.classic.platform == 'android'
367
368        bui.set_ui_input_device(None)  # Menu is up for grabs.
369
370        if self._have_achievements and self._account_has_achievements:
371            bui.buttonwidget(
372                parent=rootc,
373                color=(0.45, 0.4, 0.5),
374                position=(h_offs - 520, v_offs + 450 - 235 + 40),
375                size=(300, 60),
376                label=bui.Lstr(resource='achievementsText'),
377                on_activate_call=bui.WeakCall(self._ui_show_achievements),
378                transition_delay=delay + 1.5,
379                icon=self._game_service_achievements_texture,
380                icon_color=self._game_service_icon_color,
381                autoselect=True,
382                selectable=can_select_extra_buttons,
383            )
384
385        if self._should_show_worlds_best_button():
386            bui.buttonwidget(
387                parent=rootc,
388                color=(0.45, 0.4, 0.5),
389                position=(240, v_offs2 + 439),
390                size=(350, 62),
391                label=(
392                    bui.Lstr(resource='tournamentStandingsText')
393                    if self.session.tournament_id is not None
394                    else (
395                        bui.Lstr(resource='worldsBestScoresText')
396                        if self._score_type == 'points'
397                        else bui.Lstr(resource='worldsBestTimesText')
398                    )
399                ),
400                autoselect=True,
401                on_activate_call=bui.WeakCall(self._ui_worlds_best),
402                transition_delay=delay + 1.9,
403                selectable=can_select_extra_buttons,
404            )
405        else:
406            pass
407
408        show_next_button = self._is_more_levels and not (env.demo or env.arcade)
409
410        if not show_next_button:
411            h_offs += 60
412
413        # Due to virtual-bounds changes, have to squish buttons a bit to
414        # avoid overlapping with tips at bottom. Could look nicer to
415        # rework things in the middle to get more space, but would
416        # rather not touch this old code more than necessary.
417        small_buttons = True
418
419        if small_buttons:
420            menu_button = bui.buttonwidget(
421                parent=rootc,
422                autoselect=True,
423                position=(h_offs - 130 - 45, v_offs + 40),
424                size=(100, 50),
425                label='',
426                button_type='square',
427                on_activate_call=bui.WeakCall(self._ui_menu),
428            )
429            bui.imagewidget(
430                parent=rootc,
431                draw_controller=menu_button,
432                position=(h_offs - 130 - 60 + 43, v_offs + 43),
433                size=(45, 45),
434                texture=self._menu_icon_texture,
435                opacity=0.8,
436            )
437        else:
438            menu_button = bui.buttonwidget(
439                parent=rootc,
440                autoselect=True,
441                position=(h_offs - 130 - 60, v_offs),
442                size=(110, 85),
443                label='',
444                on_activate_call=bui.WeakCall(self._ui_menu),
445            )
446            bui.imagewidget(
447                parent=rootc,
448                draw_controller=menu_button,
449                position=(h_offs - 130 - 60 + 22, v_offs + 14),
450                size=(60, 60),
451                texture=self._menu_icon_texture,
452                opacity=0.8,
453            )
454
455        if small_buttons:
456            self._restart_button = restart_button = bui.buttonwidget(
457                parent=rootc,
458                autoselect=True,
459                position=(h_offs - 60, v_offs + 40),
460                size=(100, 50),
461                label='',
462                button_type='square',
463                on_activate_call=bui.WeakCall(self._ui_restart),
464            )
465            bui.imagewidget(
466                parent=rootc,
467                draw_controller=restart_button,
468                position=(h_offs - 60 + 25, v_offs + 42),
469                size=(47, 47),
470                texture=self._replay_icon_texture,
471                opacity=0.8,
472            )
473        else:
474            self._restart_button = restart_button = bui.buttonwidget(
475                parent=rootc,
476                autoselect=True,
477                position=(h_offs - 60, v_offs),
478                size=(110, 85),
479                label='',
480                on_activate_call=bui.WeakCall(self._ui_restart),
481            )
482            bui.imagewidget(
483                parent=rootc,
484                draw_controller=restart_button,
485                position=(h_offs - 60 + 19, v_offs + 7),
486                size=(70, 70),
487                texture=self._replay_icon_texture,
488                opacity=0.8,
489            )
490
491        next_button: bui.Widget | None = None
492
493        # Our 'next' button is disabled if we haven't unlocked the next
494        # level yet and invisible if there is none.
495        if show_next_button:
496            if self._is_complete:
497                call = bui.WeakCall(self._ui_next)
498                button_sound = True
499                image_opacity = 0.8
500                color = None
501            else:
502                call = bui.WeakCall(self._ui_error)
503                button_sound = False
504                image_opacity = 0.2
505                color = (0.3, 0.3, 0.3)
506
507            if small_buttons:
508                next_button = bui.buttonwidget(
509                    parent=rootc,
510                    autoselect=True,
511                    position=(h_offs + 130 - 75, v_offs + 40),
512                    size=(100, 50),
513                    label='',
514                    button_type='square',
515                    on_activate_call=call,
516                    color=color,
517                    enable_sound=button_sound,
518                )
519                bui.imagewidget(
520                    parent=rootc,
521                    draw_controller=next_button,
522                    position=(h_offs + 130 - 60 + 12, v_offs + 40),
523                    size=(50, 50),
524                    texture=self._next_level_icon_texture,
525                    opacity=image_opacity,
526                )
527            else:
528                next_button = bui.buttonwidget(
529                    parent=rootc,
530                    autoselect=True,
531                    position=(h_offs + 130 - 60, v_offs),
532                    size=(110, 85),
533                    label='',
534                    on_activate_call=call,
535                    color=color,
536                    enable_sound=button_sound,
537                )
538                bui.imagewidget(
539                    parent=rootc,
540                    draw_controller=next_button,
541                    position=(h_offs + 130 - 60 + 12, v_offs + 5),
542                    size=(80, 80),
543                    texture=self._next_level_icon_texture,
544                    opacity=image_opacity,
545                )
546
547        x_offs_extra = 0 if show_next_button else -100
548        self._corner_button_offs = (
549            h_offs + 300.0 + x_offs_extra,
550            v_offs + 519.0,
551        )
552
553        bui.containerwidget(
554            edit=rootc,
555            selected_child=(
556                next_button
557                if (self._newly_complete and self._victory and show_next_button)
558                else restart_button
559            ),
560            on_cancel_call=menu_button.activate,
561        )

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

@override
def on_player_join(self, player: bascenev1.Player) -> None:
605    @override
606    def on_player_join(self, player: bs.Player) -> None:
607        super().on_player_join(player)
608
609        if bs.app.classic is not None and bs.app.classic.server is not None:
610            # Host can't press retry button, so anyone can do it instead.
611            time_till_assign = max(
612                0, self._birth_time + self._min_view_time - bs.time()
613            )
614
615            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:
 617    @override
 618    def on_begin(self) -> None:
 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 = 290
 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                    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 (never want clients
1029        # to see it so no need for client-only version, etc).
1030        if self._have_achievements:
1031            if not self._account_has_achievements:
1032                Text(
1033                    bs.Lstr(resource='achievementsText'),
1034                    position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
1035                    maxwidth=210,
1036                    host_only=True,
1037                    transition=Text.Transition.IN_RIGHT,
1038                    v_align=Text.VAlign.CENTER,
1039                    scale=1.2,
1040                    transition_delay=2.8,
1041                ).autoretain()
1042
1043            assert self._game_name_str is not None
1044            assert bs.app.classic is not None
1045            achievements = bs.app.classic.ach.achievements_for_coop_level(
1046                self._game_name_str
1047            )
1048            hval = -455
1049            vval = -100
1050            tdelay = 0.0
1051            for ach in achievements:
1052                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
1053                vval -= 55
1054                tdelay += 0.250
1055
1056        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.