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

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

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

Return whether our tourney data is up to date.

def run_game(self, game: str) -> None:
875    def run_game(self, game: str) -> None:
876        """Run the provided game."""
877        # pylint: disable=too-many-branches
878        # pylint: disable=cyclic-import
879        from bastd.ui.confirm import ConfirmWindow
880        from bastd.ui.purchase import PurchaseWindow
881        from bastd.ui.account import show_sign_in_prompt
882        args: dict[str, Any] = {}
883
884        if game == 'Easy:The Last Stand':
885            ConfirmWindow(ba.Lstr(resource='difficultyHardUnlockOnlyText',
886                                  fallback_resource='difficultyHardOnlyText'),
887                          cancel_button=False,
888                          width=460,
889                          height=130)
890            return
891
892        # Infinite onslaught/runaround require pro; bring up a store link
893        # if need be.
894        if game in ('Challenges:Infinite Runaround',
895                    'Challenges:Infinite Onslaught'
896                    ) and not ba.app.accounts_v1.have_pro():
897            if _ba.get_v1_account_state() != 'signed_in':
898                show_sign_in_prompt()
899            else:
900                PurchaseWindow(items=['pro'])
901            return
902
903        required_purchase: str | None
904        if game in ['Challenges:Meteor Shower']:
905            required_purchase = 'games.meteor_shower'
906        elif game in [
907                'Challenges:Target Practice',
908                'Challenges:Target Practice B',
909        ]:
910            required_purchase = 'games.target_practice'
911        elif game in ['Challenges:Ninja Fight']:
912            required_purchase = 'games.ninja_fight'
913        elif game in ['Challenges:Pro Ninja Fight']:
914            required_purchase = 'games.ninja_fight'
915        elif game in [
916                'Challenges:Easter Egg Hunt',
917                'Challenges:Pro Easter Egg Hunt',
918        ]:
919            required_purchase = 'games.easter_egg_hunt'
920        else:
921            required_purchase = None
922
923        if (required_purchase is not None
924                and not _ba.get_purchased(required_purchase)):
925            if _ba.get_v1_account_state() != 'signed_in':
926                show_sign_in_prompt()
927            else:
928                PurchaseWindow(items=[required_purchase])
929            return
930
931        self._save_state()
932
933        if ba.app.launch_coop_game(game, args=args):
934            ba.containerwidget(edit=self._root_widget, transition='out_left')

Run the provided game.

def run_tournament( self, tournament_button: bastd.ui.coop.tournamentbutton.TournamentButton) -> None:
936    def run_tournament(self, tournament_button: TournamentButton) -> None:
937        """Run the provided tournament game."""
938        from bastd.ui.account import show_sign_in_prompt
939        from bastd.ui.tournamententry import TournamentEntryWindow
940
941        if _ba.get_v1_account_state() != 'signed_in':
942            show_sign_in_prompt()
943            return
944
945        if _ba.workspaces_in_use():
946            ba.screenmessage(
947                ba.Lstr(resource='tournamentsDisabledWorkspaceText'),
948                color=(1, 0, 0))
949            ba.playsound(ba.getsound('error'))
950            return
951
952        if not self._tourney_data_up_to_date:
953            ba.screenmessage(ba.Lstr(resource='tournamentCheckingStateText'),
954                             color=(1, 1, 0))
955            ba.playsound(ba.getsound('error'))
956            return
957
958        if tournament_button.tournament_id is None:
959            ba.screenmessage(
960                ba.Lstr(resource='internal.unavailableNoConnectionText'),
961                color=(1, 0, 0))
962            ba.playsound(ba.getsound('error'))
963            return
964
965        if tournament_button.required_league is not None:
966            ba.screenmessage(
967                ba.Lstr(
968                    resource='league.tournamentLeagueText',
969                    subs=[('${NAME}',
970                           ba.Lstr(
971                               translate=('leagueNames',
972                                          tournament_button.required_league)))
973                          ]),
974                color=(1, 0, 0),
975            )
976            ba.playsound(ba.getsound('error'))
977            return
978
979        if tournament_button.time_remaining <= 0:
980            ba.screenmessage(ba.Lstr(resource='tournamentEndedText'),
981                             color=(1, 0, 0))
982            ba.playsound(ba.getsound('error'))
983            return
984
985        self._save_state()
986
987        assert tournament_button.tournament_id is not None
988        TournamentEntryWindow(
989            tournament_id=tournament_button.tournament_id,
990            position=tournament_button.button.get_screen_space_center())

Run the provided tournament game.

Inherited Members
ba.ui.Window
get_root_widget