bauiv1lib.coop.browser

UI for browsing available co-op levels/games/etc.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""UI for browsing available co-op levels/games/etc."""
   4# FIXME: Break this up.
   5# pylint: disable=too-many-lines
   6
   7from __future__ import annotations
   8
   9import logging
  10from threading import Thread
  11from typing import TYPE_CHECKING
  12
  13from bauiv1lib.store.button import StoreButton
  14from bauiv1lib.league.rankbutton import LeagueRankButton
  15from bauiv1lib.store.browser import StoreBrowserWindow
  16import bauiv1 as bui
  17
  18if TYPE_CHECKING:
  19    from typing import Any
  20
  21    from bauiv1lib.coop.tournamentbutton import TournamentButton
  22
  23
  24class CoopBrowserWindow(bui.Window):
  25    """Window for browsing co-op levels/games/etc."""
  26
  27    def __init__(
  28        self,
  29        transition: str | None = 'in_right',
  30        origin_widget: bui.Widget | None = None,
  31    ):
  32        # pylint: disable=too-many-statements
  33        # pylint: disable=cyclic-import
  34
  35        plus = bui.app.plus
  36        assert plus is not None
  37
  38        # Preload some modules we use in a background thread so we won't
  39        # have a visual hitch when the user taps them.
  40        Thread(target=self._preload_modules).start()
  41
  42        bui.set_analytics_screen('Coop Window')
  43
  44        app = bui.app
  45        assert app.classic is not None
  46        cfg = app.config
  47
  48        # Quick note to players that tourneys won't work in ballistica
  49        # core builds. (need to split the word so it won't get subbed out)
  50        if 'ballistica' + 'kit' == bui.appname():
  51            bui.apptimer(
  52                1.0,
  53                lambda: bui.screenmessage(
  54                    bui.Lstr(resource='noTournamentsInTestBuildText'),
  55                    color=(1, 1, 0),
  56                ),
  57            )
  58
  59        # If they provided an origin-widget, scale up from that.
  60        scale_origin: tuple[float, float] | None
  61        if origin_widget is not None:
  62            self._transition_out = 'out_scale'
  63            scale_origin = origin_widget.get_screen_space_center()
  64            transition = 'in_scale'
  65        else:
  66            self._transition_out = 'out_right'
  67            scale_origin = None
  68
  69        # Try to recreate the same number of buttons we had last time so our
  70        # re-selection code works.
  71        self._tournament_button_count = app.config.get('Tournament Rows', 0)
  72        assert isinstance(self._tournament_button_count, int)
  73
  74        self.star_tex = bui.gettexture('star')
  75        self.lsbt = bui.getmesh('level_select_button_transparent')
  76        self.lsbo = bui.getmesh('level_select_button_opaque')
  77        self.a_outline_tex = bui.gettexture('achievementOutline')
  78        self.a_outline_mesh = bui.getmesh('achievementOutline')
  79        self._campaign_sub_container: bui.Widget | None = None
  80        self._tournament_info_button: bui.Widget | None = None
  81        self._easy_button: bui.Widget | None = None
  82        self._hard_button: bui.Widget | None = None
  83        self._hard_button_lock_image: bui.Widget | None = None
  84        self._campaign_percent_text: bui.Widget | None = None
  85
  86        assert bui.app.classic is not None
  87        uiscale = bui.app.ui_v1.uiscale
  88        self._width = 1520 if uiscale is bui.UIScale.SMALL else 1120
  89        self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0
  90        self._height = (
  91            657
  92            if uiscale is bui.UIScale.SMALL
  93            else 730 if uiscale is bui.UIScale.MEDIUM else 800
  94        )
  95        app.ui_v1.set_main_menu_location('Coop Select')
  96        self._r = 'coopSelectWindow'
  97        top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
  98
  99        self._tourney_data_up_to_date = False
 100
 101        self._campaign_difficulty = plus.get_v1_account_misc_val(
 102            'campaignDifficulty', 'easy'
 103        )
 104
 105        if (
 106            self._campaign_difficulty == 'hard'
 107            and not app.classic.accounts.have_pro_options()
 108        ):
 109            plus.add_v1_account_transaction(
 110                {
 111                    'type': 'SET_MISC_VAL',
 112                    'name': 'campaignDifficulty',
 113                    'value': 'easy',
 114                }
 115            )
 116            self._campaign_difficulty = 'easy'
 117
 118        super().__init__(
 119            root_widget=bui.containerwidget(
 120                size=(self._width, self._height + top_extra),
 121                toolbar_visibility='menu_full',
 122                scale_origin_stack_offset=scale_origin,
 123                stack_offset=(
 124                    (0, -15)
 125                    if uiscale is bui.UIScale.SMALL
 126                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
 127                ),
 128                transition=transition,
 129                scale=(
 130                    1.2
 131                    if uiscale is bui.UIScale.SMALL
 132                    else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75
 133                ),
 134            )
 135        )
 136
 137        if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL:
 138            self._back_button = None
 139        else:
 140            self._back_button = bui.buttonwidget(
 141                parent=self._root_widget,
 142                position=(
 143                    75 + x_inset,
 144                    self._height
 145                    - 87
 146                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 147                ),
 148                size=(120, 60),
 149                scale=1.2,
 150                autoselect=True,
 151                label=bui.Lstr(resource='backText'),
 152                button_type='back',
 153            )
 154
 155        self._league_rank_button: LeagueRankButton | None
 156        self._store_button: StoreButton | None
 157        self._store_button_widget: bui.Widget | None
 158        self._league_rank_button_widget: bui.Widget | None
 159
 160        if not app.ui_v1.use_toolbars:
 161            prb = self._league_rank_button = LeagueRankButton(
 162                parent=self._root_widget,
 163                position=(
 164                    self._width - (282 + x_inset),
 165                    self._height
 166                    - 85
 167                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 168                ),
 169                size=(100, 60),
 170                color=(0.4, 0.4, 0.9),
 171                textcolor=(0.9, 0.9, 2.0),
 172                scale=1.05,
 173                on_activate_call=bui.WeakCall(self._switch_to_league_rankings),
 174            )
 175            self._league_rank_button_widget = prb.get_button()
 176
 177            sbtn = self._store_button = StoreButton(
 178                parent=self._root_widget,
 179                position=(
 180                    self._width - (170 + x_inset),
 181                    self._height
 182                    - 85
 183                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 184                ),
 185                size=(100, 60),
 186                color=(0.6, 0.4, 0.7),
 187                show_tickets=True,
 188                button_type='square',
 189                sale_scale=0.85,
 190                textcolor=(0.9, 0.7, 1.0),
 191                scale=1.05,
 192                on_activate_call=bui.WeakCall(self._switch_to_score, None),
 193            )
 194            self._store_button_widget = sbtn.get_button()
 195            bui.widget(
 196                edit=self._back_button,
 197                right_widget=self._league_rank_button_widget,
 198            )
 199            bui.widget(
 200                edit=self._league_rank_button_widget,
 201                left_widget=self._back_button,
 202            )
 203        else:
 204            self._league_rank_button = None
 205            self._store_button = None
 206            self._store_button_widget = None
 207            self._league_rank_button_widget = None
 208
 209        # Move our corner buttons dynamically to keep them out of the way of
 210        # the party icon :-(
 211        self._update_corner_button_positions()
 212        self._update_corner_button_positions_timer = bui.AppTimer(
 213            1.0, bui.WeakCall(self._update_corner_button_positions), repeat=True
 214        )
 215
 216        self._last_tournament_query_time: float | None = None
 217        self._last_tournament_query_response_time: float | None = None
 218        self._doing_tournament_query = False
 219
 220        self._selected_campaign_level = cfg.get(
 221            'Selected Coop Campaign Level', None
 222        )
 223        self._selected_custom_level = cfg.get(
 224            'Selected Coop Custom Level', None
 225        )
 226
 227        # Don't want initial construction affecting our last-selected.
 228        self._do_selection_callbacks = False
 229        v = self._height - 95
 230        txt = bui.textwidget(
 231            parent=self._root_widget,
 232            position=(
 233                self._width * 0.5,
 234                v + 40 - (0 if uiscale is bui.UIScale.SMALL else 0),
 235            ),
 236            size=(0, 0),
 237            text=bui.Lstr(
 238                resource='playModes.singlePlayerCoopText',
 239                fallback_resource='playModes.coopText',
 240            ),
 241            h_align='center',
 242            color=app.ui_v1.title_color,
 243            scale=1.5,
 244            maxwidth=500,
 245            v_align='center',
 246        )
 247
 248        if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL:
 249            bui.textwidget(edit=txt, text='')
 250
 251        if self._back_button is not None:
 252            bui.buttonwidget(
 253                edit=self._back_button,
 254                button_type='backSmall',
 255                size=(60, 50),
 256                position=(
 257                    75 + x_inset,
 258                    self._height
 259                    - 87
 260                    - (4 if uiscale is bui.UIScale.SMALL else 0)
 261                    + 6,
 262                ),
 263                label=bui.charstr(bui.SpecialChar.BACK),
 264            )
 265
 266        self._selected_row = cfg.get('Selected Coop Row', None)
 267
 268        self._scroll_width = self._width - (130 + 2 * x_inset)
 269        self._scroll_height = self._height - (
 270            190
 271            if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars
 272            else 160
 273        )
 274
 275        self._subcontainerwidth = 800.0
 276        self._subcontainerheight = 1400.0
 277
 278        self._scrollwidget = bui.scrollwidget(
 279            parent=self._root_widget,
 280            highlight=False,
 281            position=(
 282                (65 + x_inset, 120)
 283                if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars
 284                else (65 + x_inset, 70)
 285            ),
 286            size=(self._scroll_width, self._scroll_height),
 287            simple_culling_v=10.0,
 288            claims_left_right=True,
 289            claims_tab=True,
 290            selection_loops_to_parent=True,
 291        )
 292        self._subcontainer: bui.Widget | None = None
 293
 294        # Take note of our account state; we'll refresh later if this changes.
 295        self._account_state_num = plus.get_v1_account_state_num()
 296
 297        # Same for fg/bg state.
 298        self._fg_state = app.fg_state
 299
 300        self._refresh()
 301        self._restore_state()
 302
 303        # Even though we might display cached tournament data immediately, we
 304        # don't consider it valid until we've pinged.
 305        # the server for an update
 306        self._tourney_data_up_to_date = False
 307
 308        # If we've got a cached tournament list for our account and info for
 309        # each one of those tournaments, go ahead and display it as a
 310        # starting point.
 311        if (
 312            app.classic.accounts.account_tournament_list is not None
 313            and app.classic.accounts.account_tournament_list[0]
 314            == plus.get_v1_account_state_num()
 315            and all(
 316                t_id in app.classic.accounts.tournament_info
 317                for t_id in app.classic.accounts.account_tournament_list[1]
 318            )
 319        ):
 320            tourney_data = [
 321                app.classic.accounts.tournament_info[t_id]
 322                for t_id in app.classic.accounts.account_tournament_list[1]
 323            ]
 324            self._update_for_data(tourney_data)
 325
 326        # This will pull new data periodically, update timers, etc.
 327        self._update_timer = bui.AppTimer(
 328            1.0, bui.WeakCall(self._update), repeat=True
 329        )
 330        self._update()
 331
 332    def _update_corner_button_positions(self) -> None:
 333        assert bui.app.classic is not None
 334        uiscale = bui.app.ui_v1.uiscale
 335        offs = (
 336            -55
 337            if uiscale is bui.UIScale.SMALL and bui.is_party_icon_visible()
 338            else 0
 339        )
 340        if self._league_rank_button is not None:
 341            self._league_rank_button.set_position(
 342                (
 343                    self._width - 282 + offs - self._x_inset,
 344                    self._height
 345                    - 85
 346                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 347                )
 348            )
 349        if self._store_button is not None:
 350            self._store_button.set_position(
 351                (
 352                    self._width - 170 + offs - self._x_inset,
 353                    self._height
 354                    - 85
 355                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 356                )
 357            )
 358
 359    # noinspection PyUnresolvedReferences
 360    @staticmethod
 361    def _preload_modules() -> None:
 362        """Preload modules we use; avoids hitches (called in bg thread)."""
 363        import bauiv1lib.purchase as _unused1
 364        import bauiv1lib.coop.gamebutton as _unused2
 365        import bauiv1lib.confirm as _unused3
 366        import bauiv1lib.account as _unused4
 367        import bauiv1lib.league.rankwindow as _unused5
 368        import bauiv1lib.store.browser as _unused6
 369        import bauiv1lib.account.viewer as _unused7
 370        import bauiv1lib.tournamentscores as _unused8
 371        import bauiv1lib.tournamententry as _unused9
 372        import bauiv1lib.play as _unused10
 373        import bauiv1lib.coop.tournamentbutton as _unused11
 374
 375    def _update(self) -> None:
 376        plus = bui.app.plus
 377        assert plus is not None
 378
 379        # Do nothing if we've somehow outlived our actual UI.
 380        if not self._root_widget:
 381            return
 382
 383        cur_time = bui.apptime()
 384
 385        # If its been a while since we got a tournament update, consider the
 386        # data invalid (prevents us from joining tournaments if our internet
 387        # connection goes down for a while).
 388        if (
 389            self._last_tournament_query_response_time is None
 390            or bui.apptime() - self._last_tournament_query_response_time
 391            > 60.0 * 2
 392        ):
 393            self._tourney_data_up_to_date = False
 394
 395        # If our account state has changed, do a full request.
 396        account_state_num = plus.get_v1_account_state_num()
 397        if account_state_num != self._account_state_num:
 398            self._account_state_num = account_state_num
 399            self._save_state()
 400            self._refresh()
 401
 402            # Also encourage a new tournament query since this will clear out
 403            # our current results.
 404            if not self._doing_tournament_query:
 405                self._last_tournament_query_time = None
 406
 407        # If we've been backgrounded/foregrounded, invalidate our
 408        # tournament entries (they will be refreshed below asap).
 409        if self._fg_state != bui.app.fg_state:
 410            self._tourney_data_up_to_date = False
 411
 412        # Send off a new tournament query if its been long enough or whatnot.
 413        if not self._doing_tournament_query and (
 414            self._last_tournament_query_time is None
 415            or cur_time - self._last_tournament_query_time > 30.0
 416            or self._fg_state != bui.app.fg_state
 417        ):
 418            self._fg_state = bui.app.fg_state
 419            self._last_tournament_query_time = cur_time
 420            self._doing_tournament_query = True
 421            plus.tournament_query(
 422                args={'source': 'coop window refresh', 'numScores': 1},
 423                callback=bui.WeakCall(self._on_tournament_query_response),
 424            )
 425
 426        # Decrement time on our tournament buttons.
 427        ads_enabled = plus.have_incentivized_ad()
 428        for tbtn in self._tournament_buttons:
 429            tbtn.time_remaining = max(0, tbtn.time_remaining - 1)
 430            if tbtn.time_remaining_value_text is not None:
 431                bui.textwidget(
 432                    edit=tbtn.time_remaining_value_text,
 433                    text=(
 434                        bui.timestring(tbtn.time_remaining, centi=False)
 435                        if (
 436                            tbtn.has_time_remaining
 437                            and self._tourney_data_up_to_date
 438                        )
 439                        else '-'
 440                    ),
 441                )
 442
 443            # Also adjust the ad icon visibility.
 444            if tbtn.allow_ads and plus.has_video_ads():
 445                bui.imagewidget(
 446                    edit=tbtn.entry_fee_ad_image,
 447                    opacity=1.0 if ads_enabled else 0.25,
 448                )
 449                bui.textwidget(
 450                    edit=tbtn.entry_fee_text_remaining,
 451                    color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2),
 452                )
 453
 454        self._update_hard_mode_lock_image()
 455
 456    def _update_hard_mode_lock_image(self) -> None:
 457        assert bui.app.classic is not None
 458        try:
 459            bui.imagewidget(
 460                edit=self._hard_button_lock_image,
 461                opacity=(
 462                    0.0 if bui.app.classic.accounts.have_pro_options() else 1.0
 463                ),
 464            )
 465        except Exception:
 466            logging.exception('Error updating campaign lock.')
 467
 468    def _update_for_data(self, data: list[dict[str, Any]] | None) -> None:
 469        # If the number of tournaments or challenges in the data differs from
 470        # our current arrangement, refresh with the new number.
 471        if (data is None and self._tournament_button_count != 0) or (
 472            data is not None and (len(data) != self._tournament_button_count)
 473        ):
 474            self._tournament_button_count = len(data) if data is not None else 0
 475            bui.app.config['Tournament Rows'] = self._tournament_button_count
 476            self._refresh()
 477
 478        # Update all of our tourney buttons based on whats in data.
 479        for i, tbtn in enumerate(self._tournament_buttons):
 480            assert data is not None
 481            tbtn.update_for_data(data[i])
 482
 483    def _on_tournament_query_response(
 484        self, data: dict[str, Any] | None
 485    ) -> None:
 486        plus = bui.app.plus
 487        assert plus is not None
 488
 489        assert bui.app.classic is not None
 490        accounts = bui.app.classic.accounts
 491        if data is not None:
 492            tournament_data = data['t']  # This used to be the whole payload.
 493            self._last_tournament_query_response_time = bui.apptime()
 494        else:
 495            tournament_data = None
 496
 497        # Keep our cached tourney info up to date.
 498        if data is not None:
 499            self._tourney_data_up_to_date = True
 500            accounts.cache_tournament_info(tournament_data)
 501
 502            # Also cache the current tourney list/order for this account.
 503            accounts.account_tournament_list = (
 504                plus.get_v1_account_state_num(),
 505                [e['tournamentID'] for e in tournament_data],
 506            )
 507
 508        self._doing_tournament_query = False
 509        self._update_for_data(tournament_data)
 510
 511    def _set_campaign_difficulty(self, difficulty: str) -> None:
 512        # pylint: disable=cyclic-import
 513        from bauiv1lib.purchase import PurchaseWindow
 514
 515        plus = bui.app.plus
 516        assert plus is not None
 517
 518        assert bui.app.classic is not None
 519        if difficulty != self._campaign_difficulty:
 520            if (
 521                difficulty == 'hard'
 522                and not bui.app.classic.accounts.have_pro_options()
 523            ):
 524                PurchaseWindow(items=['pro'])
 525                return
 526            bui.getsound('gunCocking').play()
 527            if difficulty not in ('easy', 'hard'):
 528                print('ERROR: invalid campaign difficulty:', difficulty)
 529                difficulty = 'easy'
 530            self._campaign_difficulty = difficulty
 531            plus.add_v1_account_transaction(
 532                {
 533                    'type': 'SET_MISC_VAL',
 534                    'name': 'campaignDifficulty',
 535                    'value': difficulty,
 536                }
 537            )
 538            self._refresh_campaign_row()
 539        else:
 540            bui.getsound('click01').play()
 541
 542    def _refresh_campaign_row(self) -> None:
 543        # pylint: disable=too-many-locals
 544        # pylint: disable=cyclic-import
 545        from bauiv1lib.coop.gamebutton import GameButton
 546
 547        parent_widget = self._campaign_sub_container
 548
 549        # Clear out anything in the parent widget already.
 550        assert parent_widget is not None
 551        for child in parent_widget.get_children():
 552            child.delete()
 553
 554        next_widget_down = self._tournament_info_button
 555
 556        h = 0
 557        v2 = -2
 558        sel_color = (0.75, 0.85, 0.5)
 559        sel_color_hard = (0.4, 0.7, 0.2)
 560        un_sel_color = (0.5, 0.5, 0.5)
 561        sel_textcolor = (2, 2, 0.8)
 562        un_sel_textcolor = (0.6, 0.6, 0.6)
 563        self._easy_button = bui.buttonwidget(
 564            parent=parent_widget,
 565            position=(h + 30, v2 + 105),
 566            size=(120, 70),
 567            label=bui.Lstr(resource='difficultyEasyText'),
 568            button_type='square',
 569            autoselect=True,
 570            enable_sound=False,
 571            on_activate_call=bui.Call(self._set_campaign_difficulty, 'easy'),
 572            on_select_call=bui.Call(self.sel_change, 'campaign', 'easyButton'),
 573            color=(
 574                sel_color
 575                if self._campaign_difficulty == 'easy'
 576                else un_sel_color
 577            ),
 578            textcolor=(
 579                sel_textcolor
 580                if self._campaign_difficulty == 'easy'
 581                else un_sel_textcolor
 582            ),
 583        )
 584        bui.widget(edit=self._easy_button, show_buffer_left=100)
 585        if self._selected_campaign_level == 'easyButton':
 586            bui.containerwidget(
 587                edit=parent_widget,
 588                selected_child=self._easy_button,
 589                visible_child=self._easy_button,
 590            )
 591        lock_tex = bui.gettexture('lock')
 592
 593        self._hard_button = bui.buttonwidget(
 594            parent=parent_widget,
 595            position=(h + 30, v2 + 32),
 596            size=(120, 70),
 597            label=bui.Lstr(resource='difficultyHardText'),
 598            button_type='square',
 599            autoselect=True,
 600            enable_sound=False,
 601            on_activate_call=bui.Call(self._set_campaign_difficulty, 'hard'),
 602            on_select_call=bui.Call(self.sel_change, 'campaign', 'hardButton'),
 603            color=(
 604                sel_color_hard
 605                if self._campaign_difficulty == 'hard'
 606                else un_sel_color
 607            ),
 608            textcolor=(
 609                sel_textcolor
 610                if self._campaign_difficulty == 'hard'
 611                else un_sel_textcolor
 612            ),
 613        )
 614        self._hard_button_lock_image = bui.imagewidget(
 615            parent=parent_widget,
 616            size=(30, 30),
 617            draw_controller=self._hard_button,
 618            position=(h + 30 - 10, v2 + 32 + 70 - 35),
 619            texture=lock_tex,
 620        )
 621        self._update_hard_mode_lock_image()
 622        bui.widget(edit=self._hard_button, show_buffer_left=100)
 623        if self._selected_campaign_level == 'hardButton':
 624            bui.containerwidget(
 625                edit=parent_widget,
 626                selected_child=self._hard_button,
 627                visible_child=self._hard_button,
 628            )
 629
 630        bui.widget(edit=self._hard_button, down_widget=next_widget_down)
 631        h_spacing = 200
 632        campaign_buttons = []
 633        if self._campaign_difficulty == 'easy':
 634            campaignname = 'Easy'
 635        else:
 636            campaignname = 'Default'
 637        items = [
 638            campaignname + ':Onslaught Training',
 639            campaignname + ':Rookie Onslaught',
 640            campaignname + ':Rookie Football',
 641            campaignname + ':Pro Onslaught',
 642            campaignname + ':Pro Football',
 643            campaignname + ':Pro Runaround',
 644            campaignname + ':Uber Onslaught',
 645            campaignname + ':Uber Football',
 646            campaignname + ':Uber Runaround',
 647        ]
 648        items += [campaignname + ':The Last Stand']
 649        if self._selected_campaign_level is None:
 650            self._selected_campaign_level = items[0]
 651        h = 150
 652        for i in items:
 653            is_last_sel = i == self._selected_campaign_level
 654            campaign_buttons.append(
 655                GameButton(
 656                    self, parent_widget, i, h, v2, is_last_sel, 'campaign'
 657                ).get_button()
 658            )
 659            h += h_spacing
 660
 661        bui.widget(edit=campaign_buttons[0], left_widget=self._easy_button)
 662
 663        if self._back_button is not None:
 664            bui.widget(edit=self._easy_button, up_widget=self._back_button)
 665            for btn in campaign_buttons:
 666                bui.widget(
 667                    edit=btn,
 668                    up_widget=self._back_button,
 669                    down_widget=next_widget_down,
 670                )
 671
 672        # Update our existing percent-complete text.
 673        assert bui.app.classic is not None
 674        campaign = bui.app.classic.getcampaign(campaignname)
 675        levels = campaign.levels
 676        levels_complete = sum((1 if l.complete else 0) for l in levels)
 677
 678        # Last level cant be completed; hence the -1.
 679        progress = min(1.0, float(levels_complete) / (len(levels) - 1))
 680        p_str = str(int(progress * 100.0)) + '%'
 681
 682        self._campaign_percent_text = bui.textwidget(
 683            edit=self._campaign_percent_text,
 684            text=bui.Lstr(
 685                value='${C} (${P})',
 686                subs=[
 687                    ('${C}', bui.Lstr(resource=self._r + '.campaignText')),
 688                    ('${P}', p_str),
 689                ],
 690            ),
 691        )
 692
 693    def _on_tournament_info_press(self) -> None:
 694        # pylint: disable=cyclic-import
 695        from bauiv1lib.confirm import ConfirmWindow
 696
 697        txt = bui.Lstr(resource=self._r + '.tournamentInfoText')
 698        ConfirmWindow(
 699            txt,
 700            cancel_button=False,
 701            width=550,
 702            height=260,
 703            origin_widget=self._tournament_info_button,
 704        )
 705
 706    def _refresh(self) -> None:
 707        # pylint: disable=too-many-statements
 708        # pylint: disable=too-many-branches
 709        # pylint: disable=too-many-locals
 710        # pylint: disable=cyclic-import
 711        from bauiv1lib.coop.gamebutton import GameButton
 712        from bauiv1lib.coop.tournamentbutton import TournamentButton
 713
 714        plus = bui.app.plus
 715        assert plus is not None
 716        assert bui.app.classic is not None
 717
 718        # (Re)create the sub-container if need be.
 719        if self._subcontainer is not None:
 720            self._subcontainer.delete()
 721
 722        tourney_row_height = 200
 723        self._subcontainerheight = (
 724            620 + self._tournament_button_count * tourney_row_height
 725        )
 726
 727        self._subcontainer = bui.containerwidget(
 728            parent=self._scrollwidget,
 729            size=(self._subcontainerwidth, self._subcontainerheight),
 730            background=False,
 731            claims_left_right=True,
 732            claims_tab=True,
 733            selection_loops_to_parent=True,
 734        )
 735
 736        bui.containerwidget(
 737            edit=self._root_widget, selected_child=self._scrollwidget
 738        )
 739        if self._back_button is not None:
 740            bui.containerwidget(
 741                edit=self._root_widget, cancel_button=self._back_button
 742            )
 743
 744        w_parent = self._subcontainer
 745        h_base = 6
 746
 747        v = self._subcontainerheight - 73
 748
 749        self._campaign_percent_text = bui.textwidget(
 750            parent=w_parent,
 751            position=(h_base + 27, v + 30),
 752            size=(0, 0),
 753            text='',
 754            h_align='left',
 755            v_align='center',
 756            color=bui.app.ui_v1.title_color,
 757            scale=1.1,
 758        )
 759
 760        row_v_show_buffer = 100
 761        v -= 198
 762
 763        h_scroll = bui.hscrollwidget(
 764            parent=w_parent,
 765            size=(self._scroll_width - 10, 205),
 766            position=(-5, v),
 767            simple_culling_h=70,
 768            highlight=False,
 769            border_opacity=0.0,
 770            color=(0.45, 0.4, 0.5),
 771            on_select_call=lambda: self._on_row_selected('campaign'),
 772        )
 773        self._campaign_h_scroll = h_scroll
 774        bui.widget(
 775            edit=h_scroll,
 776            show_buffer_top=row_v_show_buffer,
 777            show_buffer_bottom=row_v_show_buffer,
 778            autoselect=True,
 779        )
 780        if self._selected_row == 'campaign':
 781            bui.containerwidget(
 782                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 783            )
 784        bui.containerwidget(edit=h_scroll, claims_left_right=True)
 785        self._campaign_sub_container = bui.containerwidget(
 786            parent=h_scroll, size=(180 + 200 * 10, 200), background=False
 787        )
 788
 789        # Tournaments
 790
 791        self._tournament_buttons: list[TournamentButton] = []
 792
 793        v -= 53
 794        # FIXME shouldn't use hard-coded strings here.
 795        txt = bui.Lstr(
 796            resource='tournamentsText', fallback_resource='tournamentText'
 797        ).evaluate()
 798        t_width = bui.get_string_width(txt, suppress_warning=True)
 799        bui.textwidget(
 800            parent=w_parent,
 801            position=(h_base + 27, v + 30),
 802            size=(0, 0),
 803            text=txt,
 804            h_align='left',
 805            v_align='center',
 806            color=bui.app.ui_v1.title_color,
 807            scale=1.1,
 808        )
 809        self._tournament_info_button = bui.buttonwidget(
 810            parent=w_parent,
 811            label='?',
 812            size=(20, 20),
 813            text_scale=0.6,
 814            position=(h_base + 27 + t_width * 1.1 + 15, v + 18),
 815            button_type='square',
 816            color=(0.6, 0.5, 0.65),
 817            textcolor=(0.7, 0.6, 0.75),
 818            autoselect=True,
 819            up_widget=self._campaign_h_scroll,
 820            on_activate_call=self._on_tournament_info_press,
 821        )
 822        bui.widget(
 823            edit=self._tournament_info_button,
 824            left_widget=self._tournament_info_button,
 825            right_widget=self._tournament_info_button,
 826        )
 827
 828        # Say 'unavailable' if there are zero tournaments, and if we're not
 829        # signed in add that as well (that's probably why we see
 830        # no tournaments).
 831        if self._tournament_button_count == 0:
 832            unavailable_text = bui.Lstr(resource='unavailableText')
 833            if plus.get_v1_account_state() != 'signed_in':
 834                unavailable_text = bui.Lstr(
 835                    value='${A} (${B})',
 836                    subs=[
 837                        ('${A}', unavailable_text),
 838                        ('${B}', bui.Lstr(resource='notSignedInText')),
 839                    ],
 840                )
 841            bui.textwidget(
 842                parent=w_parent,
 843                position=(h_base + 47, v),
 844                size=(0, 0),
 845                text=unavailable_text,
 846                h_align='left',
 847                v_align='center',
 848                color=bui.app.ui_v1.title_color,
 849                scale=0.9,
 850            )
 851            v -= 40
 852        v -= 198
 853
 854        tournament_h_scroll = None
 855        if self._tournament_button_count > 0:
 856            for i in range(self._tournament_button_count):
 857                tournament_h_scroll = h_scroll = bui.hscrollwidget(
 858                    parent=w_parent,
 859                    size=(self._scroll_width - 10, 205),
 860                    position=(-5, v),
 861                    highlight=False,
 862                    border_opacity=0.0,
 863                    color=(0.45, 0.4, 0.5),
 864                    on_select_call=bui.Call(
 865                        self._on_row_selected, 'tournament' + str(i + 1)
 866                    ),
 867                )
 868                bui.widget(
 869                    edit=h_scroll,
 870                    show_buffer_top=row_v_show_buffer,
 871                    show_buffer_bottom=row_v_show_buffer,
 872                    autoselect=True,
 873                )
 874                if self._selected_row == 'tournament' + str(i + 1):
 875                    bui.containerwidget(
 876                        edit=w_parent,
 877                        selected_child=h_scroll,
 878                        visible_child=h_scroll,
 879                    )
 880                bui.containerwidget(edit=h_scroll, claims_left_right=True)
 881                sc2 = bui.containerwidget(
 882                    parent=h_scroll,
 883                    size=(self._scroll_width - 24, 200),
 884                    background=False,
 885                )
 886                h = 0
 887                v2 = -2
 888                is_last_sel = True
 889                self._tournament_buttons.append(
 890                    TournamentButton(
 891                        sc2,
 892                        h,
 893                        v2,
 894                        is_last_sel,
 895                        on_pressed=bui.WeakCall(self.run_tournament),
 896                    )
 897                )
 898                v -= 200
 899
 900        # Custom Games. (called 'Practice' in UI these days).
 901        v -= 50
 902        bui.textwidget(
 903            parent=w_parent,
 904            position=(h_base + 27, v + 30 + 198),
 905            size=(0, 0),
 906            text=bui.Lstr(
 907                resource='practiceText',
 908                fallback_resource='coopSelectWindow.customText',
 909            ),
 910            h_align='left',
 911            v_align='center',
 912            color=bui.app.ui_v1.title_color,
 913            scale=1.1,
 914        )
 915
 916        items = [
 917            'Challenges:Infinite Onslaught',
 918            'Challenges:Infinite Runaround',
 919            'Challenges:Ninja Fight',
 920            'Challenges:Pro Ninja Fight',
 921            'Challenges:Meteor Shower',
 922            'Challenges:Target Practice B',
 923            'Challenges:Target Practice',
 924        ]
 925
 926        # Show easter-egg-hunt either if its easter or we own it.
 927        if plus.get_v1_account_misc_read_val(
 928            'easter', False
 929        ) or plus.get_purchased('games.easter_egg_hunt'):
 930            items = [
 931                'Challenges:Easter Egg Hunt',
 932                'Challenges:Pro Easter Egg Hunt',
 933            ] + items
 934
 935        # If we've defined custom games, put them at the beginning.
 936        if bui.app.classic.custom_coop_practice_games:
 937            items = bui.app.classic.custom_coop_practice_games + items
 938
 939        self._custom_h_scroll = custom_h_scroll = h_scroll = bui.hscrollwidget(
 940            parent=w_parent,
 941            size=(self._scroll_width - 10, 205),
 942            position=(-5, v),
 943            highlight=False,
 944            border_opacity=0.0,
 945            color=(0.45, 0.4, 0.5),
 946            on_select_call=bui.Call(self._on_row_selected, 'custom'),
 947        )
 948        bui.widget(
 949            edit=h_scroll,
 950            show_buffer_top=row_v_show_buffer,
 951            show_buffer_bottom=1.5 * row_v_show_buffer,
 952            autoselect=True,
 953        )
 954        if self._selected_row == 'custom':
 955            bui.containerwidget(
 956                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 957            )
 958        bui.containerwidget(edit=h_scroll, claims_left_right=True)
 959        sc2 = bui.containerwidget(
 960            parent=h_scroll,
 961            size=(max(self._scroll_width - 24, 30 + 200 * len(items)), 200),
 962            background=False,
 963        )
 964        h_spacing = 200
 965        self._custom_buttons: list[GameButton] = []
 966        h = 0
 967        v2 = -2
 968        for item in items:
 969            is_last_sel = item == self._selected_custom_level
 970            self._custom_buttons.append(
 971                GameButton(self, sc2, item, h, v2, is_last_sel, 'custom')
 972            )
 973            h += h_spacing
 974
 975        # We can't fill in our campaign row until tourney buttons are in place.
 976        # (for wiring up)
 977        self._refresh_campaign_row()
 978
 979        for i, tbutton in enumerate(self._tournament_buttons):
 980            bui.widget(
 981                edit=tbutton.button,
 982                up_widget=(
 983                    self._tournament_info_button
 984                    if i == 0
 985                    else self._tournament_buttons[i - 1].button
 986                ),
 987                down_widget=(
 988                    self._tournament_buttons[(i + 1)].button
 989                    if i + 1 < len(self._tournament_buttons)
 990                    else custom_h_scroll
 991                ),
 992            )
 993            bui.widget(
 994                edit=tbutton.more_scores_button,
 995                down_widget=(
 996                    self._tournament_buttons[(i + 1)].current_leader_name_text
 997                    if i + 1 < len(self._tournament_buttons)
 998                    else custom_h_scroll
 999                ),
1000            )
1001            bui.widget(
1002                edit=tbutton.current_leader_name_text,
1003                up_widget=(
1004                    self._tournament_info_button
1005                    if i == 0
1006                    else self._tournament_buttons[i - 1].more_scores_button
1007                ),
1008            )
1009
1010        for btn in self._custom_buttons:
1011            try:
1012                bui.widget(
1013                    edit=btn.get_button(),
1014                    up_widget=(
1015                        tournament_h_scroll
1016                        if self._tournament_buttons
1017                        else self._tournament_info_button
1018                    ),
1019                )
1020            except Exception:
1021                logging.exception('Error wiring up custom buttons.')
1022
1023        if self._back_button is not None:
1024            bui.buttonwidget(
1025                edit=self._back_button, on_activate_call=self._back
1026            )
1027        else:
1028            bui.containerwidget(
1029                edit=self._root_widget, on_cancel_call=self._back
1030            )
1031
1032        # There's probably several 'onSelected' callbacks pushed onto the
1033        # event queue.. we need to push ours too so we're enabled *after* them.
1034        bui.pushcall(self._enable_selectable_callback)
1035
1036    def _on_row_selected(self, row: str) -> None:
1037        if self._do_selection_callbacks:
1038            if self._selected_row != row:
1039                self._selected_row = row
1040
1041    def _enable_selectable_callback(self) -> None:
1042        self._do_selection_callbacks = True
1043
1044    def _switch_to_league_rankings(self) -> None:
1045        # pylint: disable=cyclic-import
1046        from bauiv1lib.account import show_sign_in_prompt
1047        from bauiv1lib.league.rankwindow import LeagueRankWindow
1048
1049        # no-op if our underlying widget is dead or on its way out.
1050        if not self._root_widget or self._root_widget.transitioning_out:
1051            return
1052
1053        plus = bui.app.plus
1054        assert plus is not None
1055
1056        if plus.get_v1_account_state() != 'signed_in':
1057            show_sign_in_prompt()
1058            return
1059        self._save_state()
1060        bui.containerwidget(edit=self._root_widget, transition='out_left')
1061        assert self._league_rank_button is not None
1062        assert bui.app.classic is not None
1063        bui.app.ui_v1.set_main_menu_window(
1064            LeagueRankWindow(
1065                origin_widget=self._league_rank_button.get_button()
1066            ).get_root_widget(),
1067            from_window=self._root_widget,
1068        )
1069
1070    def _switch_to_score(
1071        self,
1072        show_tab: (
1073            StoreBrowserWindow.TabID | None
1074        ) = StoreBrowserWindow.TabID.EXTRAS,
1075    ) -> None:
1076        # pylint: disable=cyclic-import
1077        from bauiv1lib.account import show_sign_in_prompt
1078
1079        # no-op if our underlying widget is dead or on its way out.
1080        if not self._root_widget or self._root_widget.transitioning_out:
1081            return
1082
1083        plus = bui.app.plus
1084        assert plus is not None
1085
1086        if plus.get_v1_account_state() != 'signed_in':
1087            show_sign_in_prompt()
1088            return
1089        self._save_state()
1090        bui.containerwidget(edit=self._root_widget, transition='out_left')
1091        assert self._store_button is not None
1092        assert bui.app.classic is not None
1093        bui.app.ui_v1.set_main_menu_window(
1094            StoreBrowserWindow(
1095                origin_widget=self._store_button.get_button(),
1096                show_tab=show_tab,
1097                back_location='CoopBrowserWindow',
1098            ).get_root_widget(),
1099            from_window=self._root_widget,
1100        )
1101
1102    def is_tourney_data_up_to_date(self) -> bool:
1103        """Return whether our tourney data is up to date."""
1104        return self._tourney_data_up_to_date
1105
1106    def run_game(self, game: str) -> None:
1107        """Run the provided game."""
1108        # pylint: disable=too-many-branches
1109        # pylint: disable=cyclic-import
1110        from bauiv1lib.confirm import ConfirmWindow
1111        from bauiv1lib.purchase import PurchaseWindow
1112        from bauiv1lib.account import show_sign_in_prompt
1113
1114        plus = bui.app.plus
1115        assert plus is not None
1116
1117        assert bui.app.classic is not None
1118
1119        args: dict[str, Any] = {}
1120
1121        if game == 'Easy:The Last Stand':
1122            ConfirmWindow(
1123                bui.Lstr(
1124                    resource='difficultyHardUnlockOnlyText',
1125                    fallback_resource='difficultyHardOnlyText',
1126                ),
1127                cancel_button=False,
1128                width=460,
1129                height=130,
1130            )
1131            return
1132
1133        # Infinite onslaught/runaround require pro; bring up a store link
1134        # if need be.
1135        if (
1136            game
1137            in (
1138                'Challenges:Infinite Runaround',
1139                'Challenges:Infinite Onslaught',
1140            )
1141            and not bui.app.classic.accounts.have_pro()
1142        ):
1143            if plus.get_v1_account_state() != 'signed_in':
1144                show_sign_in_prompt()
1145            else:
1146                PurchaseWindow(items=['pro'])
1147            return
1148
1149        required_purchase: str | None
1150        if game in ['Challenges:Meteor Shower']:
1151            required_purchase = 'games.meteor_shower'
1152        elif game in [
1153            'Challenges:Target Practice',
1154            'Challenges:Target Practice B',
1155        ]:
1156            required_purchase = 'games.target_practice'
1157        elif game in ['Challenges:Ninja Fight']:
1158            required_purchase = 'games.ninja_fight'
1159        elif game in ['Challenges:Pro Ninja Fight']:
1160            required_purchase = 'games.ninja_fight'
1161        elif game in [
1162            'Challenges:Easter Egg Hunt',
1163            'Challenges:Pro Easter Egg Hunt',
1164        ]:
1165            required_purchase = 'games.easter_egg_hunt'
1166        else:
1167            required_purchase = None
1168
1169        if required_purchase is not None and not plus.get_purchased(
1170            required_purchase
1171        ):
1172            if plus.get_v1_account_state() != 'signed_in':
1173                show_sign_in_prompt()
1174            else:
1175                PurchaseWindow(items=[required_purchase])
1176            return
1177
1178        self._save_state()
1179
1180        if bui.app.classic.launch_coop_game(game, args=args):
1181            bui.containerwidget(edit=self._root_widget, transition='out_left')
1182
1183    def run_tournament(self, tournament_button: TournamentButton) -> None:
1184        """Run the provided tournament game."""
1185        from bauiv1lib.account import show_sign_in_prompt
1186        from bauiv1lib.tournamententry import TournamentEntryWindow
1187
1188        plus = bui.app.plus
1189        assert plus is not None
1190
1191        if plus.get_v1_account_state() != 'signed_in':
1192            show_sign_in_prompt()
1193            return
1194
1195        if bui.workspaces_in_use():
1196            bui.screenmessage(
1197                bui.Lstr(resource='tournamentsDisabledWorkspaceText'),
1198                color=(1, 0, 0),
1199            )
1200            bui.getsound('error').play()
1201            return
1202
1203        if not self._tourney_data_up_to_date:
1204            bui.screenmessage(
1205                bui.Lstr(resource='tournamentCheckingStateText'),
1206                color=(1, 1, 0),
1207            )
1208            bui.getsound('error').play()
1209            return
1210
1211        if tournament_button.tournament_id is None:
1212            bui.screenmessage(
1213                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1214                color=(1, 0, 0),
1215            )
1216            bui.getsound('error').play()
1217            return
1218
1219        if tournament_button.required_league is not None:
1220            bui.screenmessage(
1221                bui.Lstr(
1222                    resource='league.tournamentLeagueText',
1223                    subs=[
1224                        (
1225                            '${NAME}',
1226                            bui.Lstr(
1227                                translate=(
1228                                    'leagueNames',
1229                                    tournament_button.required_league,
1230                                )
1231                            ),
1232                        )
1233                    ],
1234                ),
1235                color=(1, 0, 0),
1236            )
1237            bui.getsound('error').play()
1238            return
1239
1240        if tournament_button.time_remaining <= 0:
1241            bui.screenmessage(
1242                bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1243            )
1244            bui.getsound('error').play()
1245            return
1246
1247        self._save_state()
1248
1249        assert tournament_button.tournament_id is not None
1250        TournamentEntryWindow(
1251            tournament_id=tournament_button.tournament_id,
1252            position=tournament_button.button.get_screen_space_center(),
1253        )
1254
1255    def _back(self) -> None:
1256        # pylint: disable=cyclic-import
1257        from bauiv1lib.play import PlayWindow
1258
1259        # no-op if our underlying widget is dead or on its way out.
1260        if not self._root_widget or self._root_widget.transitioning_out:
1261            return
1262
1263        # If something is selected, store it.
1264        self._save_state()
1265        bui.containerwidget(
1266            edit=self._root_widget, transition=self._transition_out
1267        )
1268        assert bui.app.classic is not None
1269        bui.app.ui_v1.set_main_menu_window(
1270            PlayWindow(transition='in_left').get_root_widget(),
1271            from_window=self._root_widget,
1272        )
1273
1274    def _save_state(self) -> None:
1275        cfg = bui.app.config
1276        try:
1277            sel = self._root_widget.get_selected_child()
1278            if sel == self._back_button:
1279                sel_name = 'Back'
1280            elif sel == self._store_button_widget:
1281                sel_name = 'Store'
1282            elif sel == self._league_rank_button_widget:
1283                sel_name = 'PowerRanking'
1284            elif sel == self._scrollwidget:
1285                sel_name = 'Scroll'
1286            else:
1287                raise ValueError('unrecognized selection')
1288            assert bui.app.classic is not None
1289            bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
1290        except Exception:
1291            logging.exception('Error saving state for %s.', self)
1292
1293        cfg['Selected Coop Row'] = self._selected_row
1294        cfg['Selected Coop Custom Level'] = self._selected_custom_level
1295        cfg['Selected Coop Campaign Level'] = self._selected_campaign_level
1296        cfg.commit()
1297
1298    def _restore_state(self) -> None:
1299        try:
1300            assert bui.app.classic is not None
1301            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
1302                'sel_name'
1303            )
1304            if sel_name == 'Back':
1305                sel = self._back_button
1306            elif sel_name == 'Scroll':
1307                sel = self._scrollwidget
1308            elif sel_name == 'PowerRanking':
1309                sel = self._league_rank_button_widget
1310            elif sel_name == 'Store':
1311                sel = self._store_button_widget
1312            else:
1313                sel = self._scrollwidget
1314            bui.containerwidget(edit=self._root_widget, selected_child=sel)
1315        except Exception:
1316            logging.exception('Error restoring state for %s.', self)
1317
1318    def sel_change(self, row: str, game: str) -> None:
1319        """(internal)"""
1320        if self._do_selection_callbacks:
1321            if row == 'custom':
1322                self._selected_custom_level = game
1323            elif row == 'campaign':
1324                self._selected_campaign_level = game
class CoopBrowserWindow(bauiv1._uitypes.Window):
  25class CoopBrowserWindow(bui.Window):
  26    """Window for browsing co-op levels/games/etc."""
  27
  28    def __init__(
  29        self,
  30        transition: str | None = 'in_right',
  31        origin_widget: bui.Widget | None = None,
  32    ):
  33        # pylint: disable=too-many-statements
  34        # pylint: disable=cyclic-import
  35
  36        plus = bui.app.plus
  37        assert plus is not None
  38
  39        # Preload some modules we use in a background thread so we won't
  40        # have a visual hitch when the user taps them.
  41        Thread(target=self._preload_modules).start()
  42
  43        bui.set_analytics_screen('Coop Window')
  44
  45        app = bui.app
  46        assert app.classic is not None
  47        cfg = app.config
  48
  49        # Quick note to players that tourneys won't work in ballistica
  50        # core builds. (need to split the word so it won't get subbed out)
  51        if 'ballistica' + 'kit' == bui.appname():
  52            bui.apptimer(
  53                1.0,
  54                lambda: bui.screenmessage(
  55                    bui.Lstr(resource='noTournamentsInTestBuildText'),
  56                    color=(1, 1, 0),
  57                ),
  58            )
  59
  60        # If they provided an origin-widget, scale up from that.
  61        scale_origin: tuple[float, float] | None
  62        if origin_widget is not None:
  63            self._transition_out = 'out_scale'
  64            scale_origin = origin_widget.get_screen_space_center()
  65            transition = 'in_scale'
  66        else:
  67            self._transition_out = 'out_right'
  68            scale_origin = None
  69
  70        # Try to recreate the same number of buttons we had last time so our
  71        # re-selection code works.
  72        self._tournament_button_count = app.config.get('Tournament Rows', 0)
  73        assert isinstance(self._tournament_button_count, int)
  74
  75        self.star_tex = bui.gettexture('star')
  76        self.lsbt = bui.getmesh('level_select_button_transparent')
  77        self.lsbo = bui.getmesh('level_select_button_opaque')
  78        self.a_outline_tex = bui.gettexture('achievementOutline')
  79        self.a_outline_mesh = bui.getmesh('achievementOutline')
  80        self._campaign_sub_container: bui.Widget | None = None
  81        self._tournament_info_button: bui.Widget | None = None
  82        self._easy_button: bui.Widget | None = None
  83        self._hard_button: bui.Widget | None = None
  84        self._hard_button_lock_image: bui.Widget | None = None
  85        self._campaign_percent_text: bui.Widget | None = None
  86
  87        assert bui.app.classic is not None
  88        uiscale = bui.app.ui_v1.uiscale
  89        self._width = 1520 if uiscale is bui.UIScale.SMALL else 1120
  90        self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0
  91        self._height = (
  92            657
  93            if uiscale is bui.UIScale.SMALL
  94            else 730 if uiscale is bui.UIScale.MEDIUM else 800
  95        )
  96        app.ui_v1.set_main_menu_location('Coop Select')
  97        self._r = 'coopSelectWindow'
  98        top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
  99
 100        self._tourney_data_up_to_date = False
 101
 102        self._campaign_difficulty = plus.get_v1_account_misc_val(
 103            'campaignDifficulty', 'easy'
 104        )
 105
 106        if (
 107            self._campaign_difficulty == 'hard'
 108            and not app.classic.accounts.have_pro_options()
 109        ):
 110            plus.add_v1_account_transaction(
 111                {
 112                    'type': 'SET_MISC_VAL',
 113                    'name': 'campaignDifficulty',
 114                    'value': 'easy',
 115                }
 116            )
 117            self._campaign_difficulty = 'easy'
 118
 119        super().__init__(
 120            root_widget=bui.containerwidget(
 121                size=(self._width, self._height + top_extra),
 122                toolbar_visibility='menu_full',
 123                scale_origin_stack_offset=scale_origin,
 124                stack_offset=(
 125                    (0, -15)
 126                    if uiscale is bui.UIScale.SMALL
 127                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
 128                ),
 129                transition=transition,
 130                scale=(
 131                    1.2
 132                    if uiscale is bui.UIScale.SMALL
 133                    else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75
 134                ),
 135            )
 136        )
 137
 138        if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL:
 139            self._back_button = None
 140        else:
 141            self._back_button = bui.buttonwidget(
 142                parent=self._root_widget,
 143                position=(
 144                    75 + x_inset,
 145                    self._height
 146                    - 87
 147                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 148                ),
 149                size=(120, 60),
 150                scale=1.2,
 151                autoselect=True,
 152                label=bui.Lstr(resource='backText'),
 153                button_type='back',
 154            )
 155
 156        self._league_rank_button: LeagueRankButton | None
 157        self._store_button: StoreButton | None
 158        self._store_button_widget: bui.Widget | None
 159        self._league_rank_button_widget: bui.Widget | None
 160
 161        if not app.ui_v1.use_toolbars:
 162            prb = self._league_rank_button = LeagueRankButton(
 163                parent=self._root_widget,
 164                position=(
 165                    self._width - (282 + x_inset),
 166                    self._height
 167                    - 85
 168                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 169                ),
 170                size=(100, 60),
 171                color=(0.4, 0.4, 0.9),
 172                textcolor=(0.9, 0.9, 2.0),
 173                scale=1.05,
 174                on_activate_call=bui.WeakCall(self._switch_to_league_rankings),
 175            )
 176            self._league_rank_button_widget = prb.get_button()
 177
 178            sbtn = self._store_button = StoreButton(
 179                parent=self._root_widget,
 180                position=(
 181                    self._width - (170 + x_inset),
 182                    self._height
 183                    - 85
 184                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 185                ),
 186                size=(100, 60),
 187                color=(0.6, 0.4, 0.7),
 188                show_tickets=True,
 189                button_type='square',
 190                sale_scale=0.85,
 191                textcolor=(0.9, 0.7, 1.0),
 192                scale=1.05,
 193                on_activate_call=bui.WeakCall(self._switch_to_score, None),
 194            )
 195            self._store_button_widget = sbtn.get_button()
 196            bui.widget(
 197                edit=self._back_button,
 198                right_widget=self._league_rank_button_widget,
 199            )
 200            bui.widget(
 201                edit=self._league_rank_button_widget,
 202                left_widget=self._back_button,
 203            )
 204        else:
 205            self._league_rank_button = None
 206            self._store_button = None
 207            self._store_button_widget = None
 208            self._league_rank_button_widget = None
 209
 210        # Move our corner buttons dynamically to keep them out of the way of
 211        # the party icon :-(
 212        self._update_corner_button_positions()
 213        self._update_corner_button_positions_timer = bui.AppTimer(
 214            1.0, bui.WeakCall(self._update_corner_button_positions), repeat=True
 215        )
 216
 217        self._last_tournament_query_time: float | None = None
 218        self._last_tournament_query_response_time: float | None = None
 219        self._doing_tournament_query = False
 220
 221        self._selected_campaign_level = cfg.get(
 222            'Selected Coop Campaign Level', None
 223        )
 224        self._selected_custom_level = cfg.get(
 225            'Selected Coop Custom Level', None
 226        )
 227
 228        # Don't want initial construction affecting our last-selected.
 229        self._do_selection_callbacks = False
 230        v = self._height - 95
 231        txt = bui.textwidget(
 232            parent=self._root_widget,
 233            position=(
 234                self._width * 0.5,
 235                v + 40 - (0 if uiscale is bui.UIScale.SMALL else 0),
 236            ),
 237            size=(0, 0),
 238            text=bui.Lstr(
 239                resource='playModes.singlePlayerCoopText',
 240                fallback_resource='playModes.coopText',
 241            ),
 242            h_align='center',
 243            color=app.ui_v1.title_color,
 244            scale=1.5,
 245            maxwidth=500,
 246            v_align='center',
 247        )
 248
 249        if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL:
 250            bui.textwidget(edit=txt, text='')
 251
 252        if self._back_button is not None:
 253            bui.buttonwidget(
 254                edit=self._back_button,
 255                button_type='backSmall',
 256                size=(60, 50),
 257                position=(
 258                    75 + x_inset,
 259                    self._height
 260                    - 87
 261                    - (4 if uiscale is bui.UIScale.SMALL else 0)
 262                    + 6,
 263                ),
 264                label=bui.charstr(bui.SpecialChar.BACK),
 265            )
 266
 267        self._selected_row = cfg.get('Selected Coop Row', None)
 268
 269        self._scroll_width = self._width - (130 + 2 * x_inset)
 270        self._scroll_height = self._height - (
 271            190
 272            if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars
 273            else 160
 274        )
 275
 276        self._subcontainerwidth = 800.0
 277        self._subcontainerheight = 1400.0
 278
 279        self._scrollwidget = bui.scrollwidget(
 280            parent=self._root_widget,
 281            highlight=False,
 282            position=(
 283                (65 + x_inset, 120)
 284                if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars
 285                else (65 + x_inset, 70)
 286            ),
 287            size=(self._scroll_width, self._scroll_height),
 288            simple_culling_v=10.0,
 289            claims_left_right=True,
 290            claims_tab=True,
 291            selection_loops_to_parent=True,
 292        )
 293        self._subcontainer: bui.Widget | None = None
 294
 295        # Take note of our account state; we'll refresh later if this changes.
 296        self._account_state_num = plus.get_v1_account_state_num()
 297
 298        # Same for fg/bg state.
 299        self._fg_state = app.fg_state
 300
 301        self._refresh()
 302        self._restore_state()
 303
 304        # Even though we might display cached tournament data immediately, we
 305        # don't consider it valid until we've pinged.
 306        # the server for an update
 307        self._tourney_data_up_to_date = False
 308
 309        # If we've got a cached tournament list for our account and info for
 310        # each one of those tournaments, go ahead and display it as a
 311        # starting point.
 312        if (
 313            app.classic.accounts.account_tournament_list is not None
 314            and app.classic.accounts.account_tournament_list[0]
 315            == plus.get_v1_account_state_num()
 316            and all(
 317                t_id in app.classic.accounts.tournament_info
 318                for t_id in app.classic.accounts.account_tournament_list[1]
 319            )
 320        ):
 321            tourney_data = [
 322                app.classic.accounts.tournament_info[t_id]
 323                for t_id in app.classic.accounts.account_tournament_list[1]
 324            ]
 325            self._update_for_data(tourney_data)
 326
 327        # This will pull new data periodically, update timers, etc.
 328        self._update_timer = bui.AppTimer(
 329            1.0, bui.WeakCall(self._update), repeat=True
 330        )
 331        self._update()
 332
 333    def _update_corner_button_positions(self) -> None:
 334        assert bui.app.classic is not None
 335        uiscale = bui.app.ui_v1.uiscale
 336        offs = (
 337            -55
 338            if uiscale is bui.UIScale.SMALL and bui.is_party_icon_visible()
 339            else 0
 340        )
 341        if self._league_rank_button is not None:
 342            self._league_rank_button.set_position(
 343                (
 344                    self._width - 282 + offs - self._x_inset,
 345                    self._height
 346                    - 85
 347                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 348                )
 349            )
 350        if self._store_button is not None:
 351            self._store_button.set_position(
 352                (
 353                    self._width - 170 + offs - self._x_inset,
 354                    self._height
 355                    - 85
 356                    - (4 if uiscale is bui.UIScale.SMALL else 0),
 357                )
 358            )
 359
 360    # noinspection PyUnresolvedReferences
 361    @staticmethod
 362    def _preload_modules() -> None:
 363        """Preload modules we use; avoids hitches (called in bg thread)."""
 364        import bauiv1lib.purchase as _unused1
 365        import bauiv1lib.coop.gamebutton as _unused2
 366        import bauiv1lib.confirm as _unused3
 367        import bauiv1lib.account as _unused4
 368        import bauiv1lib.league.rankwindow as _unused5
 369        import bauiv1lib.store.browser as _unused6
 370        import bauiv1lib.account.viewer as _unused7
 371        import bauiv1lib.tournamentscores as _unused8
 372        import bauiv1lib.tournamententry as _unused9
 373        import bauiv1lib.play as _unused10
 374        import bauiv1lib.coop.tournamentbutton as _unused11
 375
 376    def _update(self) -> None:
 377        plus = bui.app.plus
 378        assert plus is not None
 379
 380        # Do nothing if we've somehow outlived our actual UI.
 381        if not self._root_widget:
 382            return
 383
 384        cur_time = bui.apptime()
 385
 386        # If its been a while since we got a tournament update, consider the
 387        # data invalid (prevents us from joining tournaments if our internet
 388        # connection goes down for a while).
 389        if (
 390            self._last_tournament_query_response_time is None
 391            or bui.apptime() - self._last_tournament_query_response_time
 392            > 60.0 * 2
 393        ):
 394            self._tourney_data_up_to_date = False
 395
 396        # If our account state has changed, do a full request.
 397        account_state_num = plus.get_v1_account_state_num()
 398        if account_state_num != self._account_state_num:
 399            self._account_state_num = account_state_num
 400            self._save_state()
 401            self._refresh()
 402
 403            # Also encourage a new tournament query since this will clear out
 404            # our current results.
 405            if not self._doing_tournament_query:
 406                self._last_tournament_query_time = None
 407
 408        # If we've been backgrounded/foregrounded, invalidate our
 409        # tournament entries (they will be refreshed below asap).
 410        if self._fg_state != bui.app.fg_state:
 411            self._tourney_data_up_to_date = False
 412
 413        # Send off a new tournament query if its been long enough or whatnot.
 414        if not self._doing_tournament_query and (
 415            self._last_tournament_query_time is None
 416            or cur_time - self._last_tournament_query_time > 30.0
 417            or self._fg_state != bui.app.fg_state
 418        ):
 419            self._fg_state = bui.app.fg_state
 420            self._last_tournament_query_time = cur_time
 421            self._doing_tournament_query = True
 422            plus.tournament_query(
 423                args={'source': 'coop window refresh', 'numScores': 1},
 424                callback=bui.WeakCall(self._on_tournament_query_response),
 425            )
 426
 427        # Decrement time on our tournament buttons.
 428        ads_enabled = plus.have_incentivized_ad()
 429        for tbtn in self._tournament_buttons:
 430            tbtn.time_remaining = max(0, tbtn.time_remaining - 1)
 431            if tbtn.time_remaining_value_text is not None:
 432                bui.textwidget(
 433                    edit=tbtn.time_remaining_value_text,
 434                    text=(
 435                        bui.timestring(tbtn.time_remaining, centi=False)
 436                        if (
 437                            tbtn.has_time_remaining
 438                            and self._tourney_data_up_to_date
 439                        )
 440                        else '-'
 441                    ),
 442                )
 443
 444            # Also adjust the ad icon visibility.
 445            if tbtn.allow_ads and plus.has_video_ads():
 446                bui.imagewidget(
 447                    edit=tbtn.entry_fee_ad_image,
 448                    opacity=1.0 if ads_enabled else 0.25,
 449                )
 450                bui.textwidget(
 451                    edit=tbtn.entry_fee_text_remaining,
 452                    color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2),
 453                )
 454
 455        self._update_hard_mode_lock_image()
 456
 457    def _update_hard_mode_lock_image(self) -> None:
 458        assert bui.app.classic is not None
 459        try:
 460            bui.imagewidget(
 461                edit=self._hard_button_lock_image,
 462                opacity=(
 463                    0.0 if bui.app.classic.accounts.have_pro_options() else 1.0
 464                ),
 465            )
 466        except Exception:
 467            logging.exception('Error updating campaign lock.')
 468
 469    def _update_for_data(self, data: list[dict[str, Any]] | None) -> None:
 470        # If the number of tournaments or challenges in the data differs from
 471        # our current arrangement, refresh with the new number.
 472        if (data is None and self._tournament_button_count != 0) or (
 473            data is not None and (len(data) != self._tournament_button_count)
 474        ):
 475            self._tournament_button_count = len(data) if data is not None else 0
 476            bui.app.config['Tournament Rows'] = self._tournament_button_count
 477            self._refresh()
 478
 479        # Update all of our tourney buttons based on whats in data.
 480        for i, tbtn in enumerate(self._tournament_buttons):
 481            assert data is not None
 482            tbtn.update_for_data(data[i])
 483
 484    def _on_tournament_query_response(
 485        self, data: dict[str, Any] | None
 486    ) -> None:
 487        plus = bui.app.plus
 488        assert plus is not None
 489
 490        assert bui.app.classic is not None
 491        accounts = bui.app.classic.accounts
 492        if data is not None:
 493            tournament_data = data['t']  # This used to be the whole payload.
 494            self._last_tournament_query_response_time = bui.apptime()
 495        else:
 496            tournament_data = None
 497
 498        # Keep our cached tourney info up to date.
 499        if data is not None:
 500            self._tourney_data_up_to_date = True
 501            accounts.cache_tournament_info(tournament_data)
 502
 503            # Also cache the current tourney list/order for this account.
 504            accounts.account_tournament_list = (
 505                plus.get_v1_account_state_num(),
 506                [e['tournamentID'] for e in tournament_data],
 507            )
 508
 509        self._doing_tournament_query = False
 510        self._update_for_data(tournament_data)
 511
 512    def _set_campaign_difficulty(self, difficulty: str) -> None:
 513        # pylint: disable=cyclic-import
 514        from bauiv1lib.purchase import PurchaseWindow
 515
 516        plus = bui.app.plus
 517        assert plus is not None
 518
 519        assert bui.app.classic is not None
 520        if difficulty != self._campaign_difficulty:
 521            if (
 522                difficulty == 'hard'
 523                and not bui.app.classic.accounts.have_pro_options()
 524            ):
 525                PurchaseWindow(items=['pro'])
 526                return
 527            bui.getsound('gunCocking').play()
 528            if difficulty not in ('easy', 'hard'):
 529                print('ERROR: invalid campaign difficulty:', difficulty)
 530                difficulty = 'easy'
 531            self._campaign_difficulty = difficulty
 532            plus.add_v1_account_transaction(
 533                {
 534                    'type': 'SET_MISC_VAL',
 535                    'name': 'campaignDifficulty',
 536                    'value': difficulty,
 537                }
 538            )
 539            self._refresh_campaign_row()
 540        else:
 541            bui.getsound('click01').play()
 542
 543    def _refresh_campaign_row(self) -> None:
 544        # pylint: disable=too-many-locals
 545        # pylint: disable=cyclic-import
 546        from bauiv1lib.coop.gamebutton import GameButton
 547
 548        parent_widget = self._campaign_sub_container
 549
 550        # Clear out anything in the parent widget already.
 551        assert parent_widget is not None
 552        for child in parent_widget.get_children():
 553            child.delete()
 554
 555        next_widget_down = self._tournament_info_button
 556
 557        h = 0
 558        v2 = -2
 559        sel_color = (0.75, 0.85, 0.5)
 560        sel_color_hard = (0.4, 0.7, 0.2)
 561        un_sel_color = (0.5, 0.5, 0.5)
 562        sel_textcolor = (2, 2, 0.8)
 563        un_sel_textcolor = (0.6, 0.6, 0.6)
 564        self._easy_button = bui.buttonwidget(
 565            parent=parent_widget,
 566            position=(h + 30, v2 + 105),
 567            size=(120, 70),
 568            label=bui.Lstr(resource='difficultyEasyText'),
 569            button_type='square',
 570            autoselect=True,
 571            enable_sound=False,
 572            on_activate_call=bui.Call(self._set_campaign_difficulty, 'easy'),
 573            on_select_call=bui.Call(self.sel_change, 'campaign', 'easyButton'),
 574            color=(
 575                sel_color
 576                if self._campaign_difficulty == 'easy'
 577                else un_sel_color
 578            ),
 579            textcolor=(
 580                sel_textcolor
 581                if self._campaign_difficulty == 'easy'
 582                else un_sel_textcolor
 583            ),
 584        )
 585        bui.widget(edit=self._easy_button, show_buffer_left=100)
 586        if self._selected_campaign_level == 'easyButton':
 587            bui.containerwidget(
 588                edit=parent_widget,
 589                selected_child=self._easy_button,
 590                visible_child=self._easy_button,
 591            )
 592        lock_tex = bui.gettexture('lock')
 593
 594        self._hard_button = bui.buttonwidget(
 595            parent=parent_widget,
 596            position=(h + 30, v2 + 32),
 597            size=(120, 70),
 598            label=bui.Lstr(resource='difficultyHardText'),
 599            button_type='square',
 600            autoselect=True,
 601            enable_sound=False,
 602            on_activate_call=bui.Call(self._set_campaign_difficulty, 'hard'),
 603            on_select_call=bui.Call(self.sel_change, 'campaign', 'hardButton'),
 604            color=(
 605                sel_color_hard
 606                if self._campaign_difficulty == 'hard'
 607                else un_sel_color
 608            ),
 609            textcolor=(
 610                sel_textcolor
 611                if self._campaign_difficulty == 'hard'
 612                else un_sel_textcolor
 613            ),
 614        )
 615        self._hard_button_lock_image = bui.imagewidget(
 616            parent=parent_widget,
 617            size=(30, 30),
 618            draw_controller=self._hard_button,
 619            position=(h + 30 - 10, v2 + 32 + 70 - 35),
 620            texture=lock_tex,
 621        )
 622        self._update_hard_mode_lock_image()
 623        bui.widget(edit=self._hard_button, show_buffer_left=100)
 624        if self._selected_campaign_level == 'hardButton':
 625            bui.containerwidget(
 626                edit=parent_widget,
 627                selected_child=self._hard_button,
 628                visible_child=self._hard_button,
 629            )
 630
 631        bui.widget(edit=self._hard_button, down_widget=next_widget_down)
 632        h_spacing = 200
 633        campaign_buttons = []
 634        if self._campaign_difficulty == 'easy':
 635            campaignname = 'Easy'
 636        else:
 637            campaignname = 'Default'
 638        items = [
 639            campaignname + ':Onslaught Training',
 640            campaignname + ':Rookie Onslaught',
 641            campaignname + ':Rookie Football',
 642            campaignname + ':Pro Onslaught',
 643            campaignname + ':Pro Football',
 644            campaignname + ':Pro Runaround',
 645            campaignname + ':Uber Onslaught',
 646            campaignname + ':Uber Football',
 647            campaignname + ':Uber Runaround',
 648        ]
 649        items += [campaignname + ':The Last Stand']
 650        if self._selected_campaign_level is None:
 651            self._selected_campaign_level = items[0]
 652        h = 150
 653        for i in items:
 654            is_last_sel = i == self._selected_campaign_level
 655            campaign_buttons.append(
 656                GameButton(
 657                    self, parent_widget, i, h, v2, is_last_sel, 'campaign'
 658                ).get_button()
 659            )
 660            h += h_spacing
 661
 662        bui.widget(edit=campaign_buttons[0], left_widget=self._easy_button)
 663
 664        if self._back_button is not None:
 665            bui.widget(edit=self._easy_button, up_widget=self._back_button)
 666            for btn in campaign_buttons:
 667                bui.widget(
 668                    edit=btn,
 669                    up_widget=self._back_button,
 670                    down_widget=next_widget_down,
 671                )
 672
 673        # Update our existing percent-complete text.
 674        assert bui.app.classic is not None
 675        campaign = bui.app.classic.getcampaign(campaignname)
 676        levels = campaign.levels
 677        levels_complete = sum((1 if l.complete else 0) for l in levels)
 678
 679        # Last level cant be completed; hence the -1.
 680        progress = min(1.0, float(levels_complete) / (len(levels) - 1))
 681        p_str = str(int(progress * 100.0)) + '%'
 682
 683        self._campaign_percent_text = bui.textwidget(
 684            edit=self._campaign_percent_text,
 685            text=bui.Lstr(
 686                value='${C} (${P})',
 687                subs=[
 688                    ('${C}', bui.Lstr(resource=self._r + '.campaignText')),
 689                    ('${P}', p_str),
 690                ],
 691            ),
 692        )
 693
 694    def _on_tournament_info_press(self) -> None:
 695        # pylint: disable=cyclic-import
 696        from bauiv1lib.confirm import ConfirmWindow
 697
 698        txt = bui.Lstr(resource=self._r + '.tournamentInfoText')
 699        ConfirmWindow(
 700            txt,
 701            cancel_button=False,
 702            width=550,
 703            height=260,
 704            origin_widget=self._tournament_info_button,
 705        )
 706
 707    def _refresh(self) -> None:
 708        # pylint: disable=too-many-statements
 709        # pylint: disable=too-many-branches
 710        # pylint: disable=too-many-locals
 711        # pylint: disable=cyclic-import
 712        from bauiv1lib.coop.gamebutton import GameButton
 713        from bauiv1lib.coop.tournamentbutton import TournamentButton
 714
 715        plus = bui.app.plus
 716        assert plus is not None
 717        assert bui.app.classic is not None
 718
 719        # (Re)create the sub-container if need be.
 720        if self._subcontainer is not None:
 721            self._subcontainer.delete()
 722
 723        tourney_row_height = 200
 724        self._subcontainerheight = (
 725            620 + self._tournament_button_count * tourney_row_height
 726        )
 727
 728        self._subcontainer = bui.containerwidget(
 729            parent=self._scrollwidget,
 730            size=(self._subcontainerwidth, self._subcontainerheight),
 731            background=False,
 732            claims_left_right=True,
 733            claims_tab=True,
 734            selection_loops_to_parent=True,
 735        )
 736
 737        bui.containerwidget(
 738            edit=self._root_widget, selected_child=self._scrollwidget
 739        )
 740        if self._back_button is not None:
 741            bui.containerwidget(
 742                edit=self._root_widget, cancel_button=self._back_button
 743            )
 744
 745        w_parent = self._subcontainer
 746        h_base = 6
 747
 748        v = self._subcontainerheight - 73
 749
 750        self._campaign_percent_text = bui.textwidget(
 751            parent=w_parent,
 752            position=(h_base + 27, v + 30),
 753            size=(0, 0),
 754            text='',
 755            h_align='left',
 756            v_align='center',
 757            color=bui.app.ui_v1.title_color,
 758            scale=1.1,
 759        )
 760
 761        row_v_show_buffer = 100
 762        v -= 198
 763
 764        h_scroll = bui.hscrollwidget(
 765            parent=w_parent,
 766            size=(self._scroll_width - 10, 205),
 767            position=(-5, v),
 768            simple_culling_h=70,
 769            highlight=False,
 770            border_opacity=0.0,
 771            color=(0.45, 0.4, 0.5),
 772            on_select_call=lambda: self._on_row_selected('campaign'),
 773        )
 774        self._campaign_h_scroll = h_scroll
 775        bui.widget(
 776            edit=h_scroll,
 777            show_buffer_top=row_v_show_buffer,
 778            show_buffer_bottom=row_v_show_buffer,
 779            autoselect=True,
 780        )
 781        if self._selected_row == 'campaign':
 782            bui.containerwidget(
 783                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 784            )
 785        bui.containerwidget(edit=h_scroll, claims_left_right=True)
 786        self._campaign_sub_container = bui.containerwidget(
 787            parent=h_scroll, size=(180 + 200 * 10, 200), background=False
 788        )
 789
 790        # Tournaments
 791
 792        self._tournament_buttons: list[TournamentButton] = []
 793
 794        v -= 53
 795        # FIXME shouldn't use hard-coded strings here.
 796        txt = bui.Lstr(
 797            resource='tournamentsText', fallback_resource='tournamentText'
 798        ).evaluate()
 799        t_width = bui.get_string_width(txt, suppress_warning=True)
 800        bui.textwidget(
 801            parent=w_parent,
 802            position=(h_base + 27, v + 30),
 803            size=(0, 0),
 804            text=txt,
 805            h_align='left',
 806            v_align='center',
 807            color=bui.app.ui_v1.title_color,
 808            scale=1.1,
 809        )
 810        self._tournament_info_button = bui.buttonwidget(
 811            parent=w_parent,
 812            label='?',
 813            size=(20, 20),
 814            text_scale=0.6,
 815            position=(h_base + 27 + t_width * 1.1 + 15, v + 18),
 816            button_type='square',
 817            color=(0.6, 0.5, 0.65),
 818            textcolor=(0.7, 0.6, 0.75),
 819            autoselect=True,
 820            up_widget=self._campaign_h_scroll,
 821            on_activate_call=self._on_tournament_info_press,
 822        )
 823        bui.widget(
 824            edit=self._tournament_info_button,
 825            left_widget=self._tournament_info_button,
 826            right_widget=self._tournament_info_button,
 827        )
 828
 829        # Say 'unavailable' if there are zero tournaments, and if we're not
 830        # signed in add that as well (that's probably why we see
 831        # no tournaments).
 832        if self._tournament_button_count == 0:
 833            unavailable_text = bui.Lstr(resource='unavailableText')
 834            if plus.get_v1_account_state() != 'signed_in':
 835                unavailable_text = bui.Lstr(
 836                    value='${A} (${B})',
 837                    subs=[
 838                        ('${A}', unavailable_text),
 839                        ('${B}', bui.Lstr(resource='notSignedInText')),
 840                    ],
 841                )
 842            bui.textwidget(
 843                parent=w_parent,
 844                position=(h_base + 47, v),
 845                size=(0, 0),
 846                text=unavailable_text,
 847                h_align='left',
 848                v_align='center',
 849                color=bui.app.ui_v1.title_color,
 850                scale=0.9,
 851            )
 852            v -= 40
 853        v -= 198
 854
 855        tournament_h_scroll = None
 856        if self._tournament_button_count > 0:
 857            for i in range(self._tournament_button_count):
 858                tournament_h_scroll = h_scroll = bui.hscrollwidget(
 859                    parent=w_parent,
 860                    size=(self._scroll_width - 10, 205),
 861                    position=(-5, v),
 862                    highlight=False,
 863                    border_opacity=0.0,
 864                    color=(0.45, 0.4, 0.5),
 865                    on_select_call=bui.Call(
 866                        self._on_row_selected, 'tournament' + str(i + 1)
 867                    ),
 868                )
 869                bui.widget(
 870                    edit=h_scroll,
 871                    show_buffer_top=row_v_show_buffer,
 872                    show_buffer_bottom=row_v_show_buffer,
 873                    autoselect=True,
 874                )
 875                if self._selected_row == 'tournament' + str(i + 1):
 876                    bui.containerwidget(
 877                        edit=w_parent,
 878                        selected_child=h_scroll,
 879                        visible_child=h_scroll,
 880                    )
 881                bui.containerwidget(edit=h_scroll, claims_left_right=True)
 882                sc2 = bui.containerwidget(
 883                    parent=h_scroll,
 884                    size=(self._scroll_width - 24, 200),
 885                    background=False,
 886                )
 887                h = 0
 888                v2 = -2
 889                is_last_sel = True
 890                self._tournament_buttons.append(
 891                    TournamentButton(
 892                        sc2,
 893                        h,
 894                        v2,
 895                        is_last_sel,
 896                        on_pressed=bui.WeakCall(self.run_tournament),
 897                    )
 898                )
 899                v -= 200
 900
 901        # Custom Games. (called 'Practice' in UI these days).
 902        v -= 50
 903        bui.textwidget(
 904            parent=w_parent,
 905            position=(h_base + 27, v + 30 + 198),
 906            size=(0, 0),
 907            text=bui.Lstr(
 908                resource='practiceText',
 909                fallback_resource='coopSelectWindow.customText',
 910            ),
 911            h_align='left',
 912            v_align='center',
 913            color=bui.app.ui_v1.title_color,
 914            scale=1.1,
 915        )
 916
 917        items = [
 918            'Challenges:Infinite Onslaught',
 919            'Challenges:Infinite Runaround',
 920            'Challenges:Ninja Fight',
 921            'Challenges:Pro Ninja Fight',
 922            'Challenges:Meteor Shower',
 923            'Challenges:Target Practice B',
 924            'Challenges:Target Practice',
 925        ]
 926
 927        # Show easter-egg-hunt either if its easter or we own it.
 928        if plus.get_v1_account_misc_read_val(
 929            'easter', False
 930        ) or plus.get_purchased('games.easter_egg_hunt'):
 931            items = [
 932                'Challenges:Easter Egg Hunt',
 933                'Challenges:Pro Easter Egg Hunt',
 934            ] + items
 935
 936        # If we've defined custom games, put them at the beginning.
 937        if bui.app.classic.custom_coop_practice_games:
 938            items = bui.app.classic.custom_coop_practice_games + items
 939
 940        self._custom_h_scroll = custom_h_scroll = h_scroll = bui.hscrollwidget(
 941            parent=w_parent,
 942            size=(self._scroll_width - 10, 205),
 943            position=(-5, v),
 944            highlight=False,
 945            border_opacity=0.0,
 946            color=(0.45, 0.4, 0.5),
 947            on_select_call=bui.Call(self._on_row_selected, 'custom'),
 948        )
 949        bui.widget(
 950            edit=h_scroll,
 951            show_buffer_top=row_v_show_buffer,
 952            show_buffer_bottom=1.5 * row_v_show_buffer,
 953            autoselect=True,
 954        )
 955        if self._selected_row == 'custom':
 956            bui.containerwidget(
 957                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 958            )
 959        bui.containerwidget(edit=h_scroll, claims_left_right=True)
 960        sc2 = bui.containerwidget(
 961            parent=h_scroll,
 962            size=(max(self._scroll_width - 24, 30 + 200 * len(items)), 200),
 963            background=False,
 964        )
 965        h_spacing = 200
 966        self._custom_buttons: list[GameButton] = []
 967        h = 0
 968        v2 = -2
 969        for item in items:
 970            is_last_sel = item == self._selected_custom_level
 971            self._custom_buttons.append(
 972                GameButton(self, sc2, item, h, v2, is_last_sel, 'custom')
 973            )
 974            h += h_spacing
 975
 976        # We can't fill in our campaign row until tourney buttons are in place.
 977        # (for wiring up)
 978        self._refresh_campaign_row()
 979
 980        for i, tbutton in enumerate(self._tournament_buttons):
 981            bui.widget(
 982                edit=tbutton.button,
 983                up_widget=(
 984                    self._tournament_info_button
 985                    if i == 0
 986                    else self._tournament_buttons[i - 1].button
 987                ),
 988                down_widget=(
 989                    self._tournament_buttons[(i + 1)].button
 990                    if i + 1 < len(self._tournament_buttons)
 991                    else custom_h_scroll
 992                ),
 993            )
 994            bui.widget(
 995                edit=tbutton.more_scores_button,
 996                down_widget=(
 997                    self._tournament_buttons[(i + 1)].current_leader_name_text
 998                    if i + 1 < len(self._tournament_buttons)
 999                    else custom_h_scroll
1000                ),
1001            )
1002            bui.widget(
1003                edit=tbutton.current_leader_name_text,
1004                up_widget=(
1005                    self._tournament_info_button
1006                    if i == 0
1007                    else self._tournament_buttons[i - 1].more_scores_button
1008                ),
1009            )
1010
1011        for btn in self._custom_buttons:
1012            try:
1013                bui.widget(
1014                    edit=btn.get_button(),
1015                    up_widget=(
1016                        tournament_h_scroll
1017                        if self._tournament_buttons
1018                        else self._tournament_info_button
1019                    ),
1020                )
1021            except Exception:
1022                logging.exception('Error wiring up custom buttons.')
1023
1024        if self._back_button is not None:
1025            bui.buttonwidget(
1026                edit=self._back_button, on_activate_call=self._back
1027            )
1028        else:
1029            bui.containerwidget(
1030                edit=self._root_widget, on_cancel_call=self._back
1031            )
1032
1033        # There's probably several 'onSelected' callbacks pushed onto the
1034        # event queue.. we need to push ours too so we're enabled *after* them.
1035        bui.pushcall(self._enable_selectable_callback)
1036
1037    def _on_row_selected(self, row: str) -> None:
1038        if self._do_selection_callbacks:
1039            if self._selected_row != row:
1040                self._selected_row = row
1041
1042    def _enable_selectable_callback(self) -> None:
1043        self._do_selection_callbacks = True
1044
1045    def _switch_to_league_rankings(self) -> None:
1046        # pylint: disable=cyclic-import
1047        from bauiv1lib.account import show_sign_in_prompt
1048        from bauiv1lib.league.rankwindow import LeagueRankWindow
1049
1050        # no-op if our underlying widget is dead or on its way out.
1051        if not self._root_widget or self._root_widget.transitioning_out:
1052            return
1053
1054        plus = bui.app.plus
1055        assert plus is not None
1056
1057        if plus.get_v1_account_state() != 'signed_in':
1058            show_sign_in_prompt()
1059            return
1060        self._save_state()
1061        bui.containerwidget(edit=self._root_widget, transition='out_left')
1062        assert self._league_rank_button is not None
1063        assert bui.app.classic is not None
1064        bui.app.ui_v1.set_main_menu_window(
1065            LeagueRankWindow(
1066                origin_widget=self._league_rank_button.get_button()
1067            ).get_root_widget(),
1068            from_window=self._root_widget,
1069        )
1070
1071    def _switch_to_score(
1072        self,
1073        show_tab: (
1074            StoreBrowserWindow.TabID | None
1075        ) = StoreBrowserWindow.TabID.EXTRAS,
1076    ) -> None:
1077        # pylint: disable=cyclic-import
1078        from bauiv1lib.account import show_sign_in_prompt
1079
1080        # no-op if our underlying widget is dead or on its way out.
1081        if not self._root_widget or self._root_widget.transitioning_out:
1082            return
1083
1084        plus = bui.app.plus
1085        assert plus is not None
1086
1087        if plus.get_v1_account_state() != 'signed_in':
1088            show_sign_in_prompt()
1089            return
1090        self._save_state()
1091        bui.containerwidget(edit=self._root_widget, transition='out_left')
1092        assert self._store_button is not None
1093        assert bui.app.classic is not None
1094        bui.app.ui_v1.set_main_menu_window(
1095            StoreBrowserWindow(
1096                origin_widget=self._store_button.get_button(),
1097                show_tab=show_tab,
1098                back_location='CoopBrowserWindow',
1099            ).get_root_widget(),
1100            from_window=self._root_widget,
1101        )
1102
1103    def is_tourney_data_up_to_date(self) -> bool:
1104        """Return whether our tourney data is up to date."""
1105        return self._tourney_data_up_to_date
1106
1107    def run_game(self, game: str) -> None:
1108        """Run the provided game."""
1109        # pylint: disable=too-many-branches
1110        # pylint: disable=cyclic-import
1111        from bauiv1lib.confirm import ConfirmWindow
1112        from bauiv1lib.purchase import PurchaseWindow
1113        from bauiv1lib.account import show_sign_in_prompt
1114
1115        plus = bui.app.plus
1116        assert plus is not None
1117
1118        assert bui.app.classic is not None
1119
1120        args: dict[str, Any] = {}
1121
1122        if game == 'Easy:The Last Stand':
1123            ConfirmWindow(
1124                bui.Lstr(
1125                    resource='difficultyHardUnlockOnlyText',
1126                    fallback_resource='difficultyHardOnlyText',
1127                ),
1128                cancel_button=False,
1129                width=460,
1130                height=130,
1131            )
1132            return
1133
1134        # Infinite onslaught/runaround require pro; bring up a store link
1135        # if need be.
1136        if (
1137            game
1138            in (
1139                'Challenges:Infinite Runaround',
1140                'Challenges:Infinite Onslaught',
1141            )
1142            and not bui.app.classic.accounts.have_pro()
1143        ):
1144            if plus.get_v1_account_state() != 'signed_in':
1145                show_sign_in_prompt()
1146            else:
1147                PurchaseWindow(items=['pro'])
1148            return
1149
1150        required_purchase: str | None
1151        if game in ['Challenges:Meteor Shower']:
1152            required_purchase = 'games.meteor_shower'
1153        elif game in [
1154            'Challenges:Target Practice',
1155            'Challenges:Target Practice B',
1156        ]:
1157            required_purchase = 'games.target_practice'
1158        elif game in ['Challenges:Ninja Fight']:
1159            required_purchase = 'games.ninja_fight'
1160        elif game in ['Challenges:Pro Ninja Fight']:
1161            required_purchase = 'games.ninja_fight'
1162        elif game in [
1163            'Challenges:Easter Egg Hunt',
1164            'Challenges:Pro Easter Egg Hunt',
1165        ]:
1166            required_purchase = 'games.easter_egg_hunt'
1167        else:
1168            required_purchase = None
1169
1170        if required_purchase is not None and not plus.get_purchased(
1171            required_purchase
1172        ):
1173            if plus.get_v1_account_state() != 'signed_in':
1174                show_sign_in_prompt()
1175            else:
1176                PurchaseWindow(items=[required_purchase])
1177            return
1178
1179        self._save_state()
1180
1181        if bui.app.classic.launch_coop_game(game, args=args):
1182            bui.containerwidget(edit=self._root_widget, transition='out_left')
1183
1184    def run_tournament(self, tournament_button: TournamentButton) -> None:
1185        """Run the provided tournament game."""
1186        from bauiv1lib.account import show_sign_in_prompt
1187        from bauiv1lib.tournamententry import TournamentEntryWindow
1188
1189        plus = bui.app.plus
1190        assert plus is not None
1191
1192        if plus.get_v1_account_state() != 'signed_in':
1193            show_sign_in_prompt()
1194            return
1195
1196        if bui.workspaces_in_use():
1197            bui.screenmessage(
1198                bui.Lstr(resource='tournamentsDisabledWorkspaceText'),
1199                color=(1, 0, 0),
1200            )
1201            bui.getsound('error').play()
1202            return
1203
1204        if not self._tourney_data_up_to_date:
1205            bui.screenmessage(
1206                bui.Lstr(resource='tournamentCheckingStateText'),
1207                color=(1, 1, 0),
1208            )
1209            bui.getsound('error').play()
1210            return
1211
1212        if tournament_button.tournament_id is None:
1213            bui.screenmessage(
1214                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1215                color=(1, 0, 0),
1216            )
1217            bui.getsound('error').play()
1218            return
1219
1220        if tournament_button.required_league is not None:
1221            bui.screenmessage(
1222                bui.Lstr(
1223                    resource='league.tournamentLeagueText',
1224                    subs=[
1225                        (
1226                            '${NAME}',
1227                            bui.Lstr(
1228                                translate=(
1229                                    'leagueNames',
1230                                    tournament_button.required_league,
1231                                )
1232                            ),
1233                        )
1234                    ],
1235                ),
1236                color=(1, 0, 0),
1237            )
1238            bui.getsound('error').play()
1239            return
1240
1241        if tournament_button.time_remaining <= 0:
1242            bui.screenmessage(
1243                bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1244            )
1245            bui.getsound('error').play()
1246            return
1247
1248        self._save_state()
1249
1250        assert tournament_button.tournament_id is not None
1251        TournamentEntryWindow(
1252            tournament_id=tournament_button.tournament_id,
1253            position=tournament_button.button.get_screen_space_center(),
1254        )
1255
1256    def _back(self) -> None:
1257        # pylint: disable=cyclic-import
1258        from bauiv1lib.play import PlayWindow
1259
1260        # no-op if our underlying widget is dead or on its way out.
1261        if not self._root_widget or self._root_widget.transitioning_out:
1262            return
1263
1264        # If something is selected, store it.
1265        self._save_state()
1266        bui.containerwidget(
1267            edit=self._root_widget, transition=self._transition_out
1268        )
1269        assert bui.app.classic is not None
1270        bui.app.ui_v1.set_main_menu_window(
1271            PlayWindow(transition='in_left').get_root_widget(),
1272            from_window=self._root_widget,
1273        )
1274
1275    def _save_state(self) -> None:
1276        cfg = bui.app.config
1277        try:
1278            sel = self._root_widget.get_selected_child()
1279            if sel == self._back_button:
1280                sel_name = 'Back'
1281            elif sel == self._store_button_widget:
1282                sel_name = 'Store'
1283            elif sel == self._league_rank_button_widget:
1284                sel_name = 'PowerRanking'
1285            elif sel == self._scrollwidget:
1286                sel_name = 'Scroll'
1287            else:
1288                raise ValueError('unrecognized selection')
1289            assert bui.app.classic is not None
1290            bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
1291        except Exception:
1292            logging.exception('Error saving state for %s.', self)
1293
1294        cfg['Selected Coop Row'] = self._selected_row
1295        cfg['Selected Coop Custom Level'] = self._selected_custom_level
1296        cfg['Selected Coop Campaign Level'] = self._selected_campaign_level
1297        cfg.commit()
1298
1299    def _restore_state(self) -> None:
1300        try:
1301            assert bui.app.classic is not None
1302            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
1303                'sel_name'
1304            )
1305            if sel_name == 'Back':
1306                sel = self._back_button
1307            elif sel_name == 'Scroll':
1308                sel = self._scrollwidget
1309            elif sel_name == 'PowerRanking':
1310                sel = self._league_rank_button_widget
1311            elif sel_name == 'Store':
1312                sel = self._store_button_widget
1313            else:
1314                sel = self._scrollwidget
1315            bui.containerwidget(edit=self._root_widget, selected_child=sel)
1316        except Exception:
1317            logging.exception('Error restoring state for %s.', self)
1318
1319    def sel_change(self, row: str, game: str) -> None:
1320        """(internal)"""
1321        if self._do_selection_callbacks:
1322            if row == 'custom':
1323                self._selected_custom_level = game
1324            elif row == 'campaign':
1325                self._selected_campaign_level = game

Window for browsing co-op levels/games/etc.

CoopBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 28    def __init__(
 29        self,
 30        transition: str | None = 'in_right',
 31        origin_widget: bui.Widget | None = None,
 32    ):
 33        # pylint: disable=too-many-statements
 34        # pylint: disable=cyclic-import
 35
 36        plus = bui.app.plus
 37        assert plus is not None
 38
 39        # Preload some modules we use in a background thread so we won't
 40        # have a visual hitch when the user taps them.
 41        Thread(target=self._preload_modules).start()
 42
 43        bui.set_analytics_screen('Coop Window')
 44
 45        app = bui.app
 46        assert app.classic is not None
 47        cfg = app.config
 48
 49        # Quick note to players that tourneys won't work in ballistica
 50        # core builds. (need to split the word so it won't get subbed out)
 51        if 'ballistica' + 'kit' == bui.appname():
 52            bui.apptimer(
 53                1.0,
 54                lambda: bui.screenmessage(
 55                    bui.Lstr(resource='noTournamentsInTestBuildText'),
 56                    color=(1, 1, 0),
 57                ),
 58            )
 59
 60        # If they provided an origin-widget, scale up from that.
 61        scale_origin: tuple[float, float] | None
 62        if origin_widget is not None:
 63            self._transition_out = 'out_scale'
 64            scale_origin = origin_widget.get_screen_space_center()
 65            transition = 'in_scale'
 66        else:
 67            self._transition_out = 'out_right'
 68            scale_origin = None
 69
 70        # Try to recreate the same number of buttons we had last time so our
 71        # re-selection code works.
 72        self._tournament_button_count = app.config.get('Tournament Rows', 0)
 73        assert isinstance(self._tournament_button_count, int)
 74
 75        self.star_tex = bui.gettexture('star')
 76        self.lsbt = bui.getmesh('level_select_button_transparent')
 77        self.lsbo = bui.getmesh('level_select_button_opaque')
 78        self.a_outline_tex = bui.gettexture('achievementOutline')
 79        self.a_outline_mesh = bui.getmesh('achievementOutline')
 80        self._campaign_sub_container: bui.Widget | None = None
 81        self._tournament_info_button: bui.Widget | None = None
 82        self._easy_button: bui.Widget | None = None
 83        self._hard_button: bui.Widget | None = None
 84        self._hard_button_lock_image: bui.Widget | None = None
 85        self._campaign_percent_text: bui.Widget | None = None
 86
 87        assert bui.app.classic is not None
 88        uiscale = bui.app.ui_v1.uiscale
 89        self._width = 1520 if uiscale is bui.UIScale.SMALL else 1120
 90        self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0
 91        self._height = (
 92            657
 93            if uiscale is bui.UIScale.SMALL
 94            else 730 if uiscale is bui.UIScale.MEDIUM else 800
 95        )
 96        app.ui_v1.set_main_menu_location('Coop Select')
 97        self._r = 'coopSelectWindow'
 98        top_extra = 20 if uiscale is bui.UIScale.SMALL else 0
 99
100        self._tourney_data_up_to_date = False
101
102        self._campaign_difficulty = plus.get_v1_account_misc_val(
103            'campaignDifficulty', 'easy'
104        )
105
106        if (
107            self._campaign_difficulty == 'hard'
108            and not app.classic.accounts.have_pro_options()
109        ):
110            plus.add_v1_account_transaction(
111                {
112                    'type': 'SET_MISC_VAL',
113                    'name': 'campaignDifficulty',
114                    'value': 'easy',
115                }
116            )
117            self._campaign_difficulty = 'easy'
118
119        super().__init__(
120            root_widget=bui.containerwidget(
121                size=(self._width, self._height + top_extra),
122                toolbar_visibility='menu_full',
123                scale_origin_stack_offset=scale_origin,
124                stack_offset=(
125                    (0, -15)
126                    if uiscale is bui.UIScale.SMALL
127                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
128                ),
129                transition=transition,
130                scale=(
131                    1.2
132                    if uiscale is bui.UIScale.SMALL
133                    else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75
134                ),
135            )
136        )
137
138        if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL:
139            self._back_button = None
140        else:
141            self._back_button = bui.buttonwidget(
142                parent=self._root_widget,
143                position=(
144                    75 + x_inset,
145                    self._height
146                    - 87
147                    - (4 if uiscale is bui.UIScale.SMALL else 0),
148                ),
149                size=(120, 60),
150                scale=1.2,
151                autoselect=True,
152                label=bui.Lstr(resource='backText'),
153                button_type='back',
154            )
155
156        self._league_rank_button: LeagueRankButton | None
157        self._store_button: StoreButton | None
158        self._store_button_widget: bui.Widget | None
159        self._league_rank_button_widget: bui.Widget | None
160
161        if not app.ui_v1.use_toolbars:
162            prb = self._league_rank_button = LeagueRankButton(
163                parent=self._root_widget,
164                position=(
165                    self._width - (282 + x_inset),
166                    self._height
167                    - 85
168                    - (4 if uiscale is bui.UIScale.SMALL else 0),
169                ),
170                size=(100, 60),
171                color=(0.4, 0.4, 0.9),
172                textcolor=(0.9, 0.9, 2.0),
173                scale=1.05,
174                on_activate_call=bui.WeakCall(self._switch_to_league_rankings),
175            )
176            self._league_rank_button_widget = prb.get_button()
177
178            sbtn = self._store_button = StoreButton(
179                parent=self._root_widget,
180                position=(
181                    self._width - (170 + x_inset),
182                    self._height
183                    - 85
184                    - (4 if uiscale is bui.UIScale.SMALL else 0),
185                ),
186                size=(100, 60),
187                color=(0.6, 0.4, 0.7),
188                show_tickets=True,
189                button_type='square',
190                sale_scale=0.85,
191                textcolor=(0.9, 0.7, 1.0),
192                scale=1.05,
193                on_activate_call=bui.WeakCall(self._switch_to_score, None),
194            )
195            self._store_button_widget = sbtn.get_button()
196            bui.widget(
197                edit=self._back_button,
198                right_widget=self._league_rank_button_widget,
199            )
200            bui.widget(
201                edit=self._league_rank_button_widget,
202                left_widget=self._back_button,
203            )
204        else:
205            self._league_rank_button = None
206            self._store_button = None
207            self._store_button_widget = None
208            self._league_rank_button_widget = None
209
210        # Move our corner buttons dynamically to keep them out of the way of
211        # the party icon :-(
212        self._update_corner_button_positions()
213        self._update_corner_button_positions_timer = bui.AppTimer(
214            1.0, bui.WeakCall(self._update_corner_button_positions), repeat=True
215        )
216
217        self._last_tournament_query_time: float | None = None
218        self._last_tournament_query_response_time: float | None = None
219        self._doing_tournament_query = False
220
221        self._selected_campaign_level = cfg.get(
222            'Selected Coop Campaign Level', None
223        )
224        self._selected_custom_level = cfg.get(
225            'Selected Coop Custom Level', None
226        )
227
228        # Don't want initial construction affecting our last-selected.
229        self._do_selection_callbacks = False
230        v = self._height - 95
231        txt = bui.textwidget(
232            parent=self._root_widget,
233            position=(
234                self._width * 0.5,
235                v + 40 - (0 if uiscale is bui.UIScale.SMALL else 0),
236            ),
237            size=(0, 0),
238            text=bui.Lstr(
239                resource='playModes.singlePlayerCoopText',
240                fallback_resource='playModes.coopText',
241            ),
242            h_align='center',
243            color=app.ui_v1.title_color,
244            scale=1.5,
245            maxwidth=500,
246            v_align='center',
247        )
248
249        if app.ui_v1.use_toolbars and uiscale is bui.UIScale.SMALL:
250            bui.textwidget(edit=txt, text='')
251
252        if self._back_button is not None:
253            bui.buttonwidget(
254                edit=self._back_button,
255                button_type='backSmall',
256                size=(60, 50),
257                position=(
258                    75 + x_inset,
259                    self._height
260                    - 87
261                    - (4 if uiscale is bui.UIScale.SMALL else 0)
262                    + 6,
263                ),
264                label=bui.charstr(bui.SpecialChar.BACK),
265            )
266
267        self._selected_row = cfg.get('Selected Coop Row', None)
268
269        self._scroll_width = self._width - (130 + 2 * x_inset)
270        self._scroll_height = self._height - (
271            190
272            if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars
273            else 160
274        )
275
276        self._subcontainerwidth = 800.0
277        self._subcontainerheight = 1400.0
278
279        self._scrollwidget = bui.scrollwidget(
280            parent=self._root_widget,
281            highlight=False,
282            position=(
283                (65 + x_inset, 120)
284                if uiscale is bui.UIScale.SMALL and app.ui_v1.use_toolbars
285                else (65 + x_inset, 70)
286            ),
287            size=(self._scroll_width, self._scroll_height),
288            simple_culling_v=10.0,
289            claims_left_right=True,
290            claims_tab=True,
291            selection_loops_to_parent=True,
292        )
293        self._subcontainer: bui.Widget | None = None
294
295        # Take note of our account state; we'll refresh later if this changes.
296        self._account_state_num = plus.get_v1_account_state_num()
297
298        # Same for fg/bg state.
299        self._fg_state = app.fg_state
300
301        self._refresh()
302        self._restore_state()
303
304        # Even though we might display cached tournament data immediately, we
305        # don't consider it valid until we've pinged.
306        # the server for an update
307        self._tourney_data_up_to_date = False
308
309        # If we've got a cached tournament list for our account and info for
310        # each one of those tournaments, go ahead and display it as a
311        # starting point.
312        if (
313            app.classic.accounts.account_tournament_list is not None
314            and app.classic.accounts.account_tournament_list[0]
315            == plus.get_v1_account_state_num()
316            and all(
317                t_id in app.classic.accounts.tournament_info
318                for t_id in app.classic.accounts.account_tournament_list[1]
319            )
320        ):
321            tourney_data = [
322                app.classic.accounts.tournament_info[t_id]
323                for t_id in app.classic.accounts.account_tournament_list[1]
324            ]
325            self._update_for_data(tourney_data)
326
327        # This will pull new data periodically, update timers, etc.
328        self._update_timer = bui.AppTimer(
329            1.0, bui.WeakCall(self._update), repeat=True
330        )
331        self._update()
star_tex
lsbt
lsbo
a_outline_tex
a_outline_mesh
def is_tourney_data_up_to_date(self) -> bool:
1103    def is_tourney_data_up_to_date(self) -> bool:
1104        """Return whether our tourney data is up to date."""
1105        return self._tourney_data_up_to_date

Return whether our tourney data is up to date.

def run_game(self, game: str) -> None:
1107    def run_game(self, game: str) -> None:
1108        """Run the provided game."""
1109        # pylint: disable=too-many-branches
1110        # pylint: disable=cyclic-import
1111        from bauiv1lib.confirm import ConfirmWindow
1112        from bauiv1lib.purchase import PurchaseWindow
1113        from bauiv1lib.account import show_sign_in_prompt
1114
1115        plus = bui.app.plus
1116        assert plus is not None
1117
1118        assert bui.app.classic is not None
1119
1120        args: dict[str, Any] = {}
1121
1122        if game == 'Easy:The Last Stand':
1123            ConfirmWindow(
1124                bui.Lstr(
1125                    resource='difficultyHardUnlockOnlyText',
1126                    fallback_resource='difficultyHardOnlyText',
1127                ),
1128                cancel_button=False,
1129                width=460,
1130                height=130,
1131            )
1132            return
1133
1134        # Infinite onslaught/runaround require pro; bring up a store link
1135        # if need be.
1136        if (
1137            game
1138            in (
1139                'Challenges:Infinite Runaround',
1140                'Challenges:Infinite Onslaught',
1141            )
1142            and not bui.app.classic.accounts.have_pro()
1143        ):
1144            if plus.get_v1_account_state() != 'signed_in':
1145                show_sign_in_prompt()
1146            else:
1147                PurchaseWindow(items=['pro'])
1148            return
1149
1150        required_purchase: str | None
1151        if game in ['Challenges:Meteor Shower']:
1152            required_purchase = 'games.meteor_shower'
1153        elif game in [
1154            'Challenges:Target Practice',
1155            'Challenges:Target Practice B',
1156        ]:
1157            required_purchase = 'games.target_practice'
1158        elif game in ['Challenges:Ninja Fight']:
1159            required_purchase = 'games.ninja_fight'
1160        elif game in ['Challenges:Pro Ninja Fight']:
1161            required_purchase = 'games.ninja_fight'
1162        elif game in [
1163            'Challenges:Easter Egg Hunt',
1164            'Challenges:Pro Easter Egg Hunt',
1165        ]:
1166            required_purchase = 'games.easter_egg_hunt'
1167        else:
1168            required_purchase = None
1169
1170        if required_purchase is not None and not plus.get_purchased(
1171            required_purchase
1172        ):
1173            if plus.get_v1_account_state() != 'signed_in':
1174                show_sign_in_prompt()
1175            else:
1176                PurchaseWindow(items=[required_purchase])
1177            return
1178
1179        self._save_state()
1180
1181        if bui.app.classic.launch_coop_game(game, args=args):
1182            bui.containerwidget(edit=self._root_widget, transition='out_left')

Run the provided game.

def run_tournament( self, tournament_button: bauiv1lib.coop.tournamentbutton.TournamentButton) -> None:
1184    def run_tournament(self, tournament_button: TournamentButton) -> None:
1185        """Run the provided tournament game."""
1186        from bauiv1lib.account import show_sign_in_prompt
1187        from bauiv1lib.tournamententry import TournamentEntryWindow
1188
1189        plus = bui.app.plus
1190        assert plus is not None
1191
1192        if plus.get_v1_account_state() != 'signed_in':
1193            show_sign_in_prompt()
1194            return
1195
1196        if bui.workspaces_in_use():
1197            bui.screenmessage(
1198                bui.Lstr(resource='tournamentsDisabledWorkspaceText'),
1199                color=(1, 0, 0),
1200            )
1201            bui.getsound('error').play()
1202            return
1203
1204        if not self._tourney_data_up_to_date:
1205            bui.screenmessage(
1206                bui.Lstr(resource='tournamentCheckingStateText'),
1207                color=(1, 1, 0),
1208            )
1209            bui.getsound('error').play()
1210            return
1211
1212        if tournament_button.tournament_id is None:
1213            bui.screenmessage(
1214                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1215                color=(1, 0, 0),
1216            )
1217            bui.getsound('error').play()
1218            return
1219
1220        if tournament_button.required_league is not None:
1221            bui.screenmessage(
1222                bui.Lstr(
1223                    resource='league.tournamentLeagueText',
1224                    subs=[
1225                        (
1226                            '${NAME}',
1227                            bui.Lstr(
1228                                translate=(
1229                                    'leagueNames',
1230                                    tournament_button.required_league,
1231                                )
1232                            ),
1233                        )
1234                    ],
1235                ),
1236                color=(1, 0, 0),
1237            )
1238            bui.getsound('error').play()
1239            return
1240
1241        if tournament_button.time_remaining <= 0:
1242            bui.screenmessage(
1243                bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1244            )
1245            bui.getsound('error').play()
1246            return
1247
1248        self._save_state()
1249
1250        assert tournament_button.tournament_id is not None
1251        TournamentEntryWindow(
1252            tournament_id=tournament_button.tournament_id,
1253            position=tournament_button.button.get_screen_space_center(),
1254        )

Run the provided tournament game.

Inherited Members
bauiv1._uitypes.Window
get_root_widget