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

Score screen showing the results of a cooperative game.

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

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

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

@override
def on_player_join(self, player: bascenev1._player.Player) -> None:
581    @override
582    def on_player_join(self, player: bs.Player) -> None:
583        super().on_player_join(player)
584
585        if bs.app.classic is not None and bs.app.classic.server is not None:
586            # Host can't press retry button, so anyone can do it instead.
587            time_till_assign = max(