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
  12import bascenev1 as bs
  13import bauiv1 as bui
  14
  15from bascenev1lib.actor.text import Text
  16from bascenev1lib.actor.zoomtext import ZoomText
  17
  18if TYPE_CHECKING:
  19    from typing import Any, Sequence
  20
  21    from bauiv1lib.store.button import StoreButton
  22    from bauiv1lib.league.rankbutton import LeagueRankButton
  23
  24
  25class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
  26    """Score screen showing the results of a cooperative game."""
  27
  28    def __init__(self, settings: dict):
  29        # pylint: disable=too-many-statements
  30        super().__init__(settings)
  31
  32        plus = bs.app.plus
  33        assert plus is not None
  34
  35        # Keep prev activity alive while we fade in
  36        self.transition_time = 0.5
  37        self.inherits_tint = True
  38        self.inherits_vr_camera_offset = True
  39        self.inherits_music = True
  40        self.use_fixed_vr_overlay = True
  41
  42        self._do_new_rating: bool = self.session.tournament_id is not None
  43
  44        self._score_display_sound = bs.getsound('scoreHit01')
  45        self._score_display_sound_small = bs.getsound('scoreHit02')
  46        self.drum_roll_sound = bs.getsound('drumRoll')
  47        self.cymbal_sound = bs.getsound('cymbal')
  48
  49        self._replay_icon_texture = bui.gettexture('replayIcon')
  50        self._menu_icon_texture = bui.gettexture('menuIcon')
  51        self._next_level_icon_texture = bui.gettexture('nextLevelIcon')
  52
  53        self._campaign: bs.Campaign = settings['campaign']
  54
  55        self._have_achievements = (
  56            bs.app.classic is not None
  57            and bs.app.classic.ach.achievements_for_coop_level(
  58                self._campaign.name + ':' + settings['level']
  59            )
  60        )
  61
  62        self._account_type = (
  63            plus.get_v1_account_type()
  64            if plus.get_v1_account_state() == 'signed_in'
  65            else None
  66        )
  67
  68        self._game_service_icon_color: Sequence[float] | None
  69        self._game_service_achievements_texture: bui.Texture | None
  70        self._game_service_leaderboards_texture: bui.Texture | None
  71
  72        if self._account_type == 'Game Center':
  73            self._game_service_icon_color = (1.0, 1.0, 1.0)
  74            icon = bui.gettexture('gameCenterIcon')
  75            self._game_service_achievements_texture = icon
  76            self._game_service_leaderboards_texture = icon
  77            self._account_has_achievements = True
  78        elif self._account_type == 'Game Circle':
  79            icon = bui.gettexture('gameCircleIcon')
  80            self._game_service_icon_color = (1, 1, 1)
  81            self._game_service_achievements_texture = icon
  82            self._game_service_leaderboards_texture = icon
  83            self._account_has_achievements = True
  84        elif self._account_type == 'Google Play':
  85            self._game_service_icon_color = (0.8, 1.0, 0.6)
  86            self._game_service_achievements_texture = bui.gettexture(
  87                'googlePlayAchievementsIcon'
  88            )
  89            self._game_service_leaderboards_texture = bui.gettexture(
  90                'googlePlayLeaderboardsIcon'
  91            )
  92            self._account_has_achievements = True
  93        else:
  94            self._game_service_icon_color = None
  95            self._game_service_achievements_texture = None
  96            self._game_service_leaderboards_texture = None
  97            self._account_has_achievements = False
  98
  99        self._cashregistersound = bs.getsound('cashRegister')
 100        self._gun_cocking_sound = bs.getsound('gunCocking')
 101        self._dingsound = bs.getsound('ding')
 102        self._score_link: str | None = None
 103        self._root_ui: bui.Widget | None = None
 104        self._background: bs.Actor | None = None
 105        self._old_best_rank = 0.0
 106        self._game_name_str: str | None = None
 107        self._game_config_str: str | None = None
 108
 109        # Ui bits.
 110        self._corner_button_offs: tuple[float, float] | None = None
 111        self._league_rank_button: LeagueRankButton | None = None
 112        self._store_button_instance: StoreButton | None = None
 113        self._restart_button: bui.Widget | None = None
 114        self._update_corner_button_positions_timer: bui.AppTimer | None = None
 115        self._next_level_error: bs.Actor | None = None
 116
 117        # Score/gameplay bits.
 118        self._was_complete: bool | None = None
 119        self._is_complete: bool | None = None
 120        self._newly_complete: bool | None = None
 121        self._is_more_levels: bool | None = None
 122        self._next_level_name: str | None = None
 123        self._show_info: dict[str, Any] | None = None
 124        self._name_str: str | None = None
 125        self._friends_loading_status: bs.Actor | None = None
 126        self._score_loading_status: bs.Actor | None = None
 127        self._tournament_time_remaining: float | None = None
 128        self._tournament_time_remaining_text: Text | None = None
 129        self._tournament_time_remaining_text_timer: bs.BaseTimer | None = None
 130
 131        # Stuff for activity skip by pressing button
 132        self._birth_time = bs.time()
 133        self._min_view_time = 5.0
 134        self._allow_server_transition = False
 135        self._server_transitioning: bool | None = None
 136
 137        self._playerinfos: list[bs.PlayerInfo] = settings['playerinfos']
 138        assert isinstance(self._playerinfos, list)
 139        assert all(isinstance(i, bs.PlayerInfo) for i in self._playerinfos)
 140
 141        self._score: int | None = settings['score']
 142        assert isinstance(self._score, (int, type(None)))
 143
 144        self._fail_message: bs.Lstr | None = settings['fail_message']
 145        assert isinstance(self._fail_message, (bs.Lstr, type(None)))
 146
 147        self._begin_time: float | None = None
 148
 149        self._score_order: str
 150        if 'score_order' in settings:
 151            if not settings['score_order'] in ['increasing', 'decreasing']:
 152                raise ValueError(
 153                    'Invalid score order: ' + settings['score_order']
 154                )
 155            self._score_order = settings['score_order']
 156        else:
 157            self._score_order = 'increasing'
 158        assert isinstance(self._score_order, str)
 159
 160        self._score_type: str
 161        if 'score_type' in settings:
 162            if not settings['score_type'] in ['points', 'time']:
 163                raise ValueError(
 164                    'Invalid score type: ' + settings['score_type']
 165                )
 166            self._score_type = settings['score_type']
 167        else:
 168            self._score_type = 'points'
 169        assert isinstance(self._score_type, str)
 170
 171        self._level_name: str = settings['level']
 172        assert isinstance(self._level_name, str)
 173
 174        self._game_name_str = self._campaign.name + ':' + self._level_name
 175        self._game_config_str = (
 176            str(len(self._playerinfos))
 177            + 'p'
 178            + self._campaign.getlevel(self._level_name)
 179            .get_score_version_string()
 180            .replace(' ', '_')
 181        )
 182
 183        try:
 184            self._old_best_rank = self._campaign.getlevel(
 185                self._level_name
 186            ).rating
 187        except Exception:
 188            self._old_best_rank = 0.0
 189
 190        self._victory: bool = settings['outcome'] == 'victory'
 191
 192    def __del__(self) -> None:
 193        super().__del__()
 194
 195        # If our UI is still up, kill it.
 196        if self._root_ui:
 197            with bui.ContextRef.empty():
 198                bui.containerwidget(edit=self._root_ui, transition='out_left')
 199
 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.classic is not None:
 291            bs.app.classic.show_online_score_ui(
 292                'leaderboard',
 293                game=self._game_name_str,
 294                game_version=self._game_config_str,
 295            )
 296        else:
 297            logging.warning('show_online_score_ui requires classic')
 298
 299    def _ui_show_achievements(self) -> None:
 300        if bs.app.classic is not None:
 301            bs.app.classic.show_online_score_ui('achievements')
 302        else:
 303            logging.warning('show_online_score_ui requires classic')
 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    def on_player_join(self, player: bs.Player) -> None:
 581        super().on_player_join(player)
 582
 583        if bs.app.classic is not None and bs.app.classic.server is not None:
 584            # Host can't press retry button, so anyone can do it instead.
 585            time_till_assign = max(
 586                0, self._birth_time + self._min_view_time - bs.time()
 587            )
 588
 589            bs.timer(time_till_assign, bs.WeakCall(self._safe_assign, player))
 590
 591    def on_begin(self) -> None:
 592        # FIXME: Clean this up.
 593        # pylint: disable=too-many-statements
 594        # pylint: disable=too-many-branches
 595        # pylint: disable=too-many-locals
 596        super().on_begin()
 597
 598        app = bs.app
 599        env = app.env
 600        plus = app.plus
 601        assert plus is not None
 602
 603        self._begin_time = bs.time()
 604
 605        # Calc whether the level is complete and other stuff.
 606        levels = self._campaign.levels
 607        level = self._campaign.getlevel(self._level_name)
 608        self._was_complete = level.complete
 609        self._is_complete = self._was_complete or self._victory
 610        self._newly_complete = self._is_complete and not self._was_complete
 611        self._is_more_levels = (
 612            level.index < len(levels) - 1
 613        ) and self._campaign.sequential
 614
 615        # Any time we complete a level, set the next one as unlocked.
 616        if self._is_complete and self._is_more_levels:
 617            plus.add_v1_account_transaction(
 618                {
 619                    'type': 'COMPLETE_LEVEL',
 620                    'campaign': self._campaign.name,
 621                    'level': self._level_name,
 622                }
 623            )
 624            self._next_level_name = levels[level.index + 1].name
 625
 626            # If this is the first time we completed it, set the next one
 627            # as current.
 628            if self._newly_complete:
 629                cfg = app.config
 630                cfg['Selected Coop Game'] = (
 631                    self._campaign.name + ':' + self._next_level_name
 632                )
 633                cfg.commit()
 634                self._campaign.set_selected_level(self._next_level_name)
 635
 636        bs.timer(1.0, bs.WeakCall(self.request_ui))
 637
 638        if (
 639            self._is_complete
 640            and self._victory
 641            and self._is_more_levels
 642            and not (env.demo or env.arcade)
 643        ):
 644            Text(
 645                bs.Lstr(
 646                    value='${A}:\n',
 647                    subs=[('${A}', bs.Lstr(resource='levelUnlockedText'))],
 648                )
 649                if self._newly_complete
 650                else bs.Lstr(
 651                    value='${A}:\n',
 652                    subs=[('${A}', bs.Lstr(resource='nextLevelText'))],
 653                ),
 654                transition=Text.Transition.IN_RIGHT,
 655                transition_delay=5.2,
 656                flash=self._newly_complete,
 657                scale=0.54,
 658                h_align=Text.HAlign.CENTER,
 659                maxwidth=270,
 660                color=(0.5, 0.7, 0.5, 1),
 661                position=(270, -235),
 662            ).autoretain()
 663            assert self._next_level_name is not None
 664            Text(
 665                bs.Lstr(translate=('coopLevelNames', self._next_level_name)),
 666                transition=Text.Transition.IN_RIGHT,
 667                transition_delay=5.2,
 668                flash=self._newly_complete,
 669                scale=0.7,
 670                h_align=Text.HAlign.CENTER,
 671                maxwidth=205,
 672                color=(0.5, 0.7, 0.5, 1),
 673                position=(270, -255),
 674            ).autoretain()
 675            if self._newly_complete:
 676                bs.timer(5.2, self._cashregistersound.play)
 677                bs.timer(5.2, self._dingsound.play)
 678
 679        offs_x = -195
 680        if len(self._playerinfos) > 1:
 681            pstr = bs.Lstr(
 682                value='- ${A} -',
 683                subs=[
 684                    (
 685                        '${A}',
 686                        bs.Lstr(
 687                            resource='multiPlayerCountText',
 688                            subs=[('${COUNT}', str(len(self._playerinfos)))],
 689                        ),
 690                    )
 691                ],
 692            )
 693        else:
 694            pstr = bs.Lstr(
 695                value='- ${A} -',
 696                subs=[('${A}', bs.Lstr(resource='singlePlayerCountText'))],
 697            )
 698        ZoomText(
 699            self._campaign.getlevel(self._level_name).displayname,
 700            maxwidth=800,
 701            flash=False,
 702            trail=False,
 703            color=(0.5, 1, 0.5, 1),
 704            h_align='center',
 705            scale=0.4,
 706            position=(0, 292),
 707            jitter=1.0,
 708        ).autoretain()
 709        Text(
 710            pstr,
 711            maxwidth=300,
 712            transition=Text.Transition.FADE_IN,
 713            scale=0.7,
 714            h_align=Text.HAlign.CENTER,
 715            v_align=Text.VAlign.CENTER,
 716            color=(0.5, 0.7, 0.5, 1),
 717            position=(0, 230),
 718        ).autoretain()
 719
 720        if app.classic is not None and app.classic.server is None:
 721            # If we're running in normal non-headless build, show this text
 722            # because only host can continue the game.
 723            adisp = plus.get_v1_account_display_string()
 724            txt = Text(
 725                bs.Lstr(
 726                    resource='waitingForHostText', subs=[('${HOST}', adisp)]
 727                ),
 728                maxwidth=300,
 729                transition=Text.Transition.FADE_IN,
 730                transition_delay=8.0,
 731                scale=0.85,
 732                h_align=Text.HAlign.CENTER,
 733                v_align=Text.VAlign.CENTER,
 734                color=(1, 1, 0, 1),
 735                position=(0, -230),
 736            ).autoretain()
 737            assert txt.node
 738            txt.node.client_only = True
 739        else:
 740            # In headless build, anyone can continue the game.
 741            sval = bs.Lstr(resource='pressAnyButtonPlayAgainText')
 742            Text(
 743                sval,
 744                v_attach=Text.VAttach.BOTTOM,
 745                h_align=Text.HAlign.CENTER,
 746                flash=True,
 747                vr_depth=50,
 748                position=(0, 60),
 749                scale=0.8,
 750                color=(0.5, 0.7, 0.5, 0.5),
 751                transition=Text.Transition.IN_BOTTOM_SLOW,
 752                transition_delay=self._min_view_time,
 753            ).autoretain()
 754
 755        if self._score is not None:
 756            bs.timer(0.35, self._score_display_sound_small.play)
 757
 758        # Vestigial remain; this stuff should just be instance vars.
 759        self._show_info = {}
 760
 761        if self._score is not None:
 762            bs.timer(0.8, bs.WeakCall(self._show_score_val, offs_x))
 763        else:
 764            bs.pushcall(bs.WeakCall(self._show_fail))
 765
 766        self._name_str = name_str = ', '.join(
 767            [p.name for p in self._playerinfos]
 768        )
 769
 770        self._score_loading_status = Text(
 771            bs.Lstr(
 772                value='${A}...',
 773                subs=[('${A}', bs.Lstr(resource='loadingText'))],
 774            ),
 775            position=(280, 150 + 30),
 776            color=(1, 1, 1, 0.4),
 777            transition=Text.Transition.FADE_IN,
 778            scale=0.7,
 779            transition_delay=2.0,
 780        )
 781
 782        if self._score is not None:
 783            bs.timer(0.4, bs.WeakCall(self._play_drumroll))
 784
 785        # Add us to high scores, filter, and store.
 786        our_high_scores_all = self._campaign.getlevel(
 787            self._level_name
 788        ).get_high_scores()
 789
 790        our_high_scores = our_high_scores_all.setdefault(
 791            str(len(self._playerinfos)) + ' Player', []
 792        )
 793
 794        if self._score is not None:
 795            our_score: list | None = [
 796                self._score,
 797                {
 798                    'players': [
 799                        {'name': p.name, 'character': p.character}
 800                        for p in self._playerinfos
 801                    ]
 802                },
 803            ]
 804            our_high_scores.append(our_score)
 805        else:
 806            our_score = None
 807
 808        try:
 809            our_high_scores.sort(
 810                reverse=self._score_order == 'increasing', key=lambda x: x[0]
 811            )
 812        except Exception:
 813            logging.exception('Error sorting scores.')
 814            print(f'our_high_scores: {our_high_scores}')
 815
 816        del our_high_scores[10:]
 817
 818        if self._score is not None:
 819            sver = self._campaign.getlevel(
 820                self._level_name
 821            ).get_score_version_string()
 822            plus.add_v1_account_transaction(
 823                {
 824                    'type': 'SET_LEVEL_LOCAL_HIGH_SCORES',
 825                    'campaign': self._campaign.name,
 826                    'level': self._level_name,
 827                    'scoreVersion': sver,
 828                    'scores': our_high_scores_all,
 829                }
 830            )
 831        if plus.get_v1_account_state() != 'signed_in':
 832            # We expect this only in kiosk mode; complain otherwise.
 833            if not (env.demo or env.arcade):
 834                logging.error('got not-signed-in at score-submit; unexpected')
 835            bs.pushcall(bs.WeakCall(self._got_score_results, None))
 836        else:
 837            assert self._game_name_str is not None
 838            assert self._game_config_str is not None
 839            plus.submit_score(
 840                self._game_name_str,
 841                self._game_config_str,
 842                name_str,
 843                self._score,
 844                bs.WeakCall(self._got_score_results),
 845                order=self._score_order,
 846                tournament_id=self.session.tournament_id,
 847                score_type=self._score_type,
 848                campaign=self._campaign.name,
 849                level=self._level_name,
 850            )
 851
 852        # Apply the transactions we've been adding locally.
 853        plus.run_v1_account_transactions()
 854
 855        # If we're not doing the world's-best button, just show a title
 856        # instead.
 857        ts_height = 300
 858        ts_h_offs = 210
 859        v_offs = 40
 860        txt = Text(
 861            bs.Lstr(resource='tournamentStandingsText')
 862            if self.session.tournament_id is not None
 863            else bs.Lstr(resource='worldsBestScoresText')
 864            if self._score_type == 'points'
 865            else bs.Lstr(resource='worldsBestTimesText'),
 866            maxwidth=210,
 867            position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 868            transition=Text.Transition.IN_LEFT,
 869            v_align=Text.VAlign.CENTER,
 870            scale=1.2,
 871            transition_delay=2.2,
 872        ).autoretain()
 873
 874        # If we've got a button on the server, only show this on clients.
 875        if self._should_show_worlds_best_button():
 876            assert txt.node
 877            txt.node.client_only = True
 878
 879        ts_height = 300
 880        ts_h_offs = -480
 881        v_offs = 40
 882        Text(
 883            bs.Lstr(resource='yourBestScoresText')
 884            if self._score_type == 'points'
 885            else bs.Lstr(resource='yourBestTimesText'),
 886            maxwidth=210,
 887            position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 888            transition=Text.Transition.IN_RIGHT,
 889            v_align=Text.VAlign.CENTER,
 890            scale=1.2,
 891            transition_delay=1.8,
 892        ).autoretain()
 893
 894        display_scores = list(our_high_scores)
 895        display_count = 5
 896
 897        while len(display_scores) < display_count:
 898            display_scores.append((0, None))
 899
 900        showed_ours = False
 901        h_offs_extra = 85 if self._score_type == 'points' else 130
 902        v_offs_extra = 20
 903        v_offs_names = 0
 904        scale = 1.0
 905        p_count = len(self._playerinfos)
 906        h_offs_extra -= 75
 907        if p_count > 1:
 908            h_offs_extra -= 20
 909        if p_count == 2:
 910            scale = 0.9
 911        elif p_count == 3:
 912            scale = 0.65
 913        elif p_count == 4:
 914            scale = 0.5
 915        times: list[tuple[float, float]] = []
 916        for i in range(display_count):
 917            times.insert(
 918                random.randrange(0, len(times) + 1),
 919                (1.9 + i * 0.05, 2.3 + i * 0.05),
 920            )
 921        for i in range(display_count):
 922            try:
 923                if display_scores[i][1] is None:
 924                    name_str = '-'
 925                else:
 926                    # noinspection PyUnresolvedReferences
 927                    name_str = ', '.join(
 928                        [p['name'] for p in display_scores[i][1]['players']]
 929                    )
 930            except Exception:
 931                logging.exception(
 932                    'Error calcing name_str for %s.', display_scores
 933                )
 934                name_str = '-'
 935            if display_scores[i] == our_score and not showed_ours:
 936                flash = True
 937                color0 = (0.6, 0.4, 0.1, 1.0)
 938                color1 = (0.6, 0.6, 0.6, 1.0)
 939                tdelay1 = 3.7
 940                tdelay2 = 3.7
 941                showed_ours = True
 942            else:
 943                flash = False
 944                color0 = (0.6, 0.4, 0.1, 1.0)
 945                color1 = (0.6, 0.6, 0.6, 1.0)
 946                tdelay1 = times[i][0]
 947                tdelay2 = times[i][1]
 948            Text(
 949                str(display_scores[i][0])
 950                if self._score_type == 'points'
 951                else bs.timestring((display_scores[i][0] * 10) / 1000.0),
 952                position=(
 953                    ts_h_offs + 20 + h_offs_extra,
 954                    v_offs_extra
 955                    + ts_height / 2
 956                    + -ts_height * (i + 1) / 10
 957                    + v_offs
 958                    + 11.0,
 959                ),
 960                h_align=Text.HAlign.RIGHT,
 961                v_align=Text.VAlign.CENTER,
 962                color=color0,
 963                flash=flash,
 964                transition=Text.Transition.IN_RIGHT,
 965                transition_delay=tdelay1,
 966            ).autoretain()
 967
 968            Text(
 969                bs.Lstr(value=name_str),
 970                position=(
 971                    ts_h_offs + 35 + h_offs_extra,
 972                    v_offs_extra
 973                    + ts_height / 2
 974                    + -ts_height * (i + 1) / 10
 975                    + v_offs_names
 976                    + v_offs
 977                    + 11.0,
 978                ),
 979                maxwidth=80.0 + 100.0 * len(self._playerinfos),
 980                v_align=Text.VAlign.CENTER,
 981                color=color1,
 982                flash=flash,
 983                scale=scale,
 984                transition=Text.Transition.IN_RIGHT,
 985                transition_delay=tdelay2,
 986            ).autoretain()
 987
 988        # Show achievements for this level.
 989        ts_height = -150
 990        ts_h_offs = -480
 991        v_offs = 40
 992
 993        # Only make this if we don't have the button
 994        # (never want clients to see it so no need for client-only
 995        # version, etc).
 996        if self._have_achievements:
 997            if not self._account_has_achievements:
 998                Text(
 999                    bs.Lstr(resource='achievementsText'),
1000                    position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
1001                    maxwidth=210,
1002                    host_only=True,
1003                    transition=Text.Transition.IN_RIGHT,
1004                    v_align=Text.VAlign.CENTER,
1005                    scale=1.2,
1006                    transition_delay=2.8,
1007                ).autoretain()
1008
1009            assert self._game_name_str is not None
1010            assert bs.app.classic is not None
1011            achievements = bs.app.classic.ach.achievements_for_coop_level(
1012                self._game_name_str
1013            )
1014            hval = -455
1015            vval = -100
1016            tdelay = 0.0
1017            for ach in achievements:
1018                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
1019                vval -= 55
1020                tdelay += 0.250
1021
1022        bs.timer(5.0, bs.WeakCall(self._show_tips))
1023
1024    def _play_drumroll(self) -> None:
1025        bs.NodeActor(
1026            bs.newnode(
1027                'sound',
1028                attrs={
1029                    'sound': self.drum_roll_sound,
1030                    'positional': False,
1031                    'loop': False,
1032                },
1033            )
1034        ).autoretain()
1035
1036    def _got_friend_score_results(self, results: list[Any] | None) -> None:
1037        # FIXME: tidy this up
1038        # pylint: disable=too-many-locals
1039        # pylint: disable=too-many-branches
1040        # pylint: disable=too-many-statements
1041        from efro.util import asserttype
1042
1043        # delay a bit if results come in too fast
1044        assert self._begin_time is not None
1045        base_delay = max(0, 1.9 - (bs.time() - self._begin_time))
1046        ts_height = 300
1047        ts_h_offs = -550
1048        v_offs = 30
1049
1050        # Report in case of error.
1051        if results is None:
1052            self._friends_loading_status = Text(
1053                bs.Lstr(resource='friendScoresUnavailableText'),
1054                maxwidth=330,
1055                position=(-475, 150 + v_offs),
1056                color=(1, 1, 1, 0.4),
1057                transition=Text.Transition.FADE_IN,
1058                transition_delay=base_delay + 0.8,
1059                scale=0.7,
1060            )
1061            return
1062
1063        self._friends_loading_status = None
1064
1065        # Ok, it looks like we aren't able to reliably get a just-submitted
1066        # result returned in the score list, so we need to look for our score
1067        # in this list and replace it if ours is better or add ours otherwise.
1068        if self._score is not None:
1069            our_score_entry = [self._score, 'Me', True]
1070            for score in results:
1071                if score[2]:
1072                    if self._score_order == 'increasing':
1073                        our_score_entry[0] = max(score[0], self._score)
1074                    else:
1075                        our_score_entry[0] = min(score[0], self._score)
1076                    results.remove(score)
1077                    break
1078            results.append(our_score_entry)
1079            results.sort(
1080                reverse=self._score_order == 'increasing',
1081                key=lambda x: asserttype(x[0], int),
1082            )
1083
1084        # If we're not submitting our own score, we still want to change the
1085        # name of our own score to 'Me'.
1086        else:
1087            for score in results:
1088                if score[2]:
1089                    score[1] = 'Me'
1090                    break
1091        h_offs_extra = 80 if self._score_type == 'points' else 130
1092        v_offs_extra = 20
1093        v_offs_names = 0
1094        scale = 1.0
1095
1096        # Make sure there's at least 5.
1097        while len(results) < 5:
1098            results.append([0, '-', False])
1099        results = results[:5]
1100        times: list[tuple[float, float]] = []
1101        for i in range(len(results)):
1102            times.insert(
1103                random.randrange(0, len(times) + 1),
1104                (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05),
1105            )
1106        for i, tval in enumerate(results):
1107            score = int(tval[0])
1108            name_str = tval[1]
1109            is_me = tval[2]
1110            if is_me and score == self._score:
1111                flash = True
1112                color0 = (0.6, 0.4, 0.1, 1.0)
1113                color1 = (0.6, 0.6, 0.6, 1.0)
1114                tdelay1 = base_delay + 1.0
1115                tdelay2 = base_delay + 1.0
1116            else:
1117                flash = False
1118                if is_me:
1119                    color0 = (0.6, 0.4, 0.1, 1.0)
1120                    color1 = (0.9, 1.0, 0.9, 1.0)
1121                else:
1122                    color0 = (0.6, 0.4, 0.1, 1.0)
1123                    color1 = (0.6, 0.6, 0.6, 1.0)
1124                tdelay1 = times[i][0]
1125                tdelay2 = times[i][1]
1126            if name_str != '-':
1127                Text(
1128                    str(score)
1129                    if self._score_type == 'points'
1130                    else bs.timestring((score * 10) / 1000.0),
1131                    position=(
1132                        ts_h_offs + 20 + h_offs_extra,
1133                        v_offs_extra
1134                        + ts_height / 2
1135                        + -ts_height * (i + 1) / 10
1136                        + v_offs
1137                        + 11.0,
1138                    ),
1139                    h_align=Text.HAlign.RIGHT,
1140                    v_align=Text.VAlign.CENTER,
1141                    color=color0,
1142                    flash=flash,
1143                    transition=Text.Transition.IN_RIGHT,
1144                    transition_delay=tdelay1,
1145                ).autoretain()
1146            else:
1147                if is_me:
1148                    print('Error: got empty name_str on score result:', tval)
1149
1150            Text(
1151                bs.Lstr(value=name_str),
1152                position=(
1153                    ts_h_offs + 35 + h_offs_extra,
1154                    v_offs_extra
1155                    + ts_height / 2
1156                    + -ts_height * (i + 1) / 10
1157                    + v_offs_names
1158                    + v_offs
1159                    + 11.0,
1160                ),
1161                color=color1,
1162                maxwidth=160.0,
1163                v_align=Text.VAlign.CENTER,
1164                flash=flash,
1165                scale=scale,
1166                transition=Text.Transition.IN_RIGHT,
1167                transition_delay=tdelay2,
1168            ).autoretain()
1169
1170    def _got_score_results(self, results: dict[str, Any] | None) -> None:
1171        # FIXME: tidy this up
1172        # pylint: disable=too-many-locals
1173        # pylint: disable=too-many-branches
1174        # pylint: disable=too-many-statements
1175
1176        plus = bs.app.plus
1177        assert plus is not None
1178
1179        # We need to manually run this in the context of our activity
1180        # and only if we aren't shutting down.
1181        # (really should make the submit_score call handle that stuff itself)
1182        if self.expired:
1183            return
1184        with self.context:
1185            # Delay a bit if results come in too fast.
1186            assert self._begin_time is not None
1187            base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
1188            v_offs = 20
1189            if results is None:
1190                self._score_loading_status = Text(
1191                    bs.Lstr(resource='worldScoresUnavailableText'),
1192                    position=(230, 150 + v_offs),
1193                    color=(1, 1, 1, 0.4),
1194                    transition=Text.Transition.FADE_IN,
1195                    transition_delay=base_delay + 0.3,
1196                    scale=0.7,
1197                )
1198            else:
1199                self._score_link = results['link']
1200                assert self._score_link is not None
1201                # Prepend our master-server addr if its a relative addr.
1202                if not self._score_link.startswith(
1203                    'http://'
1204                ) and not self._score_link.startswith('https://'):
1205                    self._score_link = (
1206                        plus.get_master_server_address()
1207                        + '/'
1208                        + self._score_link
1209                    )
1210                self._score_loading_status = None
1211                if 'tournamentSecondsRemaining' in results:
1212                    secs_remaining = results['tournamentSecondsRemaining']
1213                    assert isinstance(secs_remaining, int)
1214                    self._tournament_time_remaining = secs_remaining
1215                    self._tournament_time_remaining_text_timer = bs.BaseTimer(
1216                        1.0,
1217                        bs.WeakCall(
1218                            self._update_tournament_time_remaining_text
1219                        ),
1220                        repeat=True,
1221                    )
1222
1223            assert self._show_info is not None
1224            self._show_info['results'] = results
1225            if results is not None:
1226                if results['tops'] != '':
1227                    self._show_info['tops'] = results['tops']
1228                else:
1229                    self._show_info['tops'] = []
1230            offs_x = -195
1231            available = self._show_info['results'] is not None
1232            if self._score is not None:
1233                bs.basetimer(
1234                    (1.5 + base_delay),
1235                    bs.WeakCall(self._show_world_rank, offs_x),
1236                )
1237            ts_h_offs = 200
1238            ts_height = 300
1239
1240            # Show world tops.
1241            if available:
1242                # Show the number of games represented by this
1243                # list (except for in tournaments).
1244                if self.session.tournament_id is None:
1245                    Text(
1246                        bs.Lstr(
1247                            resource='lastGamesText',
1248                            subs=[
1249                                (
1250                                    '${COUNT}',
1251                                    str(self._show_info['results']['total']),
1252                                )
1253                            ],
1254                        ),
1255                        position=(
1256                            ts_h_offs - 35 + 95,
1257                            ts_height / 2 + 6 + v_offs,
1258                        ),
1259                        color=(0.4, 0.4, 0.4, 1.0),
1260                        scale=0.7,
1261                        transition=Text.Transition.IN_RIGHT,
1262                        transition_delay=base_delay + 0.3,
1263                    ).autoretain()
1264                else:
1265                    v_offs += 20
1266
1267                h_offs_extra = 0
1268                v_offs_names = 0
1269                scale = 1.0
1270                p_count = len(self._playerinfos)
1271                if p_count > 1:
1272                    h_offs_extra -= 40
1273                if self._score_type != 'points':
1274                    h_offs_extra += 60
1275                if p_count == 2:
1276                    scale = 0.9
1277                elif p_count == 3:
1278                    scale = 0.65
1279                elif p_count == 4:
1280                    scale = 0.5
1281
1282                # Make sure there's at least 10.
1283                while len(self._show_info['tops']) < 10:
1284                    self._show_info['tops'].append([0, '-'])
1285
1286                times: list[tuple[float, float]] = []
1287                for i in range(len(self._show_info['tops'])):
1288                    times.insert(
1289                        random.randrange(0, len(times) + 1),
1290                        (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
1291                    )
1292                for i, tval in enumerate(self._show_info['tops']):
1293                    score = int(tval[0])
1294                    name_str = tval[1]
1295                    if self._name_str == name_str and self._score == score:
1296                        flash = True
1297                        color0 = (0.6, 0.4, 0.1, 1.0)
1298                        color1 = (0.6, 0.6, 0.6, 1.0)
1299                        tdelay1 = base_delay + 1.0
1300                        tdelay2 = base_delay + 1.0
1301                    else:
1302                        flash = False
1303                        if self._name_str == name_str:
1304                            color0 = (0.6, 0.4, 0.1, 1.0)
1305                            color1 = (0.9, 1.0, 0.9, 1.0)
1306                        else:
1307                            color0 = (0.6, 0.4, 0.1, 1.0)
1308                            color1 = (0.6, 0.6, 0.6, 1.0)
1309                        tdelay1 = times[i][0]
1310                        tdelay2 = times[i][1]
1311
1312                    if name_str != '-':
1313                        Text(
1314                            str(score)
1315                            if self._score_type == 'points'
1316                            else bs.timestring((score * 10) / 1000.0),
1317                            position=(
1318                                ts_h_offs + 20 + h_offs_extra,
1319                                ts_height / 2
1320                                + -ts_height * (i + 1) / 10
1321                                + v_offs
1322                                + 11.0,
1323                            ),
1324                            h_align=Text.HAlign.RIGHT,
1325                            v_align=Text.VAlign.CENTER,
1326                            color=color0,
1327                            flash=flash,
1328                            transition=Text.Transition.IN_LEFT,
1329                            transition_delay=tdelay1,
1330                        ).autoretain()
1331                    Text(
1332                        bs.Lstr(value=name_str),
1333                        position=(
1334                            ts_h_offs + 35 + h_offs_extra,
1335                            ts_height / 2
1336                            + -ts_height * (i + 1) / 10
1337                            + v_offs_names
1338                            + v_offs
1339                            + 11.0,
1340                        ),
1341                        maxwidth=80.0 + 100.0 * len(self._playerinfos),
1342                        v_align=Text.VAlign.CENTER,
1343                        color=color1,
1344                        flash=flash,
1345                        scale=scale,
1346                        transition=Text.Transition.IN_LEFT,
1347                        transition_delay=tdelay2,
1348                    ).autoretain()
1349
1350    def _show_tips(self) -> None:
1351        from bascenev1lib.actor.tipstext import TipsText
1352
1353        TipsText(offs_y=30).autoretain()
1354
1355    def _update_tournament_time_remaining_text(self) -> None:
1356        if self._tournament_time_remaining is None:
1357            return
1358        self._tournament_time_remaining = max(
1359            0, self._tournament_time_remaining - 1
1360        )
1361        if self._tournament_time_remaining_text is not None:
1362            val = bs.timestring(
1363                self._tournament_time_remaining,
1364                centi=False,
1365            )
1366            self._tournament_time_remaining_text.node.text = val
1367
1368    def _show_world_rank(self, offs_x: float) -> None:
1369        # FIXME: Tidy this up.
1370        # pylint: disable=too-many-locals
1371        # pylint: disable=too-many-branches
1372        # pylint: disable=too-many-statements
1373        assert bs.app.classic is not None
1374        assert self._show_info is not None
1375        available = self._show_info['results'] is not None
1376
1377        if available:
1378            error = (
1379                self._show_info['results']['error']
1380                if 'error' in self._show_info['results']
1381                else None
1382            )
1383            rank = self._show_info['results']['rank']
1384            total = self._show_info['results']['total']
1385            rating = (
1386                10.0
1387                if total == 1
1388                else 10.0 * (1.0 - (float(rank - 1) / (total - 1)))
1389            )
1390            player_rank = self._show_info['results']['playerRank']
1391            best_player_rank = self._show_info['results']['bestPlayerRank']
1392        else:
1393            error = False
1394            rating = None
1395            player_rank = None
1396            best_player_rank = None
1397
1398        # If we've got tournament-seconds-remaining, show it.
1399        if self._tournament_time_remaining is not None:
1400            Text(
1401                bs.Lstr(resource='coopSelectWindow.timeRemainingText'),
1402                position=(-360, -70 - 100),
1403                color=(1, 1, 1, 0.7),
1404                h_align=Text.HAlign.CENTER,
1405                v_align=Text.VAlign.CENTER,
1406                transition=Text.Transition.FADE_IN,
1407                scale=0.8,
1408                maxwidth=300,
1409                transition_delay=2.0,
1410            ).autoretain()
1411            self._tournament_time_remaining_text = Text(
1412                '',
1413                position=(-360, -110 - 100),
1414                color=(1, 1, 1, 0.7),
1415                h_align=Text.HAlign.CENTER,
1416                v_align=Text.VAlign.CENTER,
1417                transition=Text.Transition.FADE_IN,
1418                scale=1.6,
1419                maxwidth=150,
1420                transition_delay=2.0,
1421            )
1422
1423        # If we're a tournament, show prizes.
1424        try:
1425            assert bs.app.classic is not None
1426            tournament_id = self.session.tournament_id
1427            if tournament_id is not None:
1428                if tournament_id in bs.app.classic.accounts.tournament_info:
1429                    tourney_info = bs.app.classic.accounts.tournament_info[
1430                        tournament_id
1431                    ]
1432                    # pylint: disable=useless-suppression
1433                    # pylint: disable=unbalanced-tuple-unpacking
1434                    (
1435                        pr1,
1436                        pv1,
1437                        pr2,
1438                        pv2,
1439                        pr3,
1440                        pv3,
1441                    ) = bs.app.classic.get_tournament_prize_strings(
1442                        tourney_info
1443                    )
1444                    # pylint: enable=unbalanced-tuple-unpacking
1445                    # pylint: enable=useless-suppression
1446
1447                    Text(
1448                        bs.Lstr(resource='coopSelectWindow.prizesText'),
1449                        position=(-360, -70 + 77),
1450                        color=(1, 1, 1, 0.7),
1451                        h_align=Text.HAlign.CENTER,
1452                        v_align=Text.VAlign.CENTER,
1453                        transition=Text.Transition.FADE_IN,
1454                        scale=1.0,
1455                        maxwidth=300,
1456                        transition_delay=2.0,
1457                    ).autoretain()
1458                    vval = -107 + 70
1459                    for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)):
1460                        Text(
1461                            rng,
1462                            position=(-410 + 10, vval),
1463                            color=(1, 1, 1, 0.7),
1464                            h_align=Text.HAlign.RIGHT,
1465                            v_align=Text.VAlign.CENTER,
1466                            transition=Text.Transition.FADE_IN,
1467                            scale=0.6,
1468                            maxwidth=300,
1469                            transition_delay=2.0,
1470                        ).autoretain()
1471                        Text(
1472                            val,
1473                            position=(-390 + 10, vval),
1474                            color=(0.7, 0.7, 0.7, 1.0),
1475                            h_align=Text.HAlign.LEFT,
1476                            v_align=Text.VAlign.CENTER,
1477                            transition=Text.Transition.FADE_IN,
1478                            scale=0.8,
1479                            maxwidth=300,
1480                            transition_delay=2.0,
1481                        ).autoretain()
1482                        vval -= 35
1483        except Exception:
1484            logging.exception('Error showing prize ranges.')
1485
1486        if self._do_new_rating:
1487            if error:
1488                ZoomText(
1489                    bs.Lstr(resource='failText'),
1490                    flash=True,
1491                    trail=True,
1492                    scale=1.0 if available else 0.333,
1493                    tilt_translate=0.11,
1494                    h_align='center',
1495                    position=(190 + offs_x, -60),
1496                    maxwidth=200,
1497                    jitter=1.0,
1498                ).autoretain()
1499                Text(
1500                    bs.Lstr(translate=('serverResponses', error)),
1501                    position=(0, -140),
1502                    color=(1, 1, 1, 0.7),
1503                    h_align=Text.HAlign.CENTER,
1504                    v_align=Text.VAlign.CENTER,
1505                    transition=Text.Transition.FADE_IN,
1506                    scale=0.9,
1507                    maxwidth=400,
1508                    transition_delay=1.0,
1509                ).autoretain()
1510            else:
1511                ZoomText(
1512                    (
1513                        ('#' + str(player_rank))
1514                        if player_rank is not None
1515                        else bs.Lstr(resource='unavailableText')
1516                    ),
1517                    flash=True,
1518                    trail=True,
1519                    scale=1.0 if available else 0.333,
1520                    tilt_translate=0.11,
1521                    h_align='center',
1522                    position=(190 + offs_x, -60),
1523                    maxwidth=200,
1524                    jitter=1.0,
1525                ).autoretain()
1526
1527                Text(
1528                    bs.Lstr(
1529                        value='${A}:',
1530                        subs=[('${A}', bs.Lstr(resource='rankText'))],
1531                    ),
1532                    position=(0, 36),
1533                    maxwidth=300,
1534                    transition=Text.Transition.FADE_IN,
1535                    h_align=Text.HAlign.CENTER,
1536                    v_align=Text.VAlign.CENTER,
1537                    transition_delay=0,
1538                ).autoretain()
1539                if best_player_rank is not None:
1540                    Text(
1541                        bs.Lstr(
1542                            resource='currentStandingText',
1543                            fallback_resource='bestRankText',
1544                            subs=[('${RANK}', str(best_player_rank))],
1545                        ),
1546                        position=(0, -155),
1547                        color=(1, 1, 1, 0.7),
1548                        h_align=Text.HAlign.CENTER,
1549                        transition=Text.Transition.FADE_IN,
1550                        scale=0.7,
1551                        transition_delay=1.0,
1552                    ).autoretain()
1553        else:
1554            ZoomText(
1555                (
1556                    f'{rating:.1f}'
1557                    if available
1558                    else bs.Lstr(resource='unavailableText')
1559                ),
1560                flash=True,
1561                trail=True,
1562                scale=0.6 if available else 0.333,
1563                tilt_translate=0.11,
1564                h_align='center',
1565                position=(190 + offs_x, -94),
1566                maxwidth=200,
1567                jitter=1.0,
1568            ).autoretain()
1569
1570            if available:
1571                if rating >= 9.5:
1572                    stars = 3
1573                elif rating >= 7.5:
1574                    stars = 2
1575                elif rating > 0.0:
1576                    stars = 1
1577                else:
1578                    stars = 0
1579                star_tex = bs.gettexture('star')
1580                star_x = 135 + offs_x
1581                for _i in range(stars):
1582                    img = bs.NodeActor(
1583                        bs.newnode(
1584                            'image',
1585                            attrs={
1586                                'texture': star_tex,
1587                                'position': (star_x, -16),
1588                                'scale': (62, 62),
1589                                'opacity': 1.0,
1590                                'color': (2.2, 1.2, 0.3),
1591                                'absolute_scale': True,
1592                            },
1593                        )
1594                    ).autoretain()
1595
1596                    assert img.node
1597                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1598                    star_x += 60
1599                for _i in range(3 - stars):
1600                    img = bs.NodeActor(
1601                        bs.newnode(
1602                            'image',
1603                            attrs={
1604                                'texture': star_tex,
1605                                'position': (star_x, -16),
1606                                'scale': (62, 62),
1607                                'opacity': 1.0,
1608                                'color': (0.3, 0.3, 0.3),
1609                                'absolute_scale': True,
1610                            },
1611                        )
1612                    ).autoretain()
1613                    assert img.node
1614                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1615                    star_x += 60
1616
1617                def dostar(
1618                    count: int, xval: float, offs_y: float, score: str
1619                ) -> None:
1620                    Text(
1621                        score + ' =',
1622                        position=(xval, -64 + offs_y),
1623                        color=(0.6, 0.6, 0.6, 0.6),
1624                        h_align=Text.HAlign.CENTER,
1625                        v_align=Text.VAlign.CENTER,
1626                        transition=Text.Transition.FADE_IN,
1627                        scale=0.4,
1628                        transition_delay=1.0,
1629                    ).autoretain()
1630                    stx = xval + 20
1631                    for _i2 in range(count):
1632                        img2 = bs.NodeActor(
1633                            bs.newnode(
1634                                'image',
1635                                attrs={
1636                                    'texture': star_tex,
1637                                    'position': (stx, -64 + offs_y),
1638                                    'scale': (12, 12),
1639                                    'opacity': 0.7,
1640                                    'color': (2.2, 1.2, 0.3),
1641                                    'absolute_scale': True,
1642                                },
1643                            )
1644                        ).autoretain()
1645                        assert img2.node
1646                        bs.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
1647                        stx += 13.0
1648
1649                dostar(1, -44 - 30, -112, '0.0')
1650                dostar(2, 10 - 30, -112, '7.5')
1651                dostar(3, 77 - 30, -112, '9.5')
1652            try:
1653                best_rank = self._campaign.getlevel(self._level_name).rating
1654            except Exception:
1655                best_rank = 0.0
1656
1657            if available:
1658                Text(
1659                    bs.Lstr(
1660                        resource='outOfText',
1661                        subs=[
1662                            (
1663                                '${RANK}',
1664                                str(int(self._show_info['results']['rank'])),
1665                            ),
1666                            (
1667                                '${ALL}',
1668                                str(self._show_info['results']['total']),
1669                            ),
1670                        ],
1671                    ),
1672                    position=(0, -155 if self._newly_complete else -145),
1673                    color=(1, 1, 1, 0.7),
1674                    h_align=Text.HAlign.CENTER,
1675                    transition=Text.Transition.FADE_IN,
1676                    scale=0.55,
1677                    transition_delay=1.0,
1678                ).autoretain()
1679
1680            new_best = best_rank > self._old_best_rank and best_rank > 0.0
1681            was_string = bs.Lstr(
1682                value=' ${A}',
1683                subs=[
1684                    ('${A}', bs.Lstr(resource='scoreWasText')),
1685                    ('${COUNT}', str(self._old_best_rank)),
1686                ],
1687            )
1688            if not self._newly_complete:
1689                Text(
1690                    bs.Lstr(
1691                        value='${A}${B}',
1692                        subs=[
1693                            ('${A}', bs.Lstr(resource='newPersonalBestText')),
1694                            ('${B}', was_string),
1695                        ],
1696                    )
1697                    if new_best
1698                    else bs.Lstr(
1699                        resource='bestRatingText',
1700                        subs=[('${RATING}', str(best_rank))],
1701                    ),
1702                    position=(0, -165),
1703                    color=(1, 1, 1, 0.7),
1704                    flash=new_best,
1705                    h_align=Text.HAlign.CENTER,
1706                    transition=(
1707                        Text.Transition.IN_RIGHT
1708                        if new_best
1709                        else Text.Transition.FADE_IN
1710                    ),
1711                    scale=0.5,
1712                    transition_delay=1.0,
1713                ).autoretain()
1714
1715            Text(
1716                bs.Lstr(
1717                    value='${A}:',
1718                    subs=[('${A}', bs.Lstr(resource='ratingText'))],
1719                ),
1720                position=(0, 36),
1721                maxwidth=300,
1722                transition=Text.Transition.FADE_IN,
1723                h_align=Text.HAlign.CENTER,
1724                v_align=Text.VAlign.CENTER,
1725                transition_delay=0,
1726            ).autoretain()
1727
1728        bs.timer(0.35, self._score_display_sound.play)
1729        if not error:
1730            bs.timer(0.35, self.cymbal_sound.play)
1731
1732    def _show_fail(self) -> None:
1733        ZoomText(
1734            bs.Lstr(resource='failText'),
1735            maxwidth=300,
1736            flash=False,
1737            trail=True,
1738            h_align='center',
1739            tilt_translate=0.11,
1740            position=(0, 40),
1741            jitter=1.0,
1742        ).autoretain()
1743        if self._fail_message is not None:
1744            Text(
1745                self._fail_message,
1746                h_align=Text.HAlign.CENTER,
1747                position=(0, -130),
1748                maxwidth=300,
1749                color=(1, 1, 1, 0.5),
1750                transition=Text.Transition.FADE_IN,
1751                transition_delay=1.0,
1752            ).autoretain()
1753        bs.timer(0.35, self._score_display_sound.play)
1754
1755    def _show_score_val(self, offs_x: float) -> None:
1756        assert self._score_type is not None
1757        assert self._score is not None
1758        ZoomText(
1759            (
1760                str(self._score)
1761                if self._score_type == 'points'
1762                else bs.timestring((self._score * 10) / 1000.0)
1763            ),
1764            maxwidth=300,
1765            flash=True,
1766            trail=True,
1767            scale=1.0 if self._score_type == 'points' else 0.6,
1768            h_align='center',
1769            tilt_translate=0.11,
1770            position=(190 + offs_x, 115),
1771            jitter=1.0,
1772        ).autoretain()
1773        Text(
1774            bs.Lstr(
1775                value='${A}:',
1776                subs=[('${A}', bs.Lstr(resource='finalScoreText'))],
1777            )
1778            if self._score_type == 'points'
1779            else bs.Lstr(
1780                value='${A}:',
1781                subs=[('${A}', bs.Lstr(resource='finalTimeText'))],
1782            ),
1783            maxwidth=300,
1784            position=(0, 200),
1785            transition=Text.Transition.FADE_IN,
1786            h_align=Text.HAlign.CENTER,
1787            v_align=Text.VAlign.CENTER,
1788            transition_delay=0,
1789        ).autoretain()
1790        bs.timer(0.35, self._score_display_sound.play)
class CoopScoreScreen(bascenev1._activity.Activity[bascenev1._player.Player, bascenev1._team.Team]):
  26class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
  27    """Score screen showing the results of a cooperative game."""
  28
  29    def __init__(self, settings: dict):
  30        # pylint: disable=too-many-statements
  31        super().__init__(settings)
  32
  33        plus = bs.app.plus
  34        assert plus is not None
  35
  36        # Keep prev activity alive while we fade in
  37        self.transition_time = 0.5
  38        self.inherits_tint = True
  39        self.inherits_vr_camera_offset = True
  40        self.inherits_music = True
  41        self.use_fixed_vr_overlay = True
  42
  43        self._do_new_rating: bool = self.session.tournament_id is not None
  44
  45        self._score_display_sound = bs.getsound('scoreHit01')
  46        self._score_display_sound_small = bs.getsound('scoreHit02')
  47        self.drum_roll_sound = bs.getsound('drumRoll')
  48        self.cymbal_sound = bs.getsound('cymbal')
  49
  50        self._replay_icon_texture = bui.gettexture('replayIcon')
  51        self._menu_icon_texture = bui.gettexture('menuIcon')
  52        self._next_level_icon_texture = bui.gettexture('nextLevelIcon')
  53
  54        self._campaign: bs.Campaign = settings['campaign']
  55
  56        self._have_achievements = (
  57            bs.app.classic is not None
  58            and bs.app.classic.ach.achievements_for_coop_level(
  59                self._campaign.name + ':' + settings['level']
  60            )
  61        )
  62
  63        self._account_type = (
  64            plus.get_v1_account_type()
  65            if plus.get_v1_account_state() == 'signed_in'
  66            else None
  67        )
  68
  69        self._game_service_icon_color: Sequence[float] | None
  70        self._game_service_achievements_texture: bui.Texture | None
  71        self._game_service_leaderboards_texture: bui.Texture | None
  72
  73        if self._account_type == 'Game Center':
  74            self._game_service_icon_color = (1.0, 1.0, 1.0)
  75            icon = bui.gettexture('gameCenterIcon')
  76            self._game_service_achievements_texture = icon
  77            self._game_service_leaderboards_texture = icon
  78            self._account_has_achievements = True
  79        elif self._account_type == 'Game Circle':
  80            icon = bui.gettexture('gameCircleIcon')
  81            self._game_service_icon_color = (1, 1, 1)
  82            self._game_service_achievements_texture = icon
  83            self._game_service_leaderboards_texture = icon
  84            self._account_has_achievements = True
  85        elif self._account_type == 'Google Play':
  86            self._game_service_icon_color = (0.8, 1.0, 0.6)
  87            self._game_service_achievements_texture = bui.gettexture(
  88                'googlePlayAchievementsIcon'
  89            )
  90            self._game_service_leaderboards_texture = bui.gettexture(
  91                'googlePlayLeaderboardsIcon'
  92            )
  93            self._account_has_achievements = True
  94        else:
  95            self._game_service_icon_color = None
  96            self._game_service_achievements_texture = None
  97            self._game_service_leaderboards_texture = None
  98            self._account_has_achievements = False
  99
 100        self._cashregistersound = bs.getsound('cashRegister')
 101        self._gun_cocking_sound = bs.getsound('gunCocking')
 102        self._dingsound = bs.getsound('ding')
 103        self._score_link: str | None = None
 104        self._root_ui: bui.Widget | None = None
 105        self._background: bs.Actor | None = None
 106        self._old_best_rank = 0.0
 107        self._game_name_str: str | None = None
 108        self._game_config_str: str | None = None
 109
 110        # Ui bits.
 111        self._corner_button_offs: tuple[float, float] | None = None
 112        self._league_rank_button: LeagueRankButton | None = None
 113        self._store_button_instance: StoreButton | None = None
 114        self._restart_button: bui.Widget | None = None
 115        self._update_corner_button_positions_timer: bui.AppTimer | None = None
 116        self._next_level_error: bs.Actor | None = None
 117
 118        # Score/gameplay bits.
 119        self._was_complete: bool | None = None
 120        self._is_complete: bool | None = None
 121        self._newly_complete: bool | None = None
 122        self._is_more_levels: bool | None = None
 123        self._next_level_name: str | None = None
 124        self._show_info: dict[str, Any] | None = None
 125        self._name_str: str | None = None
 126        self._friends_loading_status: bs.Actor | None = None
 127        self._score_loading_status: bs.Actor | None = None
 128        self._tournament_time_remaining: float | None = None
 129        self._tournament_time_remaining_text: Text | None = None
 130        self._tournament_time_remaining_text_timer: bs.BaseTimer | None = None
 131
 132        # Stuff for activity skip by pressing button
 133        self._birth_time = bs.time()
 134        self._min_view_time = 5.0
 135        self._allow_server_transition = False
 136        self._server_transitioning: bool | None = None
 137
 138        self._playerinfos: list[bs.PlayerInfo] = settings['playerinfos']
 139        assert isinstance(self._playerinfos, list)
 140        assert all(isinstance(i, bs.PlayerInfo) for i in self._playerinfos)
 141
 142        self._score: int | None = settings['score']
 143        assert isinstance(self._score, (int, type(None)))
 144
 145        self._fail_message: bs.Lstr | None = settings['fail_message']
 146        assert isinstance(self._fail_message, (bs.Lstr, type(None)))
 147
 148        self._begin_time: float | None = None
 149
 150        self._score_order: str
 151        if 'score_order' in settings:
 152            if not settings['score_order'] in ['increasing', 'decreasing']:
 153                raise ValueError(
 154                    'Invalid score order: ' + settings['score_order']
 155                )
 156            self._score_order = settings['score_order']
 157        else:
 158            self._score_order = 'increasing'
 159        assert isinstance(self._score_order, str)
 160
 161        self._score_type: str
 162        if 'score_type' in settings:
 163            if not settings['score_type'] in ['points', 'time']:
 164                raise ValueError(
 165                    'Invalid score type: ' + settings['score_type']
 166                )
 167            self._score_type = settings['score_type']
 168        else:
 169            self._score_type = 'points'
 170        assert isinstance(self._score_type, str)
 171
 172        self._level_name: str = settings['level']
 173        assert isinstance(self._level_name, str)
 174
 175        self._game_name_str = self._campaign.name + ':' + self._level_name
 176        self._game_config_str = (
 177            str(len(self._playerinfos))
 178            + 'p'
 179            + self._campaign.getlevel(self._level_name)
 180            .get_score_version_string()
 181            .replace(' ', '_')
 182        )
 183
 184        try:
 185            self._old_best_rank = self._campaign.getlevel(
 186                self._level_name
 187            ).rating
 188        except Exception:
 189            self._old_best_rank = 0.0
 190
 191        self._victory: bool = settings['outcome'] == 'victory'
 192
 193    def __del__(self) -> None:
 194        super().__del__()
 195
 196        # If our UI is still up, kill it.
 197        if self._root_ui:
 198            with bui.ContextRef.empty():
 199                bui.containerwidget(edit=self._root_ui, transition='out_left')
 200
 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.classic is not None:
 292            bs.app.classic.show_online_score_ui(
 293                'leaderboard',
 294                game=self._game_name_str,
 295                game_version=self._game_config_str,
 296            )
 297        else:
 298            logging.warning('show_online_score_ui requires classic')
 299
 300    def _ui_show_achievements(self) -> None:
 301        if bs.app.classic is not None:
 302            bs.app.classic.show_online_score_ui('achievements')
 303        else:
 304            logging.warning('show_online_score_ui requires classic')
 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    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    def on_begin(self) -> None:
 593        # FIXME: Clean this up.
 594        # pylint: disable=too-many-statements
 595        # pylint: disable=too-many-branches
 596        # pylint: disable=too-many-locals
 597        super().on_begin()
 598
 599        app = bs.app
 600        env = app.env
 601        plus = app.plus
 602        assert plus is not None
 603
 604        self._begin_time = bs.time()
 605
 606        # Calc whether the level is complete and other stuff.
 607        levels = self._campaign.levels
 608        level = self._campaign.getlevel(self._level_name)
 609        self._was_complete = level.complete
 610        self._is_complete = self._was_complete or self._victory
 611        self._newly_complete = self._is_complete and not self._was_complete
 612        self._is_more_levels = (
 613            level.index < len(levels) - 1
 614        ) and self._campaign.sequential
 615
 616        # Any time we complete a level, set the next one as unlocked.
 617        if self._is_complete and self._is_more_levels:
 618            plus.add_v1_account_transaction(
 619                {
 620                    'type': 'COMPLETE_LEVEL',
 621                    'campaign': self._campaign.name,
 622                    'level': self._level_name,
 623                }
 624            )
 625            self._next_level_name = levels[level.index + 1].name
 626
 627            # If this is the first time we completed it, set the next one
 628            # as current.
 629            if self._newly_complete:
 630                cfg = app.config
 631                cfg['Selected Coop Game'] = (
 632                    self._campaign.name + ':' + self._next_level_name
 633                )
 634                cfg.commit()
 635                self._campaign.set_selected_level(self._next_level_name)
 636
 637        bs.timer(1.0, bs.WeakCall(self.request_ui))
 638
 639        if (
 640            self._is_complete
 641            and self._victory
 642            and self._is_more_levels
 643            and not (env.demo or env.arcade)
 644        ):
 645            Text(
 646                bs.Lstr(
 647                    value='${A}:\n',
 648                    subs=[('${A}', bs.Lstr(resource='levelUnlockedText'))],
 649                )
 650                if self._newly_complete
 651                else bs.Lstr(
 652                    value='${A}:\n',
 653                    subs=[('${A}', bs.Lstr(resource='nextLevelText'))],
 654                ),
 655                transition=Text.Transition.IN_RIGHT,
 656                transition_delay=5.2,
 657                flash=self._newly_complete,
 658                scale=0.54,
 659                h_align=Text.HAlign.CENTER,
 660                maxwidth=270,
 661                color=(0.5, 0.7, 0.5, 1),
 662                position=(270, -235),
 663            ).autoretain()
 664            assert self._next_level_name is not None
 665            Text(
 666                bs.Lstr(translate=('coopLevelNames', self._next_level_name)),
 667                transition=Text.Transition.IN_RIGHT,
 668                transition_delay=5.2,
 669                flash=self._newly_complete,
 670                scale=0.7,
 671                h_align=Text.HAlign.CENTER,
 672                maxwidth=205,
 673                color=(0.5, 0.7, 0.5, 1),
 674                position=(270, -255),
 675            ).autoretain()
 676            if self._newly_complete:
 677                bs.timer(5.2, self._cashregistersound.play)
 678                bs.timer(5.2, self._dingsound.play)
 679
 680        offs_x = -195
 681        if len(self._playerinfos) > 1:
 682            pstr = bs.Lstr(
 683                value='- ${A} -',
 684                subs=[
 685                    (
 686                        '${A}',
 687                        bs.Lstr(
 688                            resource='multiPlayerCountText',
 689                            subs=[('${COUNT}', str(len(self._playerinfos)))],
 690                        ),
 691                    )
 692                ],
 693            )
 694        else:
 695            pstr = bs.Lstr(
 696                value='- ${A} -',
 697                subs=[('${A}', bs.Lstr(resource='singlePlayerCountText'))],
 698            )
 699        ZoomText(
 700            self._campaign.getlevel(self._level_name).displayname,
 701            maxwidth=800,
 702            flash=False,
 703            trail=False,
 704            color=(0.5, 1, 0.5, 1),
 705            h_align='center',
 706            scale=0.4,
 707            position=(0, 292),
 708            jitter=1.0,
 709        ).autoretain()
 710        Text(
 711            pstr,
 712            maxwidth=300,
 713            transition=Text.Transition.FADE_IN,
 714            scale=0.7,
 715            h_align=Text.HAlign.CENTER,
 716            v_align=Text.VAlign.CENTER,
 717            color=(0.5, 0.7, 0.5, 1),
 718            position=(0, 230),
 719        ).autoretain()
 720
 721        if app.classic is not None and app.classic.server is None:
 722            # If we're running in normal non-headless build, show this text
 723            # because only host can continue the game.
 724            adisp = plus.get_v1_account_display_string()
 725            txt = Text(
 726                bs.Lstr(
 727                    resource='waitingForHostText', subs=[('${HOST}', adisp)]
 728                ),
 729                maxwidth=300,
 730                transition=Text.Transition.FADE_IN,
 731                transition_delay=8.0,
 732                scale=0.85,
 733                h_align=Text.HAlign.CENTER,
 734                v_align=Text.VAlign.CENTER,
 735                color=(1, 1, 0, 1),
 736                position=(0, -230),
 737            ).autoretain()
 738            assert txt.node
 739            txt.node.client_only = True
 740        else:
 741            # In headless build, anyone can continue the game.
 742            sval = bs.Lstr(resource='pressAnyButtonPlayAgainText')
 743            Text(
 744                sval,
 745                v_attach=Text.VAttach.BOTTOM,
 746                h_align=Text.HAlign.CENTER,
 747                flash=True,
 748                vr_depth=50,
 749                position=(0, 60),
 750                scale=0.8,
 751                color=(0.5, 0.7, 0.5, 0.5),
 752                transition=Text.Transition.IN_BOTTOM_SLOW,
 753                transition_delay=self._min_view_time,
 754            ).autoretain()
 755
 756        if self._score is not None:
 757            bs.timer(0.35, self._score_display_sound_small.play)
 758
 759        # Vestigial remain; this stuff should just be instance vars.
 760        self._show_info = {}
 761
 762        if self._score is not None:
 763            bs.timer(0.8, bs.WeakCall(self._show_score_val, offs_x))
 764        else:
 765            bs.pushcall(bs.WeakCall(self._show_fail))
 766
 767        self._name_str = name_str = ', '.join(
 768            [p.name for p in self._playerinfos]
 769        )
 770
 771        self._score_loading_status = Text(
 772            bs.Lstr(
 773                value='${A}...',
 774                subs=[('${A}', bs.Lstr(resource='loadingText'))],
 775            ),
 776            position=(280, 150 + 30),
 777            color=(1, 1, 1, 0.4),
 778            transition=Text.Transition.FADE_IN,
 779            scale=0.7,
 780            transition_delay=2.0,
 781        )
 782
 783        if self._score is not None:
 784            bs.timer(0.4, bs.WeakCall(self._play_drumroll))
 785
 786        # Add us to high scores, filter, and store.
 787        our_high_scores_all = self._campaign.getlevel(
 788            self._level_name
 789        ).get_high_scores()
 790
 791        our_high_scores = our_high_scores_all.setdefault(
 792            str(len(self._playerinfos)) + ' Player', []
 793        )
 794
 795        if self._score is not None:
 796            our_score: list | None = [
 797                self._score,
 798                {
 799                    'players': [
 800                        {'name': p.name, 'character': p.character}
 801                        for p in self._playerinfos
 802                    ]
 803                },
 804            ]
 805            our_high_scores.append(our_score)
 806        else:
 807            our_score = None
 808
 809        try:
 810            our_high_scores.sort(
 811                reverse=self._score_order == 'increasing', key=lambda x: x[0]
 812            )
 813        except Exception:
 814            logging.exception('Error sorting scores.')
 815            print(f'our_high_scores: {our_high_scores}')
 816
 817        del our_high_scores[10:]
 818
 819        if self._score is not None:
 820            sver = self._campaign.getlevel(
 821                self._level_name
 822            ).get_score_version_string()
 823            plus.add_v1_account_transaction(
 824                {
 825                    'type': 'SET_LEVEL_LOCAL_HIGH_SCORES',
 826                    'campaign': self._campaign.name,
 827                    'level': self._level_name,
 828                    'scoreVersion': sver,
 829                    'scores': our_high_scores_all,
 830                }
 831            )
 832        if plus.get_v1_account_state() != 'signed_in':
 833            # We expect this only in kiosk mode; complain otherwise.
 834            if not (env.demo or env.arcade):
 835                logging.error('got not-signed-in at score-submit; unexpected')
 836            bs.pushcall(bs.WeakCall(self._got_score_results, None))
 837        else:
 838            assert self._game_name_str is not None
 839            assert self._game_config_str is not None
 840            plus.submit_score(
 841                self._game_name_str,
 842                self._game_config_str,
 843                name_str,
 844                self._score,
 845                bs.WeakCall(self._got_score_results),
 846                order=self._score_order,
 847                tournament_id=self.session.tournament_id,
 848                score_type=self._score_type,
 849                campaign=self._campaign.name,
 850                level=self._level_name,
 851            )
 852
 853        # Apply the transactions we've been adding locally.
 854        plus.run_v1_account_transactions()
 855
 856        # If we're not doing the world's-best button, just show a title
 857        # instead.
 858        ts_height = 300
 859        ts_h_offs = 210
 860        v_offs = 40
 861        txt = Text(
 862            bs.Lstr(resource='tournamentStandingsText')
 863            if self.session.tournament_id is not None
 864            else bs.Lstr(resource='worldsBestScoresText')
 865            if self._score_type == 'points'
 866            else bs.Lstr(resource='worldsBestTimesText'),
 867            maxwidth=210,
 868            position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 869            transition=Text.Transition.IN_LEFT,
 870            v_align=Text.VAlign.CENTER,
 871            scale=1.2,
 872            transition_delay=2.2,
 873        ).autoretain()
 874
 875        # If we've got a button on the server, only show this on clients.
 876        if self._should_show_worlds_best_button():
 877            assert txt.node
 878            txt.node.client_only = True
 879
 880        ts_height = 300
 881        ts_h_offs = -480
 882        v_offs = 40
 883        Text(
 884            bs.Lstr(resource='yourBestScoresText')
 885            if self._score_type == 'points'
 886            else bs.Lstr(resource='yourBestTimesText'),
 887            maxwidth=210,
 888            position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 20),
 889            transition=Text.Transition.IN_RIGHT,
 890            v_align=Text.VAlign.CENTER,
 891            scale=1.2,
 892            transition_delay=1.8,
 893        ).autoretain()
 894
 895        display_scores = list(our_high_scores)
 896        display_count = 5
 897
 898        while len(display_scores) < display_count:
 899            display_scores.append((0, None))
 900
 901        showed_ours = False
 902        h_offs_extra = 85 if self._score_type == 'points' else 130
 903        v_offs_extra = 20
 904        v_offs_names = 0
 905        scale = 1.0
 906        p_count = len(self._playerinfos)
 907        h_offs_extra -= 75
 908        if p_count > 1:
 909            h_offs_extra -= 20
 910        if p_count == 2:
 911            scale = 0.9
 912        elif p_count == 3:
 913            scale = 0.65
 914        elif p_count == 4:
 915            scale = 0.5
 916        times: list[tuple[float, float]] = []
 917        for i in range(display_count):
 918            times.insert(
 919                random.randrange(0, len(times) + 1),
 920                (1.9 + i * 0.05, 2.3 + i * 0.05),
 921            )
 922        for i in range(display_count):
 923            try:
 924                if display_scores[i][1] is None:
 925                    name_str = '-'
 926                else:
 927                    # noinspection PyUnresolvedReferences
 928                    name_str = ', '.join(
 929                        [p['name'] for p in display_scores[i][1]['players']]
 930                    )
 931            except Exception:
 932                logging.exception(
 933                    'Error calcing name_str for %s.', display_scores
 934                )
 935                name_str = '-'
 936            if display_scores[i] == our_score and not showed_ours:
 937                flash = True
 938                color0 = (0.6, 0.4, 0.1, 1.0)
 939                color1 = (0.6, 0.6, 0.6, 1.0)
 940                tdelay1 = 3.7
 941                tdelay2 = 3.7
 942                showed_ours = True
 943            else:
 944                flash = False
 945                color0 = (0.6, 0.4, 0.1, 1.0)
 946                color1 = (0.6, 0.6, 0.6, 1.0)
 947                tdelay1 = times[i][0]
 948                tdelay2 = times[i][1]
 949            Text(
 950                str(display_scores[i][0])
 951                if self._score_type == 'points'
 952                else bs.timestring((display_scores[i][0] * 10) / 1000.0),
 953                position=(
 954                    ts_h_offs + 20 + h_offs_extra,
 955                    v_offs_extra
 956                    + ts_height / 2
 957                    + -ts_height * (i + 1) / 10
 958                    + v_offs
 959                    + 11.0,
 960                ),
 961                h_align=Text.HAlign.RIGHT,
 962                v_align=Text.VAlign.CENTER,
 963                color=color0,
 964                flash=flash,
 965                transition=Text.Transition.IN_RIGHT,
 966                transition_delay=tdelay1,
 967            ).autoretain()
 968
 969            Text(
 970                bs.Lstr(value=name_str),
 971                position=(
 972                    ts_h_offs + 35 + h_offs_extra,
 973                    v_offs_extra
 974                    + ts_height / 2
 975                    + -ts_height * (i + 1) / 10
 976                    + v_offs_names
 977                    + v_offs
 978                    + 11.0,
 979                ),
 980                maxwidth=80.0 + 100.0 * len(self._playerinfos),
 981                v_align=Text.VAlign.CENTER,
 982                color=color1,
 983                flash=flash,
 984                scale=scale,
 985                transition=Text.Transition.IN_RIGHT,
 986                transition_delay=tdelay2,
 987            ).autoretain()
 988
 989        # Show achievements for this level.
 990        ts_height = -150
 991        ts_h_offs = -480
 992        v_offs = 40
 993
 994        # Only make this if we don't have the button
 995        # (never want clients to see it so no need for client-only
 996        # version, etc).
 997        if self._have_achievements:
 998            if not self._account_has_achievements:
 999                Text(
1000                    bs.Lstr(resource='achievementsText'),
1001                    position=(ts_h_offs - 10, ts_height / 2 + 25 + v_offs + 3),
1002                    maxwidth=210,
1003                    host_only=True,
1004                    transition=Text.Transition.IN_RIGHT,
1005                    v_align=Text.VAlign.CENTER,
1006                    scale=1.2,
1007                    transition_delay=2.8,
1008                ).autoretain()
1009
1010            assert self._game_name_str is not None
1011            assert bs.app.classic is not None
1012            achievements = bs.app.classic.ach.achievements_for_coop_level(
1013                self._game_name_str
1014            )
1015            hval = -455
1016            vval = -100
1017            tdelay = 0.0
1018            for ach in achievements:
1019                ach.create_display(hval, vval + v_offs, 3.0 + tdelay)
1020                vval -= 55
1021                tdelay += 0.250
1022
1023        bs.timer(5.0, bs.WeakCall(self._show_tips))
1024
1025    def _play_drumroll(self) -> None:
1026        bs.NodeActor(
1027            bs.newnode(
1028                'sound',
1029                attrs={
1030                    'sound': self.drum_roll_sound,
1031                    'positional': False,
1032                    'loop': False,
1033                },
1034            )
1035        ).autoretain()
1036
1037    def _got_friend_score_results(self, results: list[Any] | None) -> None:
1038        # FIXME: tidy this up
1039        # pylint: disable=too-many-locals
1040        # pylint: disable=too-many-branches
1041        # pylint: disable=too-many-statements
1042        from efro.util import asserttype
1043
1044        # delay a bit if results come in too fast
1045        assert self._begin_time is not None
1046        base_delay = max(0, 1.9 - (bs.time() - self._begin_time))
1047        ts_height = 300
1048        ts_h_offs = -550
1049        v_offs = 30
1050
1051        # Report in case of error.
1052        if results is None:
1053            self._friends_loading_status = Text(
1054                bs.Lstr(resource='friendScoresUnavailableText'),
1055                maxwidth=330,
1056                position=(-475, 150 + v_offs),
1057                color=(1, 1, 1, 0.4),
1058                transition=Text.Transition.FADE_IN,
1059                transition_delay=base_delay + 0.8,
1060                scale=0.7,
1061            )
1062            return
1063
1064        self._friends_loading_status = None
1065
1066        # Ok, it looks like we aren't able to reliably get a just-submitted
1067        # result returned in the score list, so we need to look for our score
1068        # in this list and replace it if ours is better or add ours otherwise.
1069        if self._score is not None:
1070            our_score_entry = [self._score, 'Me', True]
1071            for score in results:
1072                if score[2]:
1073                    if self._score_order == 'increasing':
1074                        our_score_entry[0] = max(score[0], self._score)
1075                    else:
1076                        our_score_entry[0] = min(score[0], self._score)
1077                    results.remove(score)
1078                    break
1079            results.append(our_score_entry)
1080            results.sort(
1081                reverse=self._score_order == 'increasing',
1082                key=lambda x: asserttype(x[0], int),
1083            )
1084
1085        # If we're not submitting our own score, we still want to change the
1086        # name of our own score to 'Me'.
1087        else:
1088            for score in results:
1089                if score[2]:
1090                    score[1] = 'Me'
1091                    break
1092        h_offs_extra = 80 if self._score_type == 'points' else 130
1093        v_offs_extra = 20
1094        v_offs_names = 0
1095        scale = 1.0
1096
1097        # Make sure there's at least 5.
1098        while len(results) < 5:
1099            results.append([0, '-', False])
1100        results = results[:5]
1101        times: list[tuple[float, float]] = []
1102        for i in range(len(results)):
1103            times.insert(
1104                random.randrange(0, len(times) + 1),
1105                (base_delay + i * 0.05, base_delay + 0.3 + i * 0.05),
1106            )
1107        for i, tval in enumerate(results):
1108            score = int(tval[0])
1109            name_str = tval[1]
1110            is_me = tval[2]
1111            if is_me and score == self._score:
1112                flash = True
1113                color0 = (0.6, 0.4, 0.1, 1.0)
1114                color1 = (0.6, 0.6, 0.6, 1.0)
1115                tdelay1 = base_delay + 1.0
1116                tdelay2 = base_delay + 1.0
1117            else:
1118                flash = False
1119                if is_me:
1120                    color0 = (0.6, 0.4, 0.1, 1.0)
1121                    color1 = (0.9, 1.0, 0.9, 1.0)
1122                else:
1123                    color0 = (0.6, 0.4, 0.1, 1.0)
1124                    color1 = (0.6, 0.6, 0.6, 1.0)
1125                tdelay1 = times[i][0]
1126                tdelay2 = times[i][1]
1127            if name_str != '-':
1128                Text(
1129                    str(score)
1130                    if self._score_type == 'points'
1131                    else bs.timestring((score * 10) / 1000.0),
1132                    position=(
1133                        ts_h_offs + 20 + h_offs_extra,
1134                        v_offs_extra
1135                        + ts_height / 2
1136                        + -ts_height * (i + 1) / 10
1137                        + v_offs
1138                        + 11.0,
1139                    ),
1140                    h_align=Text.HAlign.RIGHT,
1141                    v_align=Text.VAlign.CENTER,
1142                    color=color0,
1143                    flash=flash,
1144                    transition=Text.Transition.IN_RIGHT,
1145                    transition_delay=tdelay1,
1146                ).autoretain()
1147            else:
1148                if is_me:
1149                    print('Error: got empty name_str on score result:', tval)
1150
1151            Text(
1152                bs.Lstr(value=name_str),
1153                position=(
1154                    ts_h_offs + 35 + h_offs_extra,
1155                    v_offs_extra
1156                    + ts_height / 2
1157                    + -ts_height * (i + 1) / 10
1158                    + v_offs_names
1159                    + v_offs
1160                    + 11.0,
1161                ),
1162                color=color1,
1163                maxwidth=160.0,
1164                v_align=Text.VAlign.CENTER,
1165                flash=flash,
1166                scale=scale,
1167                transition=Text.Transition.IN_RIGHT,
1168                transition_delay=tdelay2,
1169            ).autoretain()
1170
1171    def _got_score_results(self, results: dict[str, Any] | None) -> None:
1172        # FIXME: tidy this up
1173        # pylint: disable=too-many-locals
1174        # pylint: disable=too-many-branches
1175        # pylint: disable=too-many-statements
1176
1177        plus = bs.app.plus
1178        assert plus is not None
1179
1180        # We need to manually run this in the context of our activity
1181        # and only if we aren't shutting down.
1182        # (really should make the submit_score call handle that stuff itself)
1183        if self.expired:
1184            return
1185        with self.context:
1186            # Delay a bit if results come in too fast.
1187            assert self._begin_time is not None
1188            base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
1189            v_offs = 20
1190            if results is None:
1191                self._score_loading_status = Text(
1192                    bs.Lstr(resource='worldScoresUnavailableText'),
1193                    position=(230, 150 + v_offs),
1194                    color=(1, 1, 1, 0.4),
1195                    transition=Text.Transition.FADE_IN,
1196                    transition_delay=base_delay + 0.3,
1197                    scale=0.7,
1198                )
1199            else:
1200                self._score_link = results['link']
1201                assert self._score_link is not None
1202                # Prepend our master-server addr if its a relative addr.
1203                if not self._score_link.startswith(
1204                    'http://'
1205                ) and not self._score_link.startswith('https://'):
1206                    self._score_link = (
1207                        plus.get_master_server_address()
1208                        + '/'
1209                        + self._score_link
1210                    )
1211                self._score_loading_status = None
1212                if 'tournamentSecondsRemaining' in results:
1213                    secs_remaining = results['tournamentSecondsRemaining']
1214                    assert isinstance(secs_remaining, int)
1215                    self._tournament_time_remaining = secs_remaining
1216                    self._tournament_time_remaining_text_timer = bs.BaseTimer(
1217                        1.0,
1218                        bs.WeakCall(
1219                            self._update_tournament_time_remaining_text
1220                        ),
1221                        repeat=True,
1222                    )
1223
1224            assert self._show_info is not None
1225            self._show_info['results'] = results
1226            if results is not None:
1227                if results['tops'] != '':
1228                    self._show_info['tops'] = results['tops']
1229                else:
1230                    self._show_info['tops'] = []
1231            offs_x = -195
1232            available = self._show_info['results'] is not None
1233            if self._score is not None:
1234                bs.basetimer(
1235                    (1.5 + base_delay),
1236                    bs.WeakCall(self._show_world_rank, offs_x),
1237                )
1238            ts_h_offs = 200
1239            ts_height = 300
1240
1241            # Show world tops.
1242            if available:
1243                # Show the number of games represented by this
1244                # list (except for in tournaments).
1245                if self.session.tournament_id is None:
1246                    Text(
1247                        bs.Lstr(
1248                            resource='lastGamesText',
1249                            subs=[
1250                                (
1251                                    '${COUNT}',
1252                                    str(self._show_info['results']['total']),
1253                                )
1254                            ],
1255                        ),
1256                        position=(
1257                            ts_h_offs - 35 + 95,
1258                            ts_height / 2 + 6 + v_offs,
1259                        ),
1260                        color=(0.4, 0.4, 0.4, 1.0),
1261                        scale=0.7,
1262                        transition=Text.Transition.IN_RIGHT,
1263                        transition_delay=base_delay + 0.3,
1264                    ).autoretain()
1265                else:
1266                    v_offs += 20
1267
1268                h_offs_extra = 0
1269                v_offs_names = 0
1270                scale = 1.0
1271                p_count = len(self._playerinfos)
1272                if p_count > 1:
1273                    h_offs_extra -= 40
1274                if self._score_type != 'points':
1275                    h_offs_extra += 60
1276                if p_count == 2:
1277                    scale = 0.9
1278                elif p_count == 3:
1279                    scale = 0.65
1280                elif p_count == 4:
1281                    scale = 0.5
1282
1283                # Make sure there's at least 10.
1284                while len(self._show_info['tops']) < 10:
1285                    self._show_info['tops'].append([0, '-'])
1286
1287                times: list[tuple[float, float]] = []
1288                for i in range(len(self._show_info['tops'])):
1289                    times.insert(
1290                        random.randrange(0, len(times) + 1),
1291                        (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
1292                    )
1293                for i, tval in enumerate(self._show_info['tops']):
1294                    score = int(tval[0])
1295                    name_str = tval[1]
1296                    if self._name_str == name_str and self._score == score:
1297                        flash = True
1298                        color0 = (0.6, 0.4, 0.1, 1.0)
1299                        color1 = (0.6, 0.6, 0.6, 1.0)
1300                        tdelay1 = base_delay + 1.0
1301                        tdelay2 = base_delay + 1.0
1302                    else:
1303                        flash = False
1304                        if self._name_str == name_str:
1305                            color0 = (0.6, 0.4, 0.1, 1.0)
1306                            color1 = (0.9, 1.0, 0.9, 1.0)
1307                        else:
1308                            color0 = (0.6, 0.4, 0.1, 1.0)
1309                            color1 = (0.6, 0.6, 0.6, 1.0)
1310                        tdelay1 = times[i][0]
1311                        tdelay2 = times[i][1]
1312
1313                    if name_str != '-':
1314                        Text(
1315                            str(score)
1316                            if self._score_type == 'points'
1317                            else bs.timestring((score * 10) / 1000.0),
1318                            position=(
1319                                ts_h_offs + 20 + h_offs_extra,
1320                                ts_height / 2
1321                                + -ts_height * (i + 1) / 10
1322                                + v_offs
1323                                + 11.0,
1324                            ),
1325                            h_align=Text.HAlign.RIGHT,
1326                            v_align=Text.VAlign.CENTER,
1327                            color=color0,
1328                            flash=flash,
1329                            transition=Text.Transition.IN_LEFT,
1330                            transition_delay=tdelay1,
1331                        ).autoretain()
1332                    Text(
1333                        bs.Lstr(value=name_str),
1334                        position=(
1335                            ts_h_offs + 35 + h_offs_extra,
1336                            ts_height / 2
1337                            + -ts_height * (i + 1) / 10
1338                            + v_offs_names
1339                            + v_offs
1340                            + 11.0,
1341                        ),
1342                        maxwidth=80.0 + 100.0 * len(self._playerinfos),
1343                        v_align=Text.VAlign.CENTER,
1344                        color=color1,
1345                        flash=flash,
1346                        scale=scale,
1347                        transition=Text.Transition.IN_LEFT,
1348                        transition_delay=tdelay2,
1349                    ).autoretain()
1350
1351    def _show_tips(self) -> None:
1352        from bascenev1lib.actor.tipstext import TipsText
1353
1354        TipsText(offs_y=30).autoretain()
1355
1356    def _update_tournament_time_remaining_text(self) -> None:
1357        if self._tournament_time_remaining is None:
1358            return
1359        self._tournament_time_remaining = max(
1360            0, self._tournament_time_remaining - 1
1361        )
1362        if self._tournament_time_remaining_text is not None:
1363            val = bs.timestring(
1364                self._tournament_time_remaining,
1365                centi=False,
1366            )
1367            self._tournament_time_remaining_text.node.text = val
1368
1369    def _show_world_rank(self, offs_x: float) -> None:
1370        # FIXME: Tidy this up.
1371        # pylint: disable=too-many-locals
1372        # pylint: disable=too-many-branches
1373        # pylint: disable=too-many-statements
1374        assert bs.app.classic is not None
1375        assert self._show_info is not None
1376        available = self._show_info['results'] is not None
1377
1378        if available:
1379            error = (
1380                self._show_info['results']['error']
1381                if 'error' in self._show_info['results']
1382                else None
1383            )
1384            rank = self._show_info['results']['rank']
1385            total = self._show_info['results']['total']
1386            rating = (
1387                10.0
1388                if total == 1
1389                else 10.0 * (1.0 - (float(rank - 1) / (total - 1)))
1390            )
1391            player_rank = self._show_info['results']['playerRank']
1392            best_player_rank = self._show_info['results']['bestPlayerRank']
1393        else:
1394            error = False
1395            rating = None
1396            player_rank = None
1397            best_player_rank = None
1398
1399        # If we've got tournament-seconds-remaining, show it.
1400        if self._tournament_time_remaining is not None:
1401            Text(
1402                bs.Lstr(resource='coopSelectWindow.timeRemainingText'),
1403                position=(-360, -70 - 100),
1404                color=(1, 1, 1, 0.7),
1405                h_align=Text.HAlign.CENTER,
1406                v_align=Text.VAlign.CENTER,
1407                transition=Text.Transition.FADE_IN,
1408                scale=0.8,
1409                maxwidth=300,
1410                transition_delay=2.0,
1411            ).autoretain()
1412            self._tournament_time_remaining_text = Text(
1413                '',
1414                position=(-360, -110 - 100),
1415                color=(1, 1, 1, 0.7),
1416                h_align=Text.HAlign.CENTER,
1417                v_align=Text.VAlign.CENTER,
1418                transition=Text.Transition.FADE_IN,
1419                scale=1.6,
1420                maxwidth=150,
1421                transition_delay=2.0,
1422            )
1423
1424        # If we're a tournament, show prizes.
1425        try:
1426            assert bs.app.classic is not None
1427            tournament_id = self.session.tournament_id
1428            if tournament_id is not None:
1429                if tournament_id in bs.app.classic.accounts.tournament_info:
1430                    tourney_info = bs.app.classic.accounts.tournament_info[
1431                        tournament_id
1432                    ]
1433                    # pylint: disable=useless-suppression
1434                    # pylint: disable=unbalanced-tuple-unpacking
1435                    (
1436                        pr1,
1437                        pv1,
1438                        pr2,
1439                        pv2,
1440                        pr3,
1441                        pv3,
1442                    ) = bs.app.classic.get_tournament_prize_strings(
1443                        tourney_info
1444                    )
1445                    # pylint: enable=unbalanced-tuple-unpacking
1446                    # pylint: enable=useless-suppression
1447
1448                    Text(
1449                        bs.Lstr(resource='coopSelectWindow.prizesText'),
1450                        position=(-360, -70 + 77),
1451                        color=(1, 1, 1, 0.7),
1452                        h_align=Text.HAlign.CENTER,
1453                        v_align=Text.VAlign.CENTER,
1454                        transition=Text.Transition.FADE_IN,
1455                        scale=1.0,
1456                        maxwidth=300,
1457                        transition_delay=2.0,
1458                    ).autoretain()
1459                    vval = -107 + 70
1460                    for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)):
1461                        Text(
1462                            rng,
1463                            position=(-410 + 10, vval),
1464                            color=(1, 1, 1, 0.7),
1465                            h_align=Text.HAlign.RIGHT,
1466                            v_align=Text.VAlign.CENTER,
1467                            transition=Text.Transition.FADE_IN,
1468                            scale=0.6,
1469                            maxwidth=300,
1470                            transition_delay=2.0,
1471                        ).autoretain()
1472                        Text(
1473                            val,
1474                            position=(-390 + 10, vval),
1475                            color=(0.7, 0.7, 0.7, 1.0),
1476                            h_align=Text.HAlign.LEFT,
1477                            v_align=Text.VAlign.CENTER,
1478                            transition=Text.Transition.FADE_IN,
1479                            scale=0.8,
1480                            maxwidth=300,
1481                            transition_delay=2.0,
1482                        ).autoretain()
1483                        vval -= 35
1484        except Exception:
1485            logging.exception('Error showing prize ranges.')
1486
1487        if self._do_new_rating:
1488            if error:
1489                ZoomText(
1490                    bs.Lstr(resource='failText'),
1491                    flash=True,
1492                    trail=True,
1493                    scale=1.0 if available else 0.333,
1494                    tilt_translate=0.11,
1495                    h_align='center',
1496                    position=(190 + offs_x, -60),
1497                    maxwidth=200,
1498                    jitter=1.0,
1499                ).autoretain()
1500                Text(
1501                    bs.Lstr(translate=('serverResponses', error)),
1502                    position=(0, -140),
1503                    color=(1, 1, 1, 0.7),
1504                    h_align=Text.HAlign.CENTER,
1505                    v_align=Text.VAlign.CENTER,
1506                    transition=Text.Transition.FADE_IN,
1507                    scale=0.9,
1508                    maxwidth=400,
1509                    transition_delay=1.0,
1510                ).autoretain()
1511            else:
1512                ZoomText(
1513                    (
1514                        ('#' + str(player_rank))
1515                        if player_rank is not None
1516                        else bs.Lstr(resource='unavailableText')
1517                    ),
1518                    flash=True,
1519                    trail=True,
1520                    scale=1.0 if available else 0.333,
1521                    tilt_translate=0.11,
1522                    h_align='center',
1523                    position=(190 + offs_x, -60),
1524                    maxwidth=200,
1525                    jitter=1.0,
1526                ).autoretain()
1527
1528                Text(
1529                    bs.Lstr(
1530                        value='${A}:',
1531                        subs=[('${A}', bs.Lstr(resource='rankText'))],
1532                    ),
1533                    position=(0, 36),
1534                    maxwidth=300,
1535                    transition=Text.Transition.FADE_IN,
1536                    h_align=Text.HAlign.CENTER,
1537                    v_align=Text.VAlign.CENTER,
1538                    transition_delay=0,
1539                ).autoretain()
1540                if best_player_rank is not None:
1541                    Text(
1542                        bs.Lstr(
1543                            resource='currentStandingText',
1544                            fallback_resource='bestRankText',
1545                            subs=[('${RANK}', str(best_player_rank))],
1546                        ),
1547                        position=(0, -155),
1548                        color=(1, 1, 1, 0.7),
1549                        h_align=Text.HAlign.CENTER,
1550                        transition=Text.Transition.FADE_IN,
1551                        scale=0.7,
1552                        transition_delay=1.0,
1553                    ).autoretain()
1554        else:
1555            ZoomText(
1556                (
1557                    f'{rating:.1f}'
1558                    if available
1559                    else bs.Lstr(resource='unavailableText')
1560                ),
1561                flash=True,
1562                trail=True,
1563                scale=0.6 if available else 0.333,
1564                tilt_translate=0.11,
1565                h_align='center',
1566                position=(190 + offs_x, -94),
1567                maxwidth=200,
1568                jitter=1.0,
1569            ).autoretain()
1570
1571            if available:
1572                if rating >= 9.5:
1573                    stars = 3
1574                elif rating >= 7.5:
1575                    stars = 2
1576                elif rating > 0.0:
1577                    stars = 1
1578                else:
1579                    stars = 0
1580                star_tex = bs.gettexture('star')
1581                star_x = 135 + offs_x
1582                for _i in range(stars):
1583                    img = bs.NodeActor(
1584                        bs.newnode(
1585                            'image',
1586                            attrs={
1587                                'texture': star_tex,
1588                                'position': (star_x, -16),
1589                                'scale': (62, 62),
1590                                'opacity': 1.0,
1591                                'color': (2.2, 1.2, 0.3),
1592                                'absolute_scale': True,
1593                            },
1594                        )
1595                    ).autoretain()
1596
1597                    assert img.node
1598                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1599                    star_x += 60
1600                for _i in range(3 - stars):
1601                    img = bs.NodeActor(
1602                        bs.newnode(
1603                            'image',
1604                            attrs={
1605                                'texture': star_tex,
1606                                'position': (star_x, -16),
1607                                'scale': (62, 62),
1608                                'opacity': 1.0,
1609                                'color': (0.3, 0.3, 0.3),
1610                                'absolute_scale': True,
1611                            },
1612                        )
1613                    ).autoretain()
1614                    assert img.node
1615                    bs.animate(img.node, 'opacity', {0.15: 0, 0.4: 1})
1616                    star_x += 60
1617
1618                def dostar(
1619                    count: int, xval: float, offs_y: float, score: str
1620                ) -> None:
1621                    Text(
1622                        score + ' =',
1623                        position=(xval, -64 + offs_y),
1624                        color=(0.6, 0.6, 0.6, 0.6),
1625                        h_align=Text.HAlign.CENTER,
1626                        v_align=Text.VAlign.CENTER,
1627                        transition=Text.Transition.FADE_IN,
1628                        scale=0.4,
1629                        transition_delay=1.0,
1630                    ).autoretain()
1631                    stx = xval + 20
1632                    for _i2 in range(count):
1633                        img2 = bs.NodeActor(
1634                            bs.newnode(
1635                                'image',
1636                                attrs={
1637                                    'texture': star_tex,
1638                                    'position': (stx, -64 + offs_y),
1639                                    'scale': (12, 12),
1640                                    'opacity': 0.7,
1641                                    'color': (2.2, 1.2, 0.3),
1642                                    'absolute_scale': True,
1643                                },
1644                            )
1645                        ).autoretain()
1646                        assert img2.node
1647                        bs.animate(img2.node, 'opacity', {1.0: 0.0, 1.5: 0.5})
1648                        stx += 13.0
1649
1650                dostar(1, -44 - 30, -112, '0.0')
1651                dostar(2, 10 - 30, -112, '7.5')
1652                dostar(3, 77 - 30, -112, '9.5')
1653            try:
1654                best_rank = self._campaign.getlevel(self._level_name).rating
1655            except Exception:
1656                best_rank = 0.0
1657
1658            if available:
1659                Text(
1660                    bs.Lstr(
1661                        resource='outOfText',
1662                        subs=[
1663                            (
1664                                '${RANK}',
1665                                str(int(self._show_info['results']['rank'])),
1666                            ),
1667                            (
1668                                '${ALL}',
1669                                str(self._show_info['results']['total']),
1670                            ),
1671                        ],
1672                    ),
1673                    position=(0, -155 if self._newly_complete else -145),
1674                    color=(1, 1, 1, 0.7),
1675                    h_align=Text.HAlign.CENTER,
1676                    transition=Text.Transition.FADE_IN,
1677                    scale=0.55,
1678                    transition_delay=1.0,
1679                ).autoretain()
1680
1681            new_best = best_rank > self._old_best_rank and best_rank > 0.0
1682            was_string = bs.Lstr(
1683                value=' ${A}',
1684                subs=[
1685                    ('${A}', bs.Lstr(resource='scoreWasText')),
1686                    ('${COUNT}', str(self._old_best_rank)),
1687                ],
1688            )
1689            if not self._newly_complete:
1690                Text(
1691                    bs.Lstr(
1692                        value='${A}${B}',
1693                        subs=[
1694                            ('${A}', bs.Lstr(resource='newPersonalBestText')),
1695                            ('${B}', was_string),
1696                        ],
1697                    )
1698                    if new_best
1699                    else bs.Lstr(
1700                        resource='bestRatingText',
1701                        subs=[('${RATING}', str(best_rank))],
1702                    ),
1703                    position=(0, -165),
1704                    color=(1, 1, 1, 0.7),
1705                    flash=new_best,
1706                    h_align=Text.HAlign.CENTER,
1707                    transition=(
1708                        Text.Transition.IN_RIGHT
1709                        if new_best
1710                        else Text.Transition.FADE_IN
1711                    ),
1712                    scale=0.5,
1713                    transition_delay=1.0,
1714                ).autoretain()
1715
1716            Text(
1717                bs.Lstr(
1718                    value='${A}:',
1719                    subs=[('${A}', bs.Lstr(resource='ratingText'))],
1720                ),
1721                position=(0, 36),
1722                maxwidth=300,
1723                transition=Text.Transition.FADE_IN,
1724                h_align=Text.HAlign.CENTER,
1725                v_align=Text.VAlign.CENTER,
1726                transition_delay=0,
1727            ).autoretain()
1728
1729        bs.timer(0.35, self._score_display_sound.play)
1730        if not error:
1731            bs.timer(0.35, self.cymbal_sound.play)
1732
1733    def _show_fail(self) -> None:
1734        ZoomText(
1735            bs.Lstr(resource='failText'),
1736            maxwidth=300,
1737            flash=False,
1738            trail=True,
1739            h_align='center',
1740            tilt_translate=0.11,
1741            position=(0, 40),
1742            jitter=1.0,
1743        ).autoretain()
1744        if self._fail_message is not None:
1745            Text(
1746                self._fail_message,
1747                h_align=Text.HAlign.CENTER,
1748                position=(0, -130),
1749                maxwidth=300,
1750                color=(1, 1, 1, 0.5),
1751                transition=Text.Transition.FADE_IN,
1752                transition_delay=1.0,
1753            ).autoretain()
1754        bs.timer(0.35, self._score_display_sound.play)
1755
1756    def _show_score_val(self, offs_x: float) -> None:
1757        assert self._score_type is not None
1758        assert self._score is not None
1759        ZoomText(
1760            (
1761                str(self._score)
1762                if self._score_type == 'points'
1763                else bs.timestring((self._score * 10) / 1000.0)
1764            ),
1765            maxwidth=300,
1766            flash=True,
1767            trail=True,
1768            scale=1.0 if self._score_type == 'points' else 0.6,
1769            h_align='center',
1770            tilt_translate=0.11,
1771            position=(190 + offs_x, 115),
1772            jitter=1.0,
1773        ).autoretain()
1774        Text(
1775            bs.Lstr(
1776                value='${A}:',
1777                subs=[('${A}', bs.Lstr(resource='finalScoreText'))],
1778            )
1779            if self._score_type == 'points'
1780            else bs.Lstr(
1781                value='${A}:',
1782                subs=[('${A}', bs.Lstr(resource='finalTimeText'))],
1783            ),
1784            maxwidth=300,
1785            position=(0, 200),
1786            transition=Text.Transition.FADE_IN,
1787            h_align=Text.HAlign.CENTER,
1788            v_align=Text.VAlign.CENTER,
1789            transition_delay=0,
1790        ).autoretain()
1791        bs.timer(0.35, self._score_display_sound.play)

Score screen showing the results of a cooperative game.

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

def on_player_join(self, player: bascenev1._player.Player) -> None:
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: