bauiv1lib.mainmenu

Implements the main menu window.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Implements the main menu window."""
  4
  5from __future__ import annotations
  6
  7from typing import TYPE_CHECKING, override
  8import logging
  9
 10import bauiv1 as bui
 11import bascenev1 as bs
 12
 13if TYPE_CHECKING:
 14    from typing import Any, Callable
 15
 16
 17class MainMenuWindow(bui.MainWindow):
 18    """The main menu window."""
 19
 20    def __init__(
 21        self,
 22        transition: str | None = 'in_right',
 23        origin_widget: bui.Widget | None = None,
 24    ):
 25
 26        # Preload some modules we use in a background thread so we won't
 27        # have a visual hitch when the user taps them.
 28        bui.app.threadpool.submit_no_wait(self._preload_modules)
 29
 30        bui.set_analytics_screen('Main Menu')
 31        self._show_remote_app_info_on_first_launch()
 32
 33        uiscale = bui.app.ui_v1.uiscale
 34
 35        # Make a vanilla container; we'll modify it to our needs in
 36        # refresh.
 37        super().__init__(
 38            root_widget=bui.containerwidget(
 39                toolbar_visibility=('menu_full_no_back')
 40            ),
 41            transition=transition,
 42            origin_widget=origin_widget,
 43            # We're affected by screen size only at small ui-scale.
 44            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 45        )
 46
 47        # Grab this stuff in case it changes.
 48        self._is_demo = bui.app.env.demo
 49        self._is_arcade = bui.app.env.arcade
 50
 51        self._tdelay = 0.0
 52        self._t_delay_inc = 0.02
 53        self._t_delay_play = 1.7
 54        self._use_autoselect = True
 55        self._button_width = 200.0
 56        self._button_height = 45.0
 57        self._width = 100.0
 58        self._height = 100.0
 59        self._demo_menu_button: bui.Widget | None = None
 60        self._gather_button: bui.Widget | None = None
 61        self._play_button: bui.Widget | None = None
 62        self._watch_button: bui.Widget | None = None
 63        self._how_to_play_button: bui.Widget | None = None
 64        self._credits_button: bui.Widget | None = None
 65
 66        self._refresh()
 67
 68        self._restore_state()
 69
 70    @override
 71    def on_main_window_close(self) -> None:
 72        self._save_state()
 73
 74    @override
 75    def get_main_window_state(self) -> bui.MainWindowState:
 76        # Support recreating our window for back/refresh purposes.
 77        cls = type(self)
 78        return bui.BasicMainWindowState(
 79            create_call=lambda transition, origin_widget: cls(
 80                transition=transition, origin_widget=origin_widget
 81            )
 82        )
 83
 84    @staticmethod
 85    def _preload_modules() -> None:
 86        """Preload modules we use; avoids hitches (called in bg thread)."""
 87        # pylint: disable=cyclic-import
 88        import bauiv1lib.getremote as _unused
 89        import bauiv1lib.confirm as _unused2
 90        import bauiv1lib.account.settings as _unused5
 91        import bauiv1lib.store.browser as _unused6
 92        import bauiv1lib.credits as _unused7
 93        import bauiv1lib.help as _unused8
 94        import bauiv1lib.settings.allsettings as _unused9
 95        import bauiv1lib.gather as _unused10
 96        import bauiv1lib.watch as _unused11
 97        import bauiv1lib.play as _unused12
 98
 99    def _show_remote_app_info_on_first_launch(self) -> None:
100        app = bui.app
101        assert app.classic is not None
102
103        # The first time the non-in-game menu pops up, we might wanna
104        # show a 'get-remote-app' dialog in front of it.
105        if app.classic.first_main_menu:
106            app.classic.first_main_menu = False
107            try:
108                force_test = False
109                bs.get_local_active_input_devices_count()
110                if (
111                    (app.env.tv or app.classic.platform == 'mac')
112                    and bui.app.config.get('launchCount', 0) <= 1
113                ) or force_test:
114
115                    def _check_show_bs_remote_window() -> None:
116                        try:
117                            from bauiv1lib.getremote import GetBSRemoteWindow
118
119                            bui.getsound('swish').play()
120                            GetBSRemoteWindow()
121                        except Exception:
122                            logging.exception(
123                                'Error showing get-remote window.'
124                            )
125
126                    bui.apptimer(2.5, _check_show_bs_remote_window)
127            except Exception:
128                logging.exception('Error showing get-remote-app info.')
129
130    def get_play_button(self) -> bui.Widget | None:
131        """Return the play button."""
132        return self._play_button
133
134    def _refresh(self) -> None:
135        # pylint: disable=too-many-statements
136        # pylint: disable=too-many-locals
137
138        classic = bui.app.classic
139        assert classic is not None
140
141        # Clear everything that was there.
142        children = self._root_widget.get_children()
143        for child in children:
144            child.delete()
145
146        self._tdelay = 0.0
147        self._t_delay_inc = 0.0
148        self._t_delay_play = 0.0
149        self._button_width = 200.0
150        self._button_height = 45.0
151
152        self._r = 'mainMenu'
153
154        app = bui.app
155        assert app.classic is not None
156        uiscale = app.ui_v1.uiscale
157
158        # Temp note about UI changes.
159        if bool(False):
160            bui.textwidget(
161                parent=self._root_widget,
162                position=(
163                    (-400, 400)
164                    if uiscale is bui.UIScale.LARGE
165                    else (
166                        (-270, 320)
167                        if uiscale is bui.UIScale.MEDIUM
168                        else (-280, 280)
169                    )
170                ),
171                size=(0, 0),
172                scale=0.4,
173                flatness=1.0,
174                text=(
175                    'WARNING: This build contains a revamped UI\n'
176                    'which is still a work-in-progress. A number\n'
177                    'of features are not currently functional or\n'
178                    'contain bugs. To go back to the stable legacy UI,\n'
179                    'grab version 1.7.36 from ballistica.net'
180                ),
181                h_align='left',
182                v_align='top',
183            )
184
185        self._have_quit_button = app.classic.platform in (
186            'windows',
187            'mac',
188            'linux',
189        )
190
191        if not classic.did_menu_intro:
192            self._tdelay = 1.6
193            self._t_delay_inc = 0.03
194            classic.did_menu_intro = True
195
196        td1 = 2
197        td2 = 1
198        td3 = 0
199        td4 = -1
200        td5 = -2
201
202        self._width = 400.0
203        self._height = 200.0
204
205        play_button_width = self._button_width * 0.65
206        play_button_height = self._button_height * 1.1
207        play_button_scale = 1.7
208        hspace = 20.0
209        side_button_width = self._button_width * 0.4
210        side_button_height = side_button_width
211        side_button_scale = 0.95
212        side_button_y_offs = 5.0
213        hspace2 = 15.0
214        side_button_2_width = self._button_width * 1.0
215        side_button_2_height = side_button_2_width * 0.3
216        side_button_2_y_offs = 10.0
217        side_button_2_scale = 0.5
218
219        if uiscale is bui.UIScale.SMALL:
220            # We're a generally widescreen shaped window, so bump our
221            # overall scale up a bit when screen width is wider than safe
222            # bounds to take advantage of the extra space.
223            screensize = bui.get_virtual_screen_size()
224            safesize = bui.get_virtual_safe_area_size()
225            root_widget_scale = min(1.55, 1.3 * screensize[0] / safesize[0])
226            button_y_offs = -20.0
227            self._button_height *= 1.3
228        elif uiscale is bui.UIScale.MEDIUM:
229            root_widget_scale = 1.3
230            button_y_offs = -55.0
231            self._button_height *= 1.25
232        else:
233            root_widget_scale = 1.0
234            button_y_offs = -90.0
235            self._button_height *= 1.2
236
237        bui.containerwidget(
238            edit=self._root_widget,
239            size=(self._width, self._height),
240            background=False,
241            scale=root_widget_scale,
242        )
243
244        # Version/copyright info.
245        thistdelay = self._tdelay + td3 * self._t_delay_inc
246        bui.textwidget(
247            parent=self._root_widget,
248            position=(self._width * 0.5, button_y_offs - 10),
249            size=(0, 0),
250            scale=0.4,
251            flatness=1.0,
252            color=(1, 1, 1, 0.3),
253            text=(
254                f'{app.env.engine_version}'
255                f' build {app.env.engine_build_number}.'
256                f' Copyright 2025 Eric Froemling.'
257            ),
258            h_align='center',
259            v_align='center',
260            # transition_delay=self._t_delay_play,
261            transition_delay=thistdelay,
262        )
263
264        # In kiosk mode, provide a button to get back to the kiosk menu.
265        if bui.app.env.demo or bui.app.env.arcade:
266            # h, v, scale = positions[self._p_index]
267            h = self._width * 0.5
268            v = button_y_offs
269            scale = 1.0
270            this_b_width = self._button_width * 0.4 * scale
271            # demo_menu_delay = (
272            #     0.0
273            #     if self._t_delay_play == 0.0
274            #     else max(0, self._t_delay_play + 0.1)
275            # )
276            demo_menu_delay = 0.0
277            self._demo_menu_button = bui.buttonwidget(
278                parent=self._root_widget,
279                id='demo',
280                position=(self._width * 0.5 - this_b_width * 0.5, v + 90),
281                size=(this_b_width, 45),
282                autoselect=True,
283                color=(0.45, 0.55, 0.45),
284                textcolor=(0.7, 0.8, 0.7),
285                label=bui.Lstr(
286                    resource=(
287                        'modeArcadeText'
288                        if bui.app.env.arcade
289                        else 'modeDemoText'
290                    )
291                ),
292                transition_delay=demo_menu_delay,
293                on_activate_call=self.main_window_back,
294            )
295        else:
296            self._demo_menu_button = None
297
298        # Gather button
299        h = self._width * 0.5
300        h = (
301            self._width * 0.5
302            - play_button_width * play_button_scale * 0.5
303            - hspace
304            - side_button_width * side_button_scale * 0.5
305        )
306        v = button_y_offs + side_button_y_offs
307
308        thistdelay = self._tdelay + td2 * self._t_delay_inc
309        self._gather_button = btn = bui.buttonwidget(
310            parent=self._root_widget,
311            position=(h - side_button_width * side_button_scale * 0.5, v),
312            size=(side_button_width, side_button_height),
313            scale=side_button_scale,
314            autoselect=self._use_autoselect,
315            button_type='square',
316            label='',
317            transition_delay=thistdelay,
318            on_activate_call=self._gather_press,
319        )
320        bui.textwidget(
321            parent=self._root_widget,
322            position=(h, v + side_button_height * side_button_scale * 0.25),
323            size=(0, 0),
324            scale=0.75,
325            transition_delay=thistdelay,
326            draw_controller=btn,
327            color=(0.75, 1.0, 0.7),
328            maxwidth=side_button_width * side_button_scale * 0.8,
329            text=bui.Lstr(resource='gatherWindow.titleText'),
330            h_align='center',
331            v_align='center',
332        )
333        icon_size = side_button_width * side_button_scale * 0.63
334        bui.imagewidget(
335            parent=self._root_widget,
336            size=(icon_size, icon_size),
337            draw_controller=btn,
338            transition_delay=thistdelay,
339            position=(
340                h - 0.5 * icon_size,
341                v
342                + 0.65 * side_button_height * side_button_scale
343                - 0.5 * icon_size,
344            ),
345            texture=bui.gettexture('usersButton'),
346        )
347        thistdelay = self._tdelay + td1 * self._t_delay_inc
348
349        h -= (
350            side_button_width * side_button_scale * 0.5
351            + hspace2
352            + side_button_2_width * side_button_2_scale
353        )
354        v = button_y_offs + side_button_2_y_offs
355
356        btn = bui.buttonwidget(
357            parent=self._root_widget,
358            id='howtoplay',
359            position=(h, v),
360            autoselect=self._use_autoselect,
361            size=(side_button_2_width, side_button_2_height * 2.0),
362            button_type='square',
363            scale=side_button_2_scale,
364            label=bui.Lstr(resource=f'{self._r}.howToPlayText'),
365            transition_delay=thistdelay,
366            on_activate_call=self._howtoplay,
367        )
368        self._how_to_play_button = btn
369
370        # Play button.
371        h = self._width * 0.5
372        v = button_y_offs
373        assert play_button_width is not None
374        assert play_button_height is not None
375        thistdelay = self._tdelay + td3 * self._t_delay_inc
376        self._play_button = start_button = bui.buttonwidget(
377            parent=self._root_widget,
378            position=(h - play_button_width * 0.5 * play_button_scale, v),
379            size=(play_button_width, play_button_height),
380            autoselect=self._use_autoselect,
381            scale=play_button_scale,
382            text_res_scale=2.0,
383            label=bui.Lstr(resource='playText'),
384            transition_delay=thistdelay,
385            on_activate_call=self._play_press,
386        )
387        bui.containerwidget(
388            edit=self._root_widget,
389            start_button=start_button,
390            selected_child=start_button,
391        )
392
393        # self._tdelay += self._t_delay_inc
394
395        h = (
396            self._width * 0.5
397            + play_button_width * play_button_scale * 0.5
398            + hspace
399            + side_button_width * side_button_scale * 0.5
400        )
401        v = button_y_offs + side_button_y_offs
402        thistdelay = self._tdelay + td4 * self._t_delay_inc
403        self._watch_button = btn = bui.buttonwidget(
404            parent=self._root_widget,
405            position=(h - side_button_width * side_button_scale * 0.5, v),
406            size=(side_button_width, side_button_height),
407            scale=side_button_scale,
408            autoselect=self._use_autoselect,
409            button_type='square',
410            label='',
411            transition_delay=thistdelay,
412            on_activate_call=self._watch_press,
413        )
414        bui.textwidget(
415            parent=self._root_widget,
416            position=(h, v + side_button_height * side_button_scale * 0.25),
417            size=(0, 0),
418            scale=0.75,
419            transition_delay=thistdelay,
420            color=(0.75, 1.0, 0.7),
421            draw_controller=btn,
422            maxwidth=side_button_width * side_button_scale * 0.8,
423            text=bui.Lstr(resource='watchWindow.titleText'),
424            h_align='center',
425            v_align='center',
426        )
427        icon_size = side_button_width * side_button_scale * 0.63
428        bui.imagewidget(
429            parent=self._root_widget,
430            size=(icon_size, icon_size),
431            draw_controller=btn,
432            transition_delay=thistdelay,
433            position=(
434                h - 0.5 * icon_size,
435                v
436                + 0.65 * side_button_height * side_button_scale
437                - 0.5 * icon_size,
438            ),
439            texture=bui.gettexture('tv'),
440        )
441
442        # Credits button.
443        thistdelay = self._tdelay + td5 * self._t_delay_inc
444
445        h += side_button_width * side_button_scale * 0.5 + hspace2
446        v = button_y_offs + side_button_2_y_offs
447
448        if self._have_quit_button:
449            v += 1.17 * side_button_2_height * side_button_2_scale
450
451        self._credits_button = bui.buttonwidget(
452            parent=self._root_widget,
453            position=(h, v),
454            button_type=None if self._have_quit_button else 'square',
455            size=(
456                side_button_2_width,
457                side_button_2_height * (1.0 if self._have_quit_button else 2.0),
458            ),
459            scale=side_button_2_scale,
460            autoselect=self._use_autoselect,
461            label=bui.Lstr(resource=f'{self._r}.creditsText'),
462            transition_delay=thistdelay,
463            on_activate_call=self._credits,
464        )
465
466        self._quit_button: bui.Widget | None
467        if self._have_quit_button:
468            v -= 1.1 * side_button_2_height * side_button_2_scale
469            # Nudge this a tiny bit right so we can press right from the
470            # credits button to get to it.
471            self._quit_button = quit_button = bui.buttonwidget(
472                parent=self._root_widget,
473                autoselect=self._use_autoselect,
474                position=(h + 4.0, v),
475                size=(side_button_2_width, side_button_2_height),
476                scale=side_button_2_scale,
477                label=bui.Lstr(
478                    resource=self._r
479                    + (
480                        '.quitText'
481                        if 'Mac' in app.classic.legacy_user_agent_string
482                        else '.exitGameText'
483                    )
484                ),
485                on_activate_call=self._quit,
486                transition_delay=thistdelay,
487            )
488
489            bui.containerwidget(
490                edit=self._root_widget, cancel_button=quit_button
491            )
492            # self._tdelay += self._t_delay_inc
493        else:
494            self._quit_button = None
495
496            # If we're not in-game, have no quit button, and this is
497            # android, we want back presses to quit our activity.
498            if app.classic.platform == 'android':
499
500                def _do_quit() -> None:
501                    bui.quit(confirm=True, quit_type=bui.QuitType.BACK)
502
503                bui.containerwidget(
504                    edit=self._root_widget, on_cancel_call=_do_quit
505                )
506
507    def _quit(self) -> None:
508        # pylint: disable=cyclic-import
509        from bauiv1lib.confirm import QuitWindow
510
511        # no-op if we're not currently in control.
512        if not self.main_window_has_control():
513            return
514
515        # Note: Normally we should go through bui.quit(confirm=True) but
516        # invoking the window directly lets us scale it up from the
517        # button.
518        QuitWindow(origin_widget=self._quit_button)
519
520    def _credits(self) -> None:
521        # pylint: disable=cyclic-import
522        from bauiv1lib.credits import CreditsWindow
523
524        # no-op if we're not currently in control.
525        if not self.main_window_has_control():
526            return
527
528        self.main_window_replace(
529            CreditsWindow(origin_widget=self._credits_button),
530        )
531
532    def _howtoplay(self) -> None:
533        # pylint: disable=cyclic-import
534        from bauiv1lib.help import HelpWindow
535
536        # no-op if we're not currently in control.
537        if not self.main_window_has_control():
538            return
539
540        self.main_window_replace(
541            HelpWindow(origin_widget=self._how_to_play_button),
542        )
543
544    def _save_state(self) -> None:
545        try:
546            sel = self._root_widget.get_selected_child()
547            if sel == self._play_button:
548                sel_name = 'Start'
549            elif sel == self._gather_button:
550                sel_name = 'Gather'
551            elif sel == self._watch_button:
552                sel_name = 'Watch'
553            elif sel == self._how_to_play_button:
554                sel_name = 'HowToPlay'
555            elif sel == self._credits_button:
556                sel_name = 'Credits'
557            elif sel == self._quit_button:
558                sel_name = 'Quit'
559            elif sel == self._demo_menu_button:
560                sel_name = 'DemoMenu'
561            else:
562                print(f'Unknown widget in main menu selection: {sel}.')
563                sel_name = 'Start'
564            bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
565        except Exception:
566            logging.exception('Error saving state for %s.', self)
567
568    def _restore_state(self) -> None:
569        try:
570
571            sel: bui.Widget | None
572
573            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
574                'sel_name'
575            )
576            assert isinstance(sel_name, (str, type(None)))
577            if sel_name is None:
578                sel_name = 'Start'
579            if sel_name == 'HowToPlay':
580                sel = self._how_to_play_button
581            elif sel_name == 'Gather':
582                sel = self._gather_button
583            elif sel_name == 'Watch':
584                sel = self._watch_button
585            elif sel_name == 'Credits':
586                sel = self._credits_button
587            elif sel_name == 'Quit':
588                sel = self._quit_button
589            elif sel_name == 'DemoMenu':
590                sel = self._demo_menu_button
591            else:
592                sel = self._play_button
593            if sel is not None:
594                bui.containerwidget(edit=self._root_widget, selected_child=sel)
595
596        except Exception:
597            logging.exception('Error restoring state for %s.', self)
598
599    def _gather_press(self) -> None:
600        # pylint: disable=cyclic-import
601        from bauiv1lib.gather import GatherWindow
602
603        # no-op if we're not currently in control.
604        if not self.main_window_has_control():
605            return
606
607        self.main_window_replace(
608            GatherWindow(origin_widget=self._gather_button)
609        )
610
611    def _watch_press(self) -> None:
612        # pylint: disable=cyclic-import
613        from bauiv1lib.watch import WatchWindow
614
615        # no-op if we're not currently in control.
616        if not self.main_window_has_control():
617            return
618
619        self.main_window_replace(
620            WatchWindow(origin_widget=self._watch_button),
621        )
622
623    def _play_press(self) -> None:
624        # pylint: disable=cyclic-import
625        from bauiv1lib.play import PlayWindow
626
627        # no-op if we're not currently in control.
628        if not self.main_window_has_control():
629            return
630
631        self.main_window_replace(PlayWindow(origin_widget=self._play_button))