bastd.ui.mainmenu

Implements the main menu window.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Implements the main menu window."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8from typing import TYPE_CHECKING
   9
  10import ba
  11import ba.internal
  12
  13if TYPE_CHECKING:
  14    from typing import Any, Callable
  15
  16
  17class MainMenuWindow(ba.Window):
  18    """The main menu window, both in-game and in the main menu session."""
  19
  20    def __init__(self, transition: str | None = 'in_right'):
  21        # pylint: disable=cyclic-import
  22        import threading
  23        from bastd.mainmenu import MainMenuSession
  24
  25        self._in_game = not isinstance(
  26            ba.internal.get_foreground_host_session(), MainMenuSession
  27        )
  28
  29        # Preload some modules we use in a background thread so we won't
  30        # have a visual hitch when the user taps them.
  31        threading.Thread(target=self._preload_modules).start()
  32
  33        if not self._in_game:
  34            ba.set_analytics_screen('Main Menu')
  35            self._show_remote_app_info_on_first_launch()
  36
  37        # Make a vanilla container; we'll modify it to our needs in refresh.
  38        super().__init__(
  39            root_widget=ba.containerwidget(
  40                transition=transition,
  41                toolbar_visibility='menu_minimal_no_back'
  42                if self._in_game
  43                else 'menu_minimal_no_back',
  44            )
  45        )
  46
  47        # Grab this stuff in case it changes.
  48        self._is_demo = ba.app.demo_mode
  49        self._is_arcade = ba.app.arcade_mode
  50        self._is_iircade = ba.app.iircade_mode
  51
  52        self._tdelay = 0.0
  53        self._t_delay_inc = 0.02
  54        self._t_delay_play = 1.7
  55        self._p_index = 0
  56        self._use_autoselect = True
  57        self._button_width = 200.0
  58        self._button_height = 45.0
  59        self._width = 100.0
  60        self._height = 100.0
  61        self._demo_menu_button: ba.Widget | None = None
  62        self._gather_button: ba.Widget | None = None
  63        self._start_button: ba.Widget | None = None
  64        self._watch_button: ba.Widget | None = None
  65        self._account_button: ba.Widget | None = None
  66        self._how_to_play_button: ba.Widget | None = None
  67        self._credits_button: ba.Widget | None = None
  68        self._settings_button: ba.Widget | None = None
  69        self._next_refresh_allow_time = 0.0
  70
  71        self._store_char_tex = self._get_store_char_tex()
  72
  73        self._refresh()
  74        self._restore_state()
  75
  76        # Keep an eye on a few things and refresh if they change.
  77        self._account_state = ba.internal.get_v1_account_state()
  78        self._account_state_num = ba.internal.get_v1_account_state_num()
  79        self._account_type = (
  80            ba.internal.get_v1_account_type()
  81            if self._account_state == 'signed_in'
  82            else None
  83        )
  84        self._refresh_timer = ba.Timer(
  85            0.27,
  86            ba.WeakCall(self._check_refresh),
  87            repeat=True,
  88            timetype=ba.TimeType.REAL,
  89        )
  90
  91    # noinspection PyUnresolvedReferences
  92    @staticmethod
  93    def _preload_modules() -> None:
  94        """Preload modules we use (called in bg thread)."""
  95        import bastd.ui.getremote as _unused
  96        import bastd.ui.confirm as _unused2
  97        import bastd.ui.store.button as _unused3
  98        import bastd.ui.kiosk as _unused4
  99        import bastd.ui.account.settings as _unused5
 100        import bastd.ui.store.browser as _unused6
 101        import bastd.ui.creditslist as _unused7
 102        import bastd.ui.helpui as _unused8
 103        import bastd.ui.settings.allsettings as _unused9
 104        import bastd.ui.gather as _unused10
 105        import bastd.ui.watch as _unused11
 106        import bastd.ui.play as _unused12
 107
 108    def _show_remote_app_info_on_first_launch(self) -> None:
 109        # The first time the non-in-game menu pops up, we might wanna show
 110        # a 'get-remote-app' dialog in front of it.
 111        if ba.app.first_main_menu:
 112            ba.app.first_main_menu = False
 113            try:
 114                app = ba.app
 115                force_test = False
 116                ba.internal.get_local_active_input_devices_count()
 117                if (
 118                    (app.on_tv or app.platform == 'mac')
 119                    and ba.app.config.get('launchCount', 0) <= 1
 120                ) or force_test:
 121
 122                    def _check_show_bs_remote_window() -> None:
 123                        try:
 124                            from bastd.ui.getremote import GetBSRemoteWindow
 125
 126                            ba.playsound(ba.getsound('swish'))
 127                            GetBSRemoteWindow()
 128                        except Exception:
 129                            ba.print_exception(
 130                                'Error showing get-remote window.'
 131                            )
 132
 133                    ba.timer(
 134                        2.5,
 135                        _check_show_bs_remote_window,
 136                        timetype=ba.TimeType.REAL,
 137                    )
 138            except Exception:
 139                ba.print_exception('Error showing get-remote-app info')
 140
 141    def _get_store_char_tex(self) -> str:
 142        return (
 143            'storeCharacterXmas'
 144            if ba.internal.get_v1_account_misc_read_val('xmas', False)
 145            else 'storeCharacterEaster'
 146            if ba.internal.get_v1_account_misc_read_val('easter', False)
 147            else 'storeCharacter'
 148        )
 149
 150    def _check_refresh(self) -> None:
 151        if not self._root_widget:
 152            return
 153
 154        now = ba.time(ba.TimeType.REAL)
 155        if now < self._next_refresh_allow_time:
 156            return
 157
 158        # Don't refresh for the first few seconds the game is up so we don't
 159        # interrupt the transition in.
 160        # ba.app.main_menu_window_refresh_check_count += 1
 161        # if ba.app.main_menu_window_refresh_check_count < 4:
 162        #     return
 163
 164        store_char_tex = self._get_store_char_tex()
 165        account_state_num = ba.internal.get_v1_account_state_num()
 166        if (
 167            account_state_num != self._account_state_num
 168            or store_char_tex != self._store_char_tex
 169        ):
 170            self._store_char_tex = store_char_tex
 171            self._account_state_num = account_state_num
 172            account_state = (
 173                self._account_state
 174            ) = ba.internal.get_v1_account_state()
 175            self._account_type = (
 176                ba.internal.get_v1_account_type()
 177                if account_state == 'signed_in'
 178                else None
 179            )
 180            self._save_state()
 181            self._refresh()
 182            self._restore_state()
 183
 184    def get_play_button(self) -> ba.Widget | None:
 185        """Return the play button."""
 186        return self._start_button
 187
 188    def _refresh(self) -> None:
 189        # pylint: disable=too-many-branches
 190        # pylint: disable=too-many-locals
 191        # pylint: disable=too-many-statements
 192        from bastd.ui.confirm import QuitWindow
 193        from bastd.ui.store.button import StoreButton
 194
 195        # Clear everything that was there.
 196        children = self._root_widget.get_children()
 197        for child in children:
 198            child.delete()
 199
 200        self._tdelay = 0.0
 201        self._t_delay_inc = 0.0
 202        self._t_delay_play = 0.0
 203        self._button_width = 200.0
 204        self._button_height = 45.0
 205
 206        self._r = 'mainMenu'
 207
 208        app = ba.app
 209        self._have_quit_button = app.ui.uiscale is ba.UIScale.LARGE or (
 210            app.platform == 'windows' and app.subplatform == 'oculus'
 211        )
 212
 213        self._have_store_button = not self._in_game
 214
 215        self._have_settings_button = (
 216            not self._in_game or not app.toolbar_test
 217        ) and not (self._is_demo or self._is_arcade or self._is_iircade)
 218
 219        self._input_device = input_device = ba.internal.get_ui_input_device()
 220        self._input_player = input_device.player if input_device else None
 221        self._connected_to_remote_player = (
 222            input_device.is_connected_to_remote_player()
 223            if input_device
 224            else False
 225        )
 226
 227        positions: list[tuple[float, float, float]] = []
 228        self._p_index = 0
 229
 230        if self._in_game:
 231            h, v, scale = self._refresh_in_game(positions)
 232        else:
 233            h, v, scale = self._refresh_not_in_game(positions)
 234
 235        if self._have_settings_button:
 236            h, v, scale = positions[self._p_index]
 237            self._p_index += 1
 238            self._settings_button = ba.buttonwidget(
 239                parent=self._root_widget,
 240                position=(h - self._button_width * 0.5 * scale, v),
 241                size=(self._button_width, self._button_height),
 242                scale=scale,
 243                autoselect=self._use_autoselect,
 244                label=ba.Lstr(resource=self._r + '.settingsText'),
 245                transition_delay=self._tdelay,
 246                on_activate_call=self._settings,
 247            )
 248
 249        # Scattered eggs on easter.
 250        if (
 251            ba.internal.get_v1_account_misc_read_val('easter', False)
 252            and not self._in_game
 253        ):
 254            icon_size = 34
 255            ba.imagewidget(
 256                parent=self._root_widget,
 257                position=(
 258                    h - icon_size * 0.5 - 15,
 259                    v + self._button_height * scale - icon_size * 0.24 + 1.5,
 260                ),
 261                transition_delay=self._tdelay,
 262                size=(icon_size, icon_size),
 263                texture=ba.gettexture('egg3'),
 264                tilt_scale=0.0,
 265            )
 266
 267        self._tdelay += self._t_delay_inc
 268
 269        if self._in_game:
 270            h, v, scale = positions[self._p_index]
 271            self._p_index += 1
 272
 273            # If we're in a replay, we have a 'Leave Replay' button.
 274            if ba.internal.is_in_replay():
 275                ba.buttonwidget(
 276                    parent=self._root_widget,
 277                    position=(h - self._button_width * 0.5 * scale, v),
 278                    scale=scale,
 279                    size=(self._button_width, self._button_height),
 280                    autoselect=self._use_autoselect,
 281                    label=ba.Lstr(resource='replayEndText'),
 282                    on_activate_call=self._confirm_end_replay,
 283                )
 284            elif ba.internal.get_foreground_host_session() is not None:
 285                ba.buttonwidget(
 286                    parent=self._root_widget,
 287                    position=(h - self._button_width * 0.5 * scale, v),
 288                    scale=scale,
 289                    size=(self._button_width, self._button_height),
 290                    autoselect=self._use_autoselect,
 291                    label=ba.Lstr(
 292                        resource=self._r
 293                        + (
 294                            '.endTestText'
 295                            if self._is_benchmark()
 296                            else '.endGameText'
 297                        )
 298                    ),
 299                    on_activate_call=(
 300                        self._confirm_end_test
 301                        if self._is_benchmark()
 302                        else self._confirm_end_game
 303                    ),
 304                )
 305            # Assume we're in a client-session.
 306            else:
 307                ba.buttonwidget(
 308                    parent=self._root_widget,
 309                    position=(h - self._button_width * 0.5 * scale, v),
 310                    scale=scale,
 311                    size=(self._button_width, self._button_height),
 312                    autoselect=self._use_autoselect,
 313                    label=ba.Lstr(resource=self._r + '.leavePartyText'),
 314                    on_activate_call=self._confirm_leave_party,
 315                )
 316
 317        self._store_button: ba.Widget | None
 318        if self._have_store_button:
 319            this_b_width = self._button_width
 320            h, v, scale = positions[self._p_index]
 321            self._p_index += 1
 322
 323            sbtn = self._store_button_instance = StoreButton(
 324                parent=self._root_widget,
 325                position=(h - this_b_width * 0.5 * scale, v),
 326                size=(this_b_width, self._button_height),
 327                scale=scale,
 328                on_activate_call=ba.WeakCall(self._on_store_pressed),
 329                sale_scale=1.3,
 330                transition_delay=self._tdelay,
 331            )
 332            self._store_button = store_button = sbtn.get_button()
 333            uiscale = ba.app.ui.uiscale
 334            icon_size = (
 335                55
 336                if uiscale is ba.UIScale.SMALL
 337                else 55
 338                if uiscale is ba.UIScale.MEDIUM
 339                else 70
 340            )
 341            ba.imagewidget(
 342                parent=self._root_widget,
 343                position=(
 344                    h - icon_size * 0.5,
 345                    v + self._button_height * scale - icon_size * 0.23,
 346                ),
 347                transition_delay=self._tdelay,
 348                size=(icon_size, icon_size),
 349                texture=ba.gettexture(self._store_char_tex),
 350                tilt_scale=0.0,
 351                draw_controller=store_button,
 352            )
 353
 354            self._tdelay += self._t_delay_inc
 355        else:
 356            self._store_button = None
 357
 358        self._quit_button: ba.Widget | None
 359        if not self._in_game and self._have_quit_button:
 360            h, v, scale = positions[self._p_index]
 361            self._p_index += 1
 362            self._quit_button = quit_button = ba.buttonwidget(
 363                parent=self._root_widget,
 364                autoselect=self._use_autoselect,
 365                position=(h - self._button_width * 0.5 * scale, v),
 366                size=(self._button_width, self._button_height),
 367                scale=scale,
 368                label=ba.Lstr(
 369                    resource=self._r
 370                    + (
 371                        '.quitText'
 372                        if 'Mac' in ba.app.user_agent_string
 373                        else '.exitGameText'
 374                    )
 375                ),
 376                on_activate_call=self._quit,
 377                transition_delay=self._tdelay,
 378            )
 379
 380            # Scattered eggs on easter.
 381            if ba.internal.get_v1_account_misc_read_val('easter', False):
 382                icon_size = 30
 383                ba.imagewidget(
 384                    parent=self._root_widget,
 385                    position=(
 386                        h - icon_size * 0.5 + 25,
 387                        v
 388                        + self._button_height * scale
 389                        - icon_size * 0.24
 390                        + 1.5,
 391                    ),
 392                    transition_delay=self._tdelay,
 393                    size=(icon_size, icon_size),
 394                    texture=ba.gettexture('egg1'),
 395                    tilt_scale=0.0,
 396                )
 397
 398            ba.containerwidget(
 399                edit=self._root_widget, cancel_button=quit_button
 400            )
 401            self._tdelay += self._t_delay_inc
 402        else:
 403            self._quit_button = None
 404
 405            # If we're not in-game, have no quit button, and this is android,
 406            # we want back presses to quit our activity.
 407            if (
 408                not self._in_game
 409                and not self._have_quit_button
 410                and ba.app.platform == 'android'
 411            ):
 412
 413                def _do_quit() -> None:
 414                    QuitWindow(swish=True, back=True)
 415
 416                ba.containerwidget(
 417                    edit=self._root_widget, on_cancel_call=_do_quit
 418                )
 419
 420        # Add speed-up/slow-down buttons for replays.
 421        # (ideally this should be part of a fading-out playback bar like most
 422        # media players but this works for now).
 423        if ba.internal.is_in_replay():
 424            b_size = 50.0
 425            b_buffer = 10.0
 426            t_scale = 0.75
 427            uiscale = ba.app.ui.uiscale
 428            if uiscale is ba.UIScale.SMALL:
 429                b_size *= 0.6
 430                b_buffer *= 1.0
 431                v_offs = -40
 432                t_scale = 0.5
 433            elif uiscale is ba.UIScale.MEDIUM:
 434                v_offs = -70
 435            else:
 436                v_offs = -100
 437            self._replay_speed_text = ba.textwidget(
 438                parent=self._root_widget,
 439                text=ba.Lstr(
 440                    resource='watchWindow.playbackSpeedText',
 441                    subs=[('${SPEED}', str(1.23))],
 442                ),
 443                position=(h, v + v_offs + 7 * t_scale),
 444                h_align='center',
 445                v_align='center',
 446                size=(0, 0),
 447                scale=t_scale,
 448            )
 449
 450            # Update to current value.
 451            self._change_replay_speed(0)
 452
 453            # Keep updating in a timer in case it gets changed elsewhere.
 454            self._change_replay_speed_timer = ba.Timer(
 455                0.25,
 456                ba.WeakCall(self._change_replay_speed, 0),
 457                timetype=ba.TimeType.REAL,
 458                repeat=True,
 459            )
 460            btn = ba.buttonwidget(
 461                parent=self._root_widget,
 462                position=(
 463                    h - b_size - b_buffer,
 464                    v - b_size - b_buffer + v_offs,
 465                ),
 466                button_type='square',
 467                size=(b_size, b_size),
 468                label='',
 469                autoselect=True,
 470                on_activate_call=ba.Call(self._change_replay_speed, -1),
 471            )
 472            ba.textwidget(
 473                parent=self._root_widget,
 474                draw_controller=btn,
 475                text='-',
 476                position=(
 477                    h - b_size * 0.5 - b_buffer,
 478                    v - b_size * 0.5 - b_buffer + 5 * t_scale + v_offs,
 479                ),
 480                h_align='center',
 481                v_align='center',
 482                size=(0, 0),
 483                scale=3.0 * t_scale,
 484            )
 485            btn = ba.buttonwidget(
 486                parent=self._root_widget,
 487                position=(h + b_buffer, v - b_size - b_buffer + v_offs),
 488                button_type='square',
 489                size=(b_size, b_size),
 490                label='',
 491                autoselect=True,
 492                on_activate_call=ba.Call(self._change_replay_speed, 1),
 493            )
 494            ba.textwidget(
 495                parent=self._root_widget,
 496                draw_controller=btn,
 497                text='+',
 498                position=(
 499                    h + b_size * 0.5 + b_buffer,
 500                    v - b_size * 0.5 - b_buffer + 5 * t_scale + v_offs,
 501                ),
 502                h_align='center',
 503                v_align='center',
 504                size=(0, 0),
 505                scale=3.0 * t_scale,
 506            )
 507
 508    def _refresh_not_in_game(
 509        self, positions: list[tuple[float, float, float]]
 510    ) -> tuple[float, float, float]:
 511        # pylint: disable=too-many-branches
 512        # pylint: disable=too-many-locals
 513        # pylint: disable=too-many-statements
 514        if not ba.app.did_menu_intro:
 515            self._tdelay = 2.0
 516            self._t_delay_inc = 0.02
 517            self._t_delay_play = 1.7
 518
 519            def _set_allow_time() -> None:
 520                self._next_refresh_allow_time = ba.time(ba.TimeType.REAL) + 2.5
 521
 522            # Slight hack: widget transitions currently only progress when
 523            # frames are being drawn, but this tends to get called before
 524            # frame drawing even starts, meaning we don't know exactly how
 525            # long we should wait before refreshing to avoid interrupting
 526            # the transition. To make things a bit better, let's do a
 527            # redundant set of the time in a deferred call which hopefully
 528            # happens closer to actual frame draw times.
 529            _set_allow_time()
 530            ba.pushcall(_set_allow_time)
 531
 532            ba.app.did_menu_intro = True
 533        self._width = 400.0
 534        self._height = 200.0
 535        enable_account_button = True
 536        account_type_name: str | ba.Lstr
 537        if ba.internal.get_v1_account_state() == 'signed_in':
 538            account_type_name = ba.internal.get_v1_account_display_string()
 539            account_type_icon = None
 540            account_textcolor = (1.0, 1.0, 1.0)
 541        else:
 542            account_type_name = ba.Lstr(
 543                resource='notSignedInText',
 544                fallback_resource='accountSettingsWindow.titleText',
 545            )
 546            account_type_icon = None
 547            account_textcolor = (1.0, 0.2, 0.2)
 548        account_type_icon_color = (1.0, 1.0, 1.0)
 549        account_type_call = self._show_account_window
 550        account_type_enable_button_sound = True
 551        b_count = 3  # play, help, credits
 552        if self._have_settings_button:
 553            b_count += 1
 554        if enable_account_button:
 555            b_count += 1
 556        if self._have_quit_button:
 557            b_count += 1
 558        if self._have_store_button:
 559            b_count += 1
 560        uiscale = ba.app.ui.uiscale
 561        if uiscale is ba.UIScale.SMALL:
 562            root_widget_scale = 1.6
 563            play_button_width = self._button_width * 0.65
 564            play_button_height = self._button_height * 1.1
 565            small_button_scale = 0.51 if b_count > 6 else 0.63
 566            button_y_offs = -20.0
 567            button_y_offs2 = -60.0
 568            self._button_height *= 1.3
 569            button_spacing = 1.04
 570        elif uiscale is ba.UIScale.MEDIUM:
 571            root_widget_scale = 1.3
 572            play_button_width = self._button_width * 0.65
 573            play_button_height = self._button_height * 1.1
 574            small_button_scale = 0.6
 575            button_y_offs = -55.0
 576            button_y_offs2 = -75.0
 577            self._button_height *= 1.25
 578            button_spacing = 1.1
 579        else:
 580            root_widget_scale = 1.0
 581            play_button_width = self._button_width * 0.65
 582            play_button_height = self._button_height * 1.1
 583            small_button_scale = 0.75
 584            button_y_offs = -80.0
 585            button_y_offs2 = -100.0
 586            self._button_height *= 1.2
 587            button_spacing = 1.1
 588        spc = self._button_width * small_button_scale * button_spacing
 589        ba.containerwidget(
 590            edit=self._root_widget,
 591            size=(self._width, self._height),
 592            background=False,
 593            scale=root_widget_scale,
 594        )
 595        assert not positions
 596        positions.append((self._width * 0.5, button_y_offs, 1.7))
 597        x_offs = self._width * 0.5 - (spc * (b_count - 1) * 0.5) + (spc * 0.5)
 598        for i in range(b_count - 1):
 599            positions.append(
 600                (
 601                    x_offs + spc * i - 1.0,
 602                    button_y_offs + button_y_offs2,
 603                    small_button_scale,
 604                )
 605            )
 606        # In kiosk mode, provide a button to get back to the kiosk menu.
 607        if ba.app.demo_mode or ba.app.arcade_mode:
 608            h, v, scale = positions[self._p_index]
 609            this_b_width = self._button_width * 0.4 * scale
 610            demo_menu_delay = (
 611                0.0
 612                if self._t_delay_play == 0.0
 613                else max(0, self._t_delay_play + 0.1)
 614            )
 615            self._demo_menu_button = ba.buttonwidget(
 616                parent=self._root_widget,
 617                position=(self._width * 0.5 - this_b_width * 0.5, v + 90),
 618                size=(this_b_width, 45),
 619                autoselect=True,
 620                color=(0.45, 0.55, 0.45),
 621                textcolor=(0.7, 0.8, 0.7),
 622                label=ba.Lstr(
 623                    resource='modeArcadeText'
 624                    if ba.app.arcade_mode
 625                    else 'modeDemoText'
 626                ),
 627                transition_delay=demo_menu_delay,
 628                on_activate_call=self._demo_menu_press,
 629            )
 630        else:
 631            self._demo_menu_button = None
 632        uiscale = ba.app.ui.uiscale
 633        foof = (
 634            -1
 635            if uiscale is ba.UIScale.SMALL
 636            else 1
 637            if uiscale is ba.UIScale.MEDIUM
 638            else 3
 639        )
 640        h, v, scale = positions[self._p_index]
 641        v = v + foof
 642        gather_delay = (
 643            0.0
 644            if self._t_delay_play == 0.0
 645            else max(0.0, self._t_delay_play + 0.1)
 646        )
 647        assert play_button_width is not None
 648        assert play_button_height is not None
 649        this_h = h - play_button_width * 0.5 * scale - 40 * scale
 650        this_b_width = self._button_width * 0.25 * scale
 651        this_b_height = self._button_height * 0.82 * scale
 652        self._gather_button = btn = ba.buttonwidget(
 653            parent=self._root_widget,
 654            position=(this_h - this_b_width * 0.5, v),
 655            size=(this_b_width, this_b_height),
 656            autoselect=self._use_autoselect,
 657            button_type='square',
 658            label='',
 659            transition_delay=gather_delay,
 660            on_activate_call=self._gather_press,
 661        )
 662        ba.textwidget(
 663            parent=self._root_widget,
 664            position=(this_h, v + self._button_height * 0.33),
 665            size=(0, 0),
 666            scale=0.75,
 667            transition_delay=gather_delay,
 668            draw_controller=btn,
 669            color=(0.75, 1.0, 0.7),
 670            maxwidth=self._button_width * 0.33,
 671            text=ba.Lstr(resource='gatherWindow.titleText'),
 672            h_align='center',
 673            v_align='center',
 674        )
 675        icon_size = this_b_width * 0.6
 676        ba.imagewidget(
 677            parent=self._root_widget,
 678            size=(icon_size, icon_size),
 679            draw_controller=btn,
 680            transition_delay=gather_delay,
 681            position=(this_h - 0.5 * icon_size, v + 0.31 * this_b_height),
 682            texture=ba.gettexture('usersButton'),
 683        )
 684
 685        # Play button.
 686        h, v, scale = positions[self._p_index]
 687        self._p_index += 1
 688        self._start_button = start_button = ba.buttonwidget(
 689            parent=self._root_widget,
 690            position=(h - play_button_width * 0.5 * scale, v),
 691            size=(play_button_width, play_button_height),
 692            autoselect=self._use_autoselect,
 693            scale=scale,
 694            text_res_scale=2.0,
 695            label=ba.Lstr(resource='playText'),
 696            transition_delay=self._t_delay_play,
 697            on_activate_call=self._play_press,
 698        )
 699        ba.containerwidget(
 700            edit=self._root_widget,
 701            start_button=start_button,
 702            selected_child=start_button,
 703        )
 704        v = v + foof
 705        watch_delay = (
 706            0.0
 707            if self._t_delay_play == 0.0
 708            else max(0.0, self._t_delay_play - 0.1)
 709        )
 710        this_h = h + play_button_width * 0.5 * scale + 40 * scale
 711        this_b_width = self._button_width * 0.25 * scale
 712        this_b_height = self._button_height * 0.82 * scale
 713        self._watch_button = btn = ba.buttonwidget(
 714            parent=self._root_widget,
 715            position=(this_h - this_b_width * 0.5, v),
 716            size=(this_b_width, this_b_height),
 717            autoselect=self._use_autoselect,
 718            button_type='square',
 719            label='',
 720            transition_delay=watch_delay,
 721            on_activate_call=self._watch_press,
 722        )
 723        ba.textwidget(
 724            parent=self._root_widget,
 725            position=(this_h, v + self._button_height * 0.33),
 726            size=(0, 0),
 727            scale=0.75,
 728            transition_delay=watch_delay,
 729            color=(0.75, 1.0, 0.7),
 730            draw_controller=btn,
 731            maxwidth=self._button_width * 0.33,
 732            text=ba.Lstr(resource='watchWindow.titleText'),
 733            h_align='center',
 734            v_align='center',
 735        )
 736        icon_size = this_b_width * 0.55
 737        ba.imagewidget(
 738            parent=self._root_widget,
 739            size=(icon_size, icon_size),
 740            draw_controller=btn,
 741            transition_delay=watch_delay,
 742            position=(this_h - 0.5 * icon_size, v + 0.33 * this_b_height),
 743            texture=ba.gettexture('tv'),
 744        )
 745        if not self._in_game and enable_account_button:
 746            this_b_width = self._button_width
 747            h, v, scale = positions[self._p_index]
 748            self._p_index += 1
 749            self._account_button = ba.buttonwidget(
 750                parent=self._root_widget,
 751                position=(h - this_b_width * 0.5 * scale, v),
 752                size=(this_b_width, self._button_height),
 753                scale=scale,
 754                label=account_type_name,
 755                autoselect=self._use_autoselect,
 756                on_activate_call=account_type_call,
 757                textcolor=account_textcolor,
 758                icon=account_type_icon,
 759                icon_color=account_type_icon_color,
 760                transition_delay=self._tdelay,
 761                enable_sound=account_type_enable_button_sound,
 762            )
 763
 764            # Scattered eggs on easter.
 765            if (
 766                ba.internal.get_v1_account_misc_read_val('easter', False)
 767                and not self._in_game
 768            ):
 769                icon_size = 32
 770                ba.imagewidget(
 771                    parent=self._root_widget,
 772                    position=(
 773                        h - icon_size * 0.5 + 35,
 774                        v
 775                        + self._button_height * scale
 776                        - icon_size * 0.24
 777                        + 1.5,
 778                    ),
 779                    transition_delay=self._tdelay,
 780                    size=(icon_size, icon_size),
 781                    texture=ba.gettexture('egg2'),
 782                    tilt_scale=0.0,
 783                )
 784            self._tdelay += self._t_delay_inc
 785        else:
 786            self._account_button = None
 787
 788        # How-to-play button.
 789        h, v, scale = positions[self._p_index]
 790        self._p_index += 1
 791        btn = ba.buttonwidget(
 792            parent=self._root_widget,
 793            position=(h - self._button_width * 0.5 * scale, v),
 794            scale=scale,
 795            autoselect=self._use_autoselect,
 796            size=(self._button_width, self._button_height),
 797            label=ba.Lstr(resource=self._r + '.howToPlayText'),
 798            transition_delay=self._tdelay,
 799            on_activate_call=self._howtoplay,
 800        )
 801        self._how_to_play_button = btn
 802
 803        # Scattered eggs on easter.
 804        if (
 805            ba.internal.get_v1_account_misc_read_val('easter', False)
 806            and not self._in_game
 807        ):
 808            icon_size = 28
 809            ba.imagewidget(
 810                parent=self._root_widget,
 811                position=(
 812                    h - icon_size * 0.5 + 30,
 813                    v + self._button_height * scale - icon_size * 0.24 + 1.5,
 814                ),
 815                transition_delay=self._tdelay,
 816                size=(icon_size, icon_size),
 817                texture=ba.gettexture('egg4'),
 818                tilt_scale=0.0,
 819            )
 820        # Credits button.
 821        self._tdelay += self._t_delay_inc
 822        h, v, scale = positions[self._p_index]
 823        self._p_index += 1
 824        self._credits_button = ba.buttonwidget(
 825            parent=self._root_widget,
 826            position=(h - self._button_width * 0.5 * scale, v),
 827            size=(self._button_width, self._button_height),
 828            autoselect=self._use_autoselect,
 829            label=ba.Lstr(resource=self._r + '.creditsText'),
 830            scale=scale,
 831            transition_delay=self._tdelay,
 832            on_activate_call=self._credits,
 833        )
 834        self._tdelay += self._t_delay_inc
 835        return h, v, scale
 836
 837    def _refresh_in_game(
 838        self, positions: list[tuple[float, float, float]]
 839    ) -> tuple[float, float, float]:
 840        # pylint: disable=too-many-branches
 841        # pylint: disable=too-many-locals
 842        # pylint: disable=too-many-statements
 843        custom_menu_entries: list[dict[str, Any]] = []
 844        session = ba.internal.get_foreground_host_session()
 845        if session is not None:
 846            try:
 847                custom_menu_entries = session.get_custom_menu_entries()
 848                for cme in custom_menu_entries:
 849                    if (
 850                        not isinstance(cme, dict)
 851                        or 'label' not in cme
 852                        or not isinstance(cme['label'], (str, ba.Lstr))
 853                        or 'call' not in cme
 854                        or not callable(cme['call'])
 855                    ):
 856                        raise ValueError(
 857                            'invalid custom menu entry: ' + str(cme)
 858                        )
 859            except Exception:
 860                custom_menu_entries = []
 861                ba.print_exception(
 862                    f'Error getting custom menu entries for {session}'
 863                )
 864        self._width = 250.0
 865        self._height = 250.0 if self._input_player else 180.0
 866        if (self._is_demo or self._is_arcade) and self._input_player:
 867            self._height -= 40
 868        if not self._have_settings_button:
 869            self._height -= 50
 870        if self._connected_to_remote_player:
 871            # In this case we have a leave *and* a disconnect button.
 872            self._height += 50
 873        self._height += 50 * (len(custom_menu_entries))
 874        uiscale = ba.app.ui.uiscale
 875        ba.containerwidget(
 876            edit=self._root_widget,
 877            size=(self._width, self._height),
 878            scale=(
 879                2.15
 880                if uiscale is ba.UIScale.SMALL
 881                else 1.6
 882                if uiscale is ba.UIScale.MEDIUM
 883                else 1.0
 884            ),
 885        )
 886        h = 125.0
 887        v = self._height - 80.0 if self._input_player else self._height - 60
 888        h_offset = 0
 889        d_h_offset = 0
 890        v_offset = -50
 891        for _i in range(6 + len(custom_menu_entries)):
 892            positions.append((h, v, 1.0))
 893            v += v_offset
 894            h += h_offset
 895            h_offset += d_h_offset
 896        self._start_button = None
 897        ba.app.pause()
 898
 899        # Player name if applicable.
 900        if self._input_player:
 901            player_name = self._input_player.getname()
 902            h, v, scale = positions[self._p_index]
 903            v += 35
 904            ba.textwidget(
 905                parent=self._root_widget,
 906                position=(h - self._button_width / 2, v),
 907                size=(self._button_width, self._button_height),
 908                color=(1, 1, 1, 0.5),
 909                scale=0.7,
 910                h_align='center',
 911                text=ba.Lstr(value=player_name),
 912            )
 913        else:
 914            player_name = ''
 915        h, v, scale = positions[self._p_index]
 916        self._p_index += 1
 917        btn = ba.buttonwidget(
 918            parent=self._root_widget,
 919            position=(h - self._button_width / 2, v),
 920            size=(self._button_width, self._button_height),
 921            scale=scale,
 922            label=ba.Lstr(resource=self._r + '.resumeText'),
 923            autoselect=self._use_autoselect,
 924            on_activate_call=self._resume,
 925        )
 926        ba.containerwidget(edit=self._root_widget, cancel_button=btn)
 927
 928        # Add any custom options defined by the current game.
 929        for entry in custom_menu_entries:
 930            h, v, scale = positions[self._p_index]
 931            self._p_index += 1
 932
 933            # Ask the entry whether we should resume when we call
 934            # it (defaults to true).
 935            resume = bool(entry.get('resume_on_call', True))
 936
 937            if resume:
 938                call = ba.Call(self._resume_and_call, entry['call'])
 939            else:
 940                call = ba.Call(entry['call'], ba.WeakCall(self._resume))
 941
 942            ba.buttonwidget(
 943                parent=self._root_widget,
 944                position=(h - self._button_width / 2, v),
 945                size=(self._button_width, self._button_height),
 946                scale=scale,
 947                on_activate_call=call,
 948                label=entry['label'],
 949                autoselect=self._use_autoselect,
 950            )
 951        # Add a 'leave' button if the menu-owner has a player.
 952        if (self._input_player or self._connected_to_remote_player) and not (
 953            self._is_demo or self._is_arcade
 954        ):
 955            h, v, scale = positions[self._p_index]
 956            self._p_index += 1
 957            btn = ba.buttonwidget(
 958                parent=self._root_widget,
 959                position=(h - self._button_width / 2, v),
 960                size=(self._button_width, self._button_height),
 961                scale=scale,
 962                on_activate_call=self._leave,
 963                label='',
 964                autoselect=self._use_autoselect,
 965            )
 966
 967            if (
 968                player_name != ''
 969                and player_name[0] != '<'
 970                and player_name[-1] != '>'
 971            ):
 972                txt = ba.Lstr(
 973                    resource=self._r + '.justPlayerText',
 974                    subs=[('${NAME}', player_name)],
 975                )
 976            else:
 977                txt = ba.Lstr(value=player_name)
 978            ba.textwidget(
 979                parent=self._root_widget,
 980                position=(
 981                    h,
 982                    v
 983                    + self._button_height
 984                    * (0.64 if player_name != '' else 0.5),
 985                ),
 986                size=(0, 0),
 987                text=ba.Lstr(resource=self._r + '.leaveGameText'),
 988                scale=(0.83 if player_name != '' else 1.0),
 989                color=(0.75, 1.0, 0.7),
 990                h_align='center',
 991                v_align='center',
 992                draw_controller=btn,
 993                maxwidth=self._button_width * 0.9,
 994            )
 995            ba.textwidget(
 996                parent=self._root_widget,
 997                position=(h, v + self._button_height * 0.27),
 998                size=(0, 0),
 999                text=txt,
1000                color=(0.75, 1.0, 0.7),
1001                h_align='center',
1002                v_align='center',
1003                draw_controller=btn,
1004                scale=0.45,
1005                maxwidth=self._button_width * 0.9,
1006            )
1007        return h, v, scale
1008
1009    def _change_replay_speed(self, offs: int) -> None:
1010        if not self._replay_speed_text:
1011            if ba.do_once():
1012                print('_change_replay_speed called without widget')
1013            return
1014        ba.internal.set_replay_speed_exponent(
1015            ba.internal.get_replay_speed_exponent() + offs
1016        )
1017        actual_speed = pow(2.0, ba.internal.get_replay_speed_exponent())
1018        ba.textwidget(
1019            edit=self._replay_speed_text,
1020            text=ba.Lstr(
1021                resource='watchWindow.playbackSpeedText',
1022                subs=[('${SPEED}', str(actual_speed))],
1023            ),
1024        )
1025
1026    def _quit(self) -> None:
1027        # pylint: disable=cyclic-import
1028        from bastd.ui.confirm import QuitWindow
1029
1030        QuitWindow(origin_widget=self._quit_button)
1031
1032    def _demo_menu_press(self) -> None:
1033        # pylint: disable=cyclic-import
1034        from bastd.ui.kiosk import KioskWindow
1035
1036        self._save_state()
1037        ba.containerwidget(edit=self._root_widget, transition='out_right')
1038        ba.app.ui.set_main_menu_window(
1039            KioskWindow(transition='in_left').get_root_widget()
1040        )
1041
1042    def _show_account_window(self) -> None:
1043        # pylint: disable=cyclic-import
1044        from bastd.ui.account.settings import AccountSettingsWindow
1045
1046        self._save_state()
1047        ba.containerwidget(edit=self._root_widget, transition='out_left')
1048        ba.app.ui.set_main_menu_window(
1049            AccountSettingsWindow(
1050                origin_widget=self._account_button
1051            ).get_root_widget()
1052        )
1053
1054    def _on_store_pressed(self) -> None:
1055        # pylint: disable=cyclic-import
1056        from bastd.ui.store.browser import StoreBrowserWindow
1057        from bastd.ui.account import show_sign_in_prompt
1058
1059        if ba.internal.get_v1_account_state() != 'signed_in':
1060            show_sign_in_prompt()
1061            return
1062        self._save_state()
1063        ba.containerwidget(edit=self._root_widget, transition='out_left')
1064        ba.app.ui.set_main_menu_window(
1065            StoreBrowserWindow(
1066                origin_widget=self._store_button
1067            ).get_root_widget()
1068        )
1069
1070    def _is_benchmark(self) -> bool:
1071        session = ba.internal.get_foreground_host_session()
1072        return (
1073            getattr(session, 'benchmark_type', None) == 'cpu'
1074            or ba.app.stress_test_reset_timer is not None
1075        )
1076
1077    def _confirm_end_game(self) -> None:
1078        # pylint: disable=cyclic-import
1079        from bastd.ui.confirm import ConfirmWindow
1080
1081        # FIXME: Currently we crash calling this on client-sessions.
1082
1083        # Select cancel by default; this occasionally gets called by accident
1084        # in a fit of button mashing and this will help reduce damage.
1085        ConfirmWindow(
1086            ba.Lstr(resource=self._r + '.exitToMenuText'),
1087            self._end_game,
1088            cancel_is_selected=True,
1089        )
1090
1091    def _confirm_end_test(self) -> None:
1092        # pylint: disable=cyclic-import
1093        from bastd.ui.confirm import ConfirmWindow
1094
1095        # Select cancel by default; this occasionally gets called by accident
1096        # in a fit of button mashing and this will help reduce damage.
1097        ConfirmWindow(
1098            ba.Lstr(resource=self._r + '.exitToMenuText'),
1099            self._end_game,
1100            cancel_is_selected=True,
1101        )
1102
1103    def _confirm_end_replay(self) -> None:
1104        # pylint: disable=cyclic-import
1105        from bastd.ui.confirm import ConfirmWindow
1106
1107        # Select cancel by default; this occasionally gets called by accident
1108        # in a fit of button mashing and this will help reduce damage.
1109        ConfirmWindow(
1110            ba.Lstr(resource=self._r + '.exitToMenuText'),
1111            self._end_game,
1112            cancel_is_selected=True,
1113        )
1114
1115    def _confirm_leave_party(self) -> None:
1116        # pylint: disable=cyclic-import
1117        from bastd.ui.confirm import ConfirmWindow
1118
1119        # Select cancel by default; this occasionally gets called by accident
1120        # in a fit of button mashing and this will help reduce damage.
1121        ConfirmWindow(
1122            ba.Lstr(resource=self._r + '.leavePartyConfirmText'),
1123            self._leave_party,
1124            cancel_is_selected=True,
1125        )
1126
1127    def _leave_party(self) -> None:
1128        ba.internal.disconnect_from_host()
1129
1130    def _end_game(self) -> None:
1131        if not self._root_widget:
1132            return
1133        ba.containerwidget(edit=self._root_widget, transition='out_left')
1134        ba.app.return_to_main_menu_session_gracefully(reset_ui=False)
1135
1136    def _leave(self) -> None:
1137        if self._input_player:
1138            self._input_player.remove_from_game()
1139        elif self._connected_to_remote_player:
1140            if self._input_device:
1141                self._input_device.remove_remote_player_from_game()
1142        self._resume()
1143
1144    def _credits(self) -> None:
1145        # pylint: disable=cyclic-import
1146        from bastd.ui.creditslist import CreditsListWindow
1147
1148        self._save_state()
1149        ba.containerwidget(edit=self._root_widget, transition='out_left')
1150        ba.app.ui.set_main_menu_window(
1151            CreditsListWindow(
1152                origin_widget=self._credits_button
1153            ).get_root_widget()
1154        )
1155
1156    def _howtoplay(self) -> None:
1157        # pylint: disable=cyclic-import
1158        from bastd.ui.helpui import HelpWindow
1159
1160        self._save_state()
1161        ba.containerwidget(edit=self._root_widget, transition='out_left')
1162        ba.app.ui.set_main_menu_window(
1163            HelpWindow(
1164                main_menu=True, origin_widget=self._how_to_play_button
1165            ).get_root_widget()
1166        )
1167
1168    def _settings(self) -> None:
1169        # pylint: disable=cyclic-import
1170        from bastd.ui.settings.allsettings import AllSettingsWindow
1171
1172        self._save_state()
1173        ba.containerwidget(edit=self._root_widget, transition='out_left')
1174        ba.app.ui.set_main_menu_window(
1175            AllSettingsWindow(
1176                origin_widget=self._settings_button
1177            ).get_root_widget()
1178        )
1179
1180    def _resume_and_call(self, call: Callable[[], Any]) -> None:
1181        self._resume()
1182        call()
1183
1184    def _do_game_service_press(self) -> None:
1185        self._save_state()
1186        ba.internal.show_online_score_ui()
1187
1188    def _save_state(self) -> None:
1189
1190        # Don't do this for the in-game menu.
1191        if self._in_game:
1192            return
1193        sel = self._root_widget.get_selected_child()
1194        if sel == self._start_button:
1195            ba.app.ui.main_menu_selection = 'Start'
1196        elif sel == self._gather_button:
1197            ba.app.ui.main_menu_selection = 'Gather'
1198        elif sel == self._watch_button:
1199            ba.app.ui.main_menu_selection = 'Watch'
1200        elif sel == self._how_to_play_button:
1201            ba.app.ui.main_menu_selection = 'HowToPlay'
1202        elif sel == self._credits_button:
1203            ba.app.ui.main_menu_selection = 'Credits'
1204        elif sel == self._settings_button:
1205            ba.app.ui.main_menu_selection = 'Settings'
1206        elif sel == self._account_button:
1207            ba.app.ui.main_menu_selection = 'Account'
1208        elif sel == self._store_button:
1209            ba.app.ui.main_menu_selection = 'Store'
1210        elif sel == self._quit_button:
1211            ba.app.ui.main_menu_selection = 'Quit'
1212        elif sel == self._demo_menu_button:
1213            ba.app.ui.main_menu_selection = 'DemoMenu'
1214        else:
1215            print('unknown widget in main menu store selection:', sel)
1216            ba.app.ui.main_menu_selection = 'Start'
1217
1218    def _restore_state(self) -> None:
1219        # pylint: disable=too-many-branches
1220
1221        # Don't do this for the in-game menu.
1222        if self._in_game:
1223            return
1224        sel_name = ba.app.ui.main_menu_selection
1225        sel: ba.Widget | None
1226        if sel_name is None:
1227            sel_name = 'Start'
1228        if sel_name == 'HowToPlay':
1229            sel = self._how_to_play_button
1230        elif sel_name == 'Gather':
1231            sel = self._gather_button
1232        elif sel_name == 'Watch':
1233            sel = self._watch_button
1234        elif sel_name == 'Credits':
1235            sel = self._credits_button
1236        elif sel_name == 'Settings':
1237            sel = self._settings_button
1238        elif sel_name == 'Account':
1239            sel = self._account_button
1240        elif sel_name == 'Store':
1241            sel = self._store_button
1242        elif sel_name == 'Quit':
1243            sel = self._quit_button
1244        elif sel_name == 'DemoMenu':
1245            sel = self._demo_menu_button
1246        else:
1247            sel = self._start_button
1248        if sel is not None:
1249            ba.containerwidget(edit=self._root_widget, selected_child=sel)
1250
1251    def _gather_press(self) -> None:
1252        # pylint: disable=cyclic-import
1253        from bastd.ui.gather import GatherWindow
1254
1255        self._save_state()
1256        ba.containerwidget(edit=self._root_widget, transition='out_left')
1257        ba.app.ui.set_main_menu_window(
1258            GatherWindow(origin_widget=self._gather_button).get_root_widget()
1259        )
1260
1261    def _watch_press(self) -> None:
1262        # pylint: disable=cyclic-import
1263        from bastd.ui.watch import WatchWindow
1264
1265        self._save_state()
1266        ba.containerwidget(edit=self._root_widget, transition='out_left')
1267        ba.app.ui.set_main_menu_window(
1268            WatchWindow(origin_widget=self._watch_button).get_root_widget()
1269        )
1270
1271    def _play_press(self) -> None:
1272        # pylint: disable=cyclic-import
1273        from bastd.ui.play import PlayWindow
1274
1275        self._save_state()
1276        ba.containerwidget(edit=self._root_widget, transition='out_left')
1277
1278        ba.app.ui.selecting_private_party_playlist = False
1279        ba.app.ui.set_main_menu_window(
1280            PlayWindow(origin_widget=self._start_button).get_root_widget()
1281        )
1282
1283    def _resume(self) -> None:
1284        ba.app.resume()
1285        if self._root_widget:
1286            ba.containerwidget(edit=self._root_widget, transition='out_right')
1287        ba.app.ui.clear_main_menu_window()
1288
1289        # If there's callbacks waiting for this window to go away, call them.
1290        for call in ba.app.main_menu_resume_callbacks:
1291            call()
1292        del ba.app.main_menu_resume_callbacks[:]