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