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        # Make a vanilla container; we'll modify it to our needs in
 34        # refresh.
 35        super().__init__(
 36            root_widget=bui.containerwidget(
 37                toolbar_visibility=('menu_full_no_back')
 38            ),
 39            transition=transition,
 40            origin_widget=origin_widget,
 41        )
 42
 43        # Grab this stuff in case it changes.
 44        self._is_demo = bui.app.env.demo
 45        self._is_arcade = bui.app.env.arcade
 46
 47        self._tdelay = 0.0
 48        self._t_delay_inc = 0.02
 49        self._t_delay_play = 1.7
 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: bui.Widget | None = None
 56        self._gather_button: bui.Widget | None = None
 57        self._play_button: bui.Widget | None = None
 58        self._watch_button: bui.Widget | None = None
 59        self._how_to_play_button: bui.Widget | None = None
 60        self._credits_button: bui.Widget | None = None
 61
 62        self._refresh()
 63
 64        self._restore_state()
 65
 66    @override
 67    def on_main_window_close(self) -> None:
 68        self._save_state()
 69
 70    @override
 71    def get_main_window_state(self) -> bui.MainWindowState:
 72        # Support recreating our window for back/refresh purposes.
 73        return self.do_get_main_window_state()
 74
 75    @classmethod
 76    def do_get_main_window_state(cls) -> bui.MainWindowState:
 77        """Classmethod to gen a windowstate for the main menu."""
 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.store.button as _unused3
 91        import bauiv1lib.account.settings as _unused5
 92        import bauiv1lib.store.browser as _unused6
 93        import bauiv1lib.credits as _unused7
 94        import bauiv1lib.helpui as _unused8
 95        import bauiv1lib.settings.allsettings as _unused9
 96        import bauiv1lib.gather as _unused10
 97        import bauiv1lib.watch as _unused11
 98        import bauiv1lib.play as _unused12
 99
100    def _show_remote_app_info_on_first_launch(self) -> None:
101        app = bui.app
102        assert app.classic is not None
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        bui.textwidget(
160            parent=self._root_widget,
161            position=(
162                (-400, 400)
163                if uiscale is bui.UIScale.LARGE
164                else (
165                    (-270, 320)
166                    if uiscale is bui.UIScale.MEDIUM
167                    else (-280, 280)
168                )
169            ),
170            size=(0, 0),
171            scale=0.4,
172            flatness=1.0,
173            text=(
174                'WARNING: This build contains a revamped UI\n'
175                'which is still a work-in-progress. A number\n'
176                'of features are not currently functional or\n'
177                'contain bugs. To go back to the stable legacy UI,\n'
178                'grab version 1.7.36 from ballistica.net'
179            ),
180            h_align='left',
181            v_align='top',
182        )
183
184        self._have_quit_button = app.classic.platform in (
185            'windows',
186            'mac',
187            'linux',
188        )
189
190        if not classic.did_menu_intro:
191            self._tdelay = 1.7
192            self._t_delay_inc = 0.05
193            self._t_delay_play = 1.7
194            classic.did_menu_intro = True
195
196        self._width = 400.0
197        self._height = 200.0
198
199        play_button_width = self._button_width * 0.65
200        play_button_height = self._button_height * 1.1
201        play_button_scale = 1.7
202        hspace = 20.0
203        side_button_width = self._button_width * 0.4
204        side_button_height = side_button_width
205        side_button_scale = 0.95
206        side_button_y_offs = 5.0
207        hspace2 = 15.0
208        side_button_2_width = self._button_width * 1.0
209        side_button_2_height = side_button_2_width * 0.3
210        side_button_2_y_offs = 10.0
211        side_button_2_scale = 0.5
212
213        if uiscale is bui.UIScale.SMALL:
214            root_widget_scale = 1.3
215            button_y_offs = -20.0
216            self._button_height *= 1.3
217        elif uiscale is bui.UIScale.MEDIUM:
218            root_widget_scale = 1.3
219            button_y_offs = -55.0
220            self._button_height *= 1.25
221        else:
222            root_widget_scale = 1.0
223            button_y_offs = -90.0
224            self._button_height *= 1.2
225
226        bui.containerwidget(
227            edit=self._root_widget,
228            size=(self._width, self._height),
229            background=False,
230            scale=root_widget_scale,
231        )
232
233        # Version/copyright info.
234        bui.textwidget(
235            parent=self._root_widget,
236            position=(self._width * 0.5, button_y_offs - 10),
237            size=(0, 0),
238            scale=0.4,
239            flatness=1.0,
240            color=(1, 1, 1, 0.3),
241            text=(
242                f'{app.env.engine_version}'
243                f' build {app.env.engine_build_number}.'
244                f' Copyright 2024 Eric Froemling.'
245            ),
246            h_align='center',
247            v_align='center',
248            transition_delay=self._t_delay_play,
249        )
250
251        # In kiosk mode, provide a button to get back to the kiosk menu.
252        if bui.app.env.demo or bui.app.env.arcade:
253            # h, v, scale = positions[self._p_index]
254            h = self._width * 0.5
255            v = button_y_offs
256            scale = 1.0
257            this_b_width = self._button_width * 0.4 * scale
258            demo_menu_delay = (
259                0.0
260                if self._t_delay_play == 0.0
261                else max(0, self._t_delay_play + 0.1)
262            )
263            self._demo_menu_button = bui.buttonwidget(
264                parent=self._root_widget,
265                position=(self._width * 0.5 - this_b_width * 0.5, v + 90),
266                size=(this_b_width, 45),
267                autoselect=True,
268                color=(0.45, 0.55, 0.45),
269                textcolor=(0.7, 0.8, 0.7),
270                label=bui.Lstr(
271                    resource=(
272                        'modeArcadeText'
273                        if bui.app.env.arcade
274                        else 'modeDemoText'
275                    )
276                ),
277                transition_delay=demo_menu_delay,
278                on_activate_call=self.main_window_back,
279            )
280        else:
281            self._demo_menu_button = None
282
283        # Gather button
284        h = self._width * 0.5
285        h = (
286            self._width * 0.5
287            - play_button_width * play_button_scale * 0.5
288            - hspace
289            - side_button_width * side_button_scale * 0.5
290        )
291        v = button_y_offs + side_button_y_offs
292        self._gather_button = btn = bui.buttonwidget(
293            parent=self._root_widget,
294            position=(h - side_button_width * side_button_scale * 0.5, v),
295            size=(side_button_width, side_button_height),
296            scale=side_button_scale,
297            autoselect=self._use_autoselect,
298            button_type='square',
299            label='',
300            transition_delay=self._tdelay,
301            on_activate_call=self._gather_press,
302        )
303        bui.textwidget(
304            parent=self._root_widget,
305            position=(h, v + side_button_height * side_button_scale * 0.25),
306            size=(0, 0),
307            scale=0.75,
308            transition_delay=self._tdelay,
309            draw_controller=btn,
310            color=(0.75, 1.0, 0.7),
311            maxwidth=side_button_width * side_button_scale * 0.8,
312            text=bui.Lstr(resource='gatherWindow.titleText'),
313            h_align='center',
314            v_align='center',
315        )
316        icon_size = side_button_width * side_button_scale * 0.63
317        bui.imagewidget(
318            parent=self._root_widget,
319            size=(icon_size, icon_size),
320            draw_controller=btn,
321            transition_delay=self._tdelay,
322            position=(
323                h - 0.5 * icon_size,
324                v
325                + 0.65 * side_button_height * side_button_scale
326                - 0.5 * icon_size,
327            ),
328            texture=bui.gettexture('usersButton'),
329        )
330
331        h -= (
332            side_button_width * side_button_scale * 0.5
333            + hspace2
334            + side_button_2_width * side_button_2_scale
335        )
336        v = button_y_offs + side_button_2_y_offs
337
338        btn = bui.buttonwidget(
339            parent=self._root_widget,
340            position=(h, v),
341            autoselect=self._use_autoselect,
342            size=(side_button_2_width, side_button_2_height * 2.0),
343            button_type='square',
344            scale=side_button_2_scale,
345            label=bui.Lstr(resource=f'{self._r}.howToPlayText'),
346            transition_delay=self._tdelay,
347            on_activate_call=self._howtoplay,
348        )
349        self._how_to_play_button = btn
350
351        # Play button.
352        h = self._width * 0.5
353        v = button_y_offs
354        assert play_button_width is not None
355        assert play_button_height is not None
356        self._play_button = start_button = bui.buttonwidget(
357            parent=self._root_widget,
358            position=(h - play_button_width * 0.5 * play_button_scale, v),
359            size=(play_button_width, play_button_height),
360            autoselect=self._use_autoselect,
361            scale=play_button_scale,
362            text_res_scale=2.0,
363            label=bui.Lstr(resource='playText'),
364            transition_delay=self._t_delay_play,
365            on_activate_call=self._play_press,
366        )
367        bui.containerwidget(
368            edit=self._root_widget,
369            start_button=start_button,
370            selected_child=start_button,
371        )
372
373        self._tdelay += self._t_delay_inc
374
375        h = (
376            self._width * 0.5
377            + play_button_width * play_button_scale * 0.5
378            + hspace
379            + side_button_width * side_button_scale * 0.5
380        )
381        v = button_y_offs + side_button_y_offs
382        self._watch_button = btn = bui.buttonwidget(
383            parent=self._root_widget,
384            position=(h - side_button_width * side_button_scale * 0.5, v),
385            size=(side_button_width, side_button_height),
386            scale=side_button_scale,
387            autoselect=self._use_autoselect,
388            button_type='square',
389            label='',
390            transition_delay=self._tdelay,
391            on_activate_call=self._watch_press,
392        )
393        bui.textwidget(
394            parent=self._root_widget,
395            position=(h, v + side_button_height * side_button_scale * 0.25),
396            size=(0, 0),
397            scale=0.75,
398            transition_delay=self._tdelay,
399            color=(0.75, 1.0, 0.7),
400            draw_controller=btn,
401            maxwidth=side_button_width * side_button_scale * 0.8,
402            text=bui.Lstr(resource='watchWindow.titleText'),
403            h_align='center',
404            v_align='center',
405        )
406        icon_size = side_button_width * side_button_scale * 0.63
407        bui.imagewidget(
408            parent=self._root_widget,
409            size=(icon_size, icon_size),
410            draw_controller=btn,
411            transition_delay=self._tdelay,
412            position=(
413                h - 0.5 * icon_size,
414                v
415                + 0.65 * side_button_height * side_button_scale
416                - 0.5 * icon_size,
417            ),
418            texture=bui.gettexture('tv'),
419        )
420
421        # Credits button.
422        self._tdelay += self._t_delay_inc
423
424        h += side_button_width * side_button_scale * 0.5 + hspace2
425        v = button_y_offs + side_button_2_y_offs
426
427        if self._have_quit_button:
428            v += 1.17 * side_button_2_height * side_button_2_scale
429
430        self._credits_button = bui.buttonwidget(
431            parent=self._root_widget,
432            position=(h, v),
433            button_type=None if self._have_quit_button else 'square',
434            size=(
435                side_button_2_width,
436                side_button_2_height * (1.0 if self._have_quit_button else 2.0),
437            ),
438            scale=side_button_2_scale,
439            autoselect=self._use_autoselect,
440            label=bui.Lstr(resource=f'{self._r}.creditsText'),
441            transition_delay=self._tdelay,
442            on_activate_call=self._credits,
443        )
444        self._tdelay += self._t_delay_inc
445
446        self._quit_button: bui.Widget | None
447        if self._have_quit_button:
448            v -= 1.1 * side_button_2_height * side_button_2_scale
449            self._quit_button = quit_button = bui.buttonwidget(
450                parent=self._root_widget,
451                autoselect=self._use_autoselect,
452                position=(h, v),
453                size=(side_button_2_width, side_button_2_height),
454                scale=side_button_2_scale,
455                label=bui.Lstr(
456                    resource=self._r
457                    + (
458                        '.quitText'
459                        if 'Mac' in app.classic.legacy_user_agent_string
460                        else '.exitGameText'
461                    )
462                ),
463                on_activate_call=self._quit,
464                transition_delay=self._tdelay,
465            )
466
467            bui.containerwidget(
468                edit=self._root_widget, cancel_button=quit_button
469            )
470            self._tdelay += self._t_delay_inc
471        else:
472            self._quit_button = None
473
474            # If we're not in-game, have no quit button, and this is
475            # android, we want back presses to quit our activity.
476            if app.classic.platform == 'android':
477
478                def _do_quit() -> None:
479                    bui.quit(confirm=True, quit_type=bui.QuitType.BACK)
480
481                bui.containerwidget(
482                    edit=self._root_widget, on_cancel_call=_do_quit
483                )
484
485    def _quit(self) -> None:
486        # pylint: disable=cyclic-import
487        from bauiv1lib.confirm import QuitWindow
488
489        # no-op if we're not currently in control.
490        if not self.main_window_has_control():
491            return
492
493        # Note: Normally we should go through bui.quit(confirm=True) but
494        # invoking the window directly lets us scale it up from the
495        # button.
496        QuitWindow(origin_widget=self._quit_button)
497
498    def _credits(self) -> None:
499        # pylint: disable=cyclic-import
500        from bauiv1lib.credits import CreditsWindow
501
502        # no-op if we're not currently in control.
503        if not self.main_window_has_control():
504            return
505
506        self.main_window_replace(
507            CreditsWindow(origin_widget=self._credits_button),
508        )
509
510    def _howtoplay(self) -> None:
511        # pylint: disable=cyclic-import
512        from bauiv1lib.helpui import HelpWindow
513
514        # no-op if we're not currently in control.
515        if not self.main_window_has_control():
516            return
517
518        self.main_window_replace(
519            HelpWindow(origin_widget=self._how_to_play_button),
520        )
521
522    def _save_state(self) -> None:
523        try:
524            sel = self._root_widget.get_selected_child()
525            if sel == self._play_button:
526                sel_name = 'Start'
527            elif sel == self._gather_button:
528                sel_name = 'Gather'
529            elif sel == self._watch_button:
530                sel_name = 'Watch'
531            elif sel == self._how_to_play_button:
532                sel_name = 'HowToPlay'
533            elif sel == self._credits_button:
534                sel_name = 'Credits'
535            elif sel == self._quit_button:
536                sel_name = 'Quit'
537            elif sel == self._demo_menu_button:
538                sel_name = 'DemoMenu'
539            else:
540                print(f'Unknown widget in main menu selection: {sel}.')
541                sel_name = 'Start'
542            bui.app.ui_v1.window_states[type(self)] = {'sel_name': sel_name}
543        except Exception:
544            logging.exception('Error saving state for %s.', self)
545
546    def _restore_state(self) -> None:
547        try:
548
549            sel: bui.Widget | None
550
551            sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get(
552                'sel_name'
553            )
554            assert isinstance(sel_name, (str, type(None)))
555            if sel_name is None:
556                sel_name = 'Start'
557            if sel_name == 'HowToPlay':
558                sel = self._how_to_play_button
559            elif sel_name == 'Gather':
560                sel = self._gather_button
561            elif sel_name == 'Watch':
562                sel = self._watch_button
563            elif sel_name == 'Credits':
564                sel = self._credits_button
565            elif sel_name == 'Quit':
566                sel = self._quit_button
567            elif sel_name == 'DemoMenu':
568                sel = self._demo_menu_button
569            else:
570                sel = self._play_button
571            if sel is not None:
572                bui.containerwidget(edit=self._root_widget, selected_child=sel)
573
574        except Exception:
575            logging.exception('Error restoring state for %s.', self)
576
577    def _gather_press(self) -> None:
578        # pylint: disable=cyclic-import
579        from bauiv1lib.gather import GatherWindow
580
581        # no-op if we're not currently in control.
582        if not self.main_window_has_control():
583            return
584
585        self.main_window_replace(
586            GatherWindow(origin_widget=self._gather_button)
587        )
588
589    def _watch_press(self) -> None:
590        # pylint: disable=cyclic-import
591        from bauiv1lib.watch import WatchWindow
592
593        # no-op if we're not currently in control.
594        if not self.main_window_has_control():
595            return
596
597        self.main_window_replace(
598            WatchWindow(origin_widget=self._watch_button),
599        )
600
601    def _play_press(self) -> None:
602        # pylint: disable=cyclic-import
603        from bauiv1lib.play import PlayWindow
604
605        # no-op if we're not currently in control.
606        if not self.main_window_has_control():
607            return
608
609        self.main_window_replace(PlayWindow(origin_widget=self._play_button))