bascenev1lib.activity.coopscore

Provides a score screen for coop games.

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

Score screen showing the results of a cooperative game.

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

Creates an Activity in the current bascenev1.Session.

The activity will not be actually run until bascenev1.Session.setactivity is called. 'settings' should be a dict of key/value pairs specific to the activity.

Activities should preload as much of their media/etc as possible in their constructor, but none of it should actually be used until they are transitioned in.

transition_time = 0.0

If the activity fades or transitions in, it should set the length of time here so that previous activities will be kept alive for that long (avoiding 'holes' in the screen) This value is given in real-time seconds.

inherits_tint = False

Set this to true to inherit screen tint/vignette colors from the previous activity (useful to prevent sudden color changes during transitions).

inherits_vr_camera_offset = False

Set this to true to inherit VR camera offsets from the previous activity (useful for preventing sporadic camera movement during transitions).

inherits_music = False

Set this to True to keep playing the music from the previous activity (without even restarting it).

use_fixed_vr_overlay = False

In vr mode, this determines whether overlay nodes (text, images, etc) are created at a fixed position in space or one that moves based on the current map. Generally this should be on for games and off for transitions/score-screens/etc. that persist between maps.

drum_roll_sound
cymbal_sound
@override
def on_transition_in(self) -> None:
200    @override
201    def on_transition_in(self) -> None:
202        from bascenev1lib.actor import background  # FIXME NO BSSTD
203
204        bs.set_analytics_screen('Coop Score Screen')
205        super().on_transition_in()
206        self._background = background.Background(
207            fade_time=0.45, start_faded=False, show_logo=True
208        )

Called when the Activity is first becoming visible.

Upon this call, the Activity should fade in backgrounds, start playing music, etc. It does not yet have access to players or teams, however. They remain owned by the previous Activity up until bascenev1.Activity.on_begin() is called.

def request_ui(self) -> None:
338    def request_ui(self) -> None:
339        """Set up a callback to show our UI at the next opportune time."""
340        classic = bui.app.classic
341        assert classic is not None
342        # We don't want to just show our UI in case the user already has the
343        # main menu up, so instead we add a callback for when the menu
344        # closes; if we're still alive, we'll come up then.
345        # If there's no main menu this gets called immediately.
346        classic.add_main_menu_close_callback(bui.WeakCall(self.show_ui))

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

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