bastd.mainmenu

Session and Activity for displaying the main menu bg.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Session and Activity for displaying the main menu bg."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import random
   9import time
  10import weakref
  11from typing import TYPE_CHECKING
  12
  13import ba
  14import ba.internal
  15
  16if TYPE_CHECKING:
  17    from typing import Any
  18
  19# FIXME: Clean this up if I ever revisit it.
  20# pylint: disable=attribute-defined-outside-init
  21# pylint: disable=too-many-branches
  22# pylint: disable=too-many-statements
  23# pylint: disable=too-many-locals
  24# noinspection PyUnreachableCode
  25# noinspection PyAttributeOutsideInit
  26
  27
  28class MainMenuActivity(ba.Activity[ba.Player, ba.Team]):
  29    """Activity showing the rotating main menu bg stuff."""
  30
  31    _stdassets = ba.Dependency(ba.AssetPackage, 'stdassets@1')
  32
  33    def on_transition_in(self) -> None:
  34        super().on_transition_in()
  35        random.seed(123)
  36        self._logo_node: ba.Node | None = None
  37        self._custom_logo_tex_name: str | None = None
  38        self._word_actors: list[ba.Actor] = []
  39        app = ba.app
  40
  41        # FIXME: We shouldn't be doing things conditionally based on whether
  42        #  the host is VR mode or not (clients may differ in that regard).
  43        #  Any differences need to happen at the engine level so everyone
  44        #  sees things in their own optimal way.
  45        vr_mode = ba.app.vr_mode
  46
  47        if not ba.app.toolbar_test:
  48            color = (1.0, 1.0, 1.0, 1.0) if vr_mode else (0.5, 0.6, 0.5, 0.6)
  49
  50            # FIXME: Need a node attr for vr-specific-scale.
  51            scale = (
  52                0.9 if (app.ui.uiscale is ba.UIScale.SMALL or vr_mode) else 0.7
  53            )
  54            self.my_name = ba.NodeActor(
  55                ba.newnode(
  56                    'text',
  57                    attrs={
  58                        'v_attach': 'bottom',
  59                        'h_align': 'center',
  60                        'color': color,
  61                        'flatness': 1.0,
  62                        'shadow': 1.0 if vr_mode else 0.5,
  63                        'scale': scale,
  64                        'position': (0, 10),
  65                        'vr_depth': -10,
  66                        'text': '\xa9 2011-2023 Eric Froemling',
  67                    },
  68                )
  69            )
  70
  71        # Throw up some text that only clients can see so they know that the
  72        # host is navigating menus while they're just staring at an
  73        # empty-ish screen.
  74        tval = ba.Lstr(
  75            resource='hostIsNavigatingMenusText',
  76            subs=[('${HOST}', ba.internal.get_v1_account_display_string())],
  77        )
  78        self._host_is_navigating_text = ba.NodeActor(
  79            ba.newnode(
  80                'text',
  81                attrs={
  82                    'text': tval,
  83                    'client_only': True,
  84                    'position': (0, -200),
  85                    'flatness': 1.0,
  86                    'h_align': 'center',
  87                },
  88            )
  89        )
  90        if not ba.app.main_menu_did_initial_transition and hasattr(
  91            self, 'my_name'
  92        ):
  93            assert self.my_name.node
  94            ba.animate(self.my_name.node, 'opacity', {2.3: 0, 3.0: 1.0})
  95
  96        # FIXME: We shouldn't be doing things conditionally based on whether
  97        #  the host is vr mode or not (clients may not be or vice versa).
  98        #  Any differences need to happen at the engine level so everyone sees
  99        #  things in their own optimal way.
 100        vr_mode = app.vr_mode
 101        uiscale = app.ui.uiscale
 102
 103        # In cases where we're doing lots of dev work lets always show the
 104        # build number.
 105        force_show_build_number = False
 106
 107        if not ba.app.toolbar_test:
 108            if app.debug_build or app.test_build or force_show_build_number:
 109                if app.debug_build:
 110                    text = ba.Lstr(
 111                        value='${V} (${B}) (${D})',
 112                        subs=[
 113                            ('${V}', app.version),
 114                            ('${B}', str(app.build_number)),
 115                            ('${D}', ba.Lstr(resource='debugText')),
 116                        ],
 117                    )
 118                else:
 119                    text = ba.Lstr(
 120                        value='${V} (${B})',
 121                        subs=[
 122                            ('${V}', app.version),
 123                            ('${B}', str(app.build_number)),
 124                        ],
 125                    )
 126            else:
 127                text = ba.Lstr(value='${V}', subs=[('${V}', app.version)])
 128            scale = 0.9 if (uiscale is ba.UIScale.SMALL or vr_mode) else 0.7
 129            color = (1, 1, 1, 1) if vr_mode else (0.5, 0.6, 0.5, 0.7)
 130            self.version = ba.NodeActor(
 131                ba.newnode(
 132                    'text',
 133                    attrs={
 134                        'v_attach': 'bottom',
 135                        'h_attach': 'right',
 136                        'h_align': 'right',
 137                        'flatness': 1.0,
 138                        'vr_depth': -10,
 139                        'shadow': 1.0 if vr_mode else 0.5,
 140                        'color': color,
 141                        'scale': scale,
 142                        'position': (-260, 10) if vr_mode else (-10, 10),
 143                        'text': text,
 144                    },
 145                )
 146            )
 147            if not ba.app.main_menu_did_initial_transition:
 148                assert self.version.node
 149                ba.animate(self.version.node, 'opacity', {2.3: 0, 3.0: 1.0})
 150
 151        # Show the iircade logo on our iircade build.
 152        if app.iircade_mode:
 153            img = ba.NodeActor(
 154                ba.newnode(
 155                    'image',
 156                    attrs={
 157                        'texture': ba.gettexture('iircadeLogo'),
 158                        'attach': 'center',
 159                        'scale': (250, 250),
 160                        'position': (0, 0),
 161                        'tilt_translate': 0.21,
 162                        'absolute_scale': True,
 163                    },
 164                )
 165            ).autoretain()
 166            imgdelay = 0.0 if app.main_menu_did_initial_transition else 1.0
 167            ba.animate(
 168                img.node, 'opacity', {imgdelay + 1.5: 0.0, imgdelay + 2.5: 1.0}
 169            )
 170
 171        # Throw in test build info.
 172        self.beta_info = self.beta_info_2 = None
 173        if app.test_build and not (app.demo_mode or app.arcade_mode):
 174            pos = (
 175                (230, 125) if (app.demo_mode or app.arcade_mode) else (230, 35)
 176            )
 177            self.beta_info = ba.NodeActor(
 178                ba.newnode(
 179                    'text',
 180                    attrs={
 181                        'v_attach': 'center',
 182                        'h_align': 'center',
 183                        'color': (1, 1, 1, 1),
 184                        'shadow': 0.5,
 185                        'flatness': 0.5,
 186                        'scale': 1,
 187                        'vr_depth': -60,
 188                        'position': pos,
 189                        'text': ba.Lstr(resource='testBuildText'),
 190                    },
 191                )
 192            )
 193            if not ba.app.main_menu_did_initial_transition:
 194                assert self.beta_info.node
 195                ba.animate(self.beta_info.node, 'opacity', {1.3: 0, 1.8: 1.0})
 196
 197        model = ba.getmodel('thePadLevel')
 198        trees_model = ba.getmodel('trees')
 199        bottom_model = ba.getmodel('thePadLevelBottom')
 200        color_texture = ba.gettexture('thePadLevelColor')
 201        trees_texture = ba.gettexture('treesColor')
 202        bgtex = ba.gettexture('menuBG')
 203        bgmodel = ba.getmodel('thePadBG')
 204
 205        # Load these last since most platforms don't use them.
 206        vr_bottom_fill_model = ba.getmodel('thePadVRFillBottom')
 207        vr_top_fill_model = ba.getmodel('thePadVRFillTop')
 208
 209        gnode = self.globalsnode
 210        gnode.camera_mode = 'rotate'
 211
 212        tint = (1.14, 1.1, 1.0)
 213        gnode.tint = tint
 214        gnode.ambient_color = (1.06, 1.04, 1.03)
 215        gnode.vignette_outer = (0.45, 0.55, 0.54)
 216        gnode.vignette_inner = (0.99, 0.98, 0.98)
 217
 218        self.bottom = ba.NodeActor(
 219            ba.newnode(
 220                'terrain',
 221                attrs={
 222                    'model': bottom_model,
 223                    'lighting': False,
 224                    'reflection': 'soft',
 225                    'reflection_scale': [0.45],
 226                    'color_texture': color_texture,
 227                },
 228            )
 229        )
 230        self.vr_bottom_fill = ba.NodeActor(
 231            ba.newnode(
 232                'terrain',
 233                attrs={
 234                    'model': vr_bottom_fill_model,
 235                    'lighting': False,
 236                    'vr_only': True,
 237                    'color_texture': color_texture,
 238                },
 239            )
 240        )
 241        self.vr_top_fill = ba.NodeActor(
 242            ba.newnode(
 243                'terrain',
 244                attrs={
 245                    'model': vr_top_fill_model,
 246                    'vr_only': True,
 247                    'lighting': False,
 248                    'color_texture': bgtex,
 249                },
 250            )
 251        )
 252        self.terrain = ba.NodeActor(
 253            ba.newnode(
 254                'terrain',
 255                attrs={
 256                    'model': model,
 257                    'color_texture': color_texture,
 258                    'reflection': 'soft',
 259                    'reflection_scale': [0.3],
 260                },
 261            )
 262        )
 263        self.trees = ba.NodeActor(
 264            ba.newnode(
 265                'terrain',
 266                attrs={
 267                    'model': trees_model,
 268                    'lighting': False,
 269                    'reflection': 'char',
 270                    'reflection_scale': [0.1],
 271                    'color_texture': trees_texture,
 272                },
 273            )
 274        )
 275        self.bgterrain = ba.NodeActor(
 276            ba.newnode(
 277                'terrain',
 278                attrs={
 279                    'model': bgmodel,
 280                    'color': (0.92, 0.91, 0.9),
 281                    'lighting': False,
 282                    'background': True,
 283                    'color_texture': bgtex,
 284                },
 285            )
 286        )
 287
 288        self._ts = 0.86
 289
 290        self._language: str | None = None
 291        self._update_timer = ba.Timer(1.0, self._update, repeat=True)
 292        self._update()
 293
 294        # Hopefully this won't hitch but lets space these out anyway.
 295        ba.internal.add_clean_frame_callback(ba.WeakCall(self._start_preloads))
 296
 297        random.seed()
 298
 299        # On the main menu, also show our news.
 300        class News:
 301            """Wrangles news display."""
 302
 303            def __init__(self, activity: ba.Activity):
 304                self._valid = True
 305                self._message_duration = 10.0
 306                self._message_spacing = 2.0
 307                self._text: ba.NodeActor | None = None
 308                self._activity = weakref.ref(activity)
 309
 310                # If we're signed in, fetch news immediately.
 311                # Otherwise wait until we are signed in.
 312                self._fetch_timer: ba.Timer | None = ba.Timer(
 313                    1.0, ba.WeakCall(self._try_fetching_news), repeat=True
 314                )
 315                self._try_fetching_news()
 316
 317            # We now want to wait until we're signed in before fetching news.
 318            def _try_fetching_news(self) -> None:
 319                if ba.internal.get_v1_account_state() == 'signed_in':
 320                    self._fetch_news()
 321                    self._fetch_timer = None
 322
 323            def _fetch_news(self) -> None:
 324                ba.app.main_menu_last_news_fetch_time = time.time()
 325
 326                # UPDATE - We now just pull news from MRVs.
 327                news = ba.internal.get_v1_account_misc_read_val('n', None)
 328                if news is not None:
 329                    self._got_news(news)
 330
 331            def _change_phrase(self) -> None:
 332                from bastd.actor.text import Text
 333
 334                # If our news is way out of date, lets re-request it;
 335                # otherwise, rotate our phrase.
 336                assert ba.app.main_menu_last_news_fetch_time is not None
 337                if time.time() - ba.app.main_menu_last_news_fetch_time > 600.0:
 338                    self._fetch_news()
 339                    self._text = None
 340                else:
 341                    if self._text is not None:
 342                        if not self._phrases:
 343                            for phr in self._used_phrases:
 344                                self._phrases.insert(0, phr)
 345                        val = self._phrases.pop()
 346                        if val == '__ACH__':
 347                            vrmode = app.vr_mode
 348                            Text(
 349                                ba.Lstr(resource='nextAchievementsText'),
 350                                color=(
 351                                    (1, 1, 1, 1)
 352                                    if vrmode
 353                                    else (0.95, 0.9, 1, 0.4)
 354                                ),
 355                                host_only=True,
 356                                maxwidth=200,
 357                                position=(-300, -35),
 358                                h_align=Text.HAlign.RIGHT,
 359                                transition=Text.Transition.FADE_IN,
 360                                scale=0.9 if vrmode else 0.7,
 361                                flatness=1.0 if vrmode else 0.6,
 362                                shadow=1.0 if vrmode else 0.5,
 363                                h_attach=Text.HAttach.CENTER,
 364                                v_attach=Text.VAttach.TOP,
 365                                transition_delay=1.0,
 366                                transition_out_delay=self._message_duration,
 367                            ).autoretain()
 368                            achs = [
 369                                a
 370                                for a in app.ach.achievements
 371                                if not a.complete
 372                            ]
 373                            if achs:
 374                                ach = achs.pop(
 375                                    random.randrange(min(4, len(achs)))
 376                                )
 377                                ach.create_display(
 378                                    -180,
 379                                    -35,
 380                                    1.0,
 381                                    outdelay=self._message_duration,
 382                                    style='news',
 383                                )
 384                            if achs:
 385                                ach = achs.pop(
 386                                    random.randrange(min(8, len(achs)))
 387                                )
 388                                ach.create_display(
 389                                    180,
 390                                    -35,
 391                                    1.25,
 392                                    outdelay=self._message_duration,
 393                                    style='news',
 394                                )
 395                        else:
 396                            spc = self._message_spacing
 397                            keys = {
 398                                spc: 0.0,
 399                                spc + 1.0: 1.0,
 400                                spc + self._message_duration - 1.0: 1.0,
 401                                spc + self._message_duration: 0.0,
 402                            }
 403                            assert self._text.node
 404                            ba.animate(self._text.node, 'opacity', keys)
 405                            # {k: v
 406                            #  for k, v in list(keys.items())})
 407                            self._text.node.text = val
 408
 409            def _got_news(self, news: str) -> None:
 410                # Run this stuff in the context of our activity since we
 411                # need to make nodes and stuff.. should fix the serverget
 412                # call so it.
 413                activity = self._activity()
 414                if activity is None or activity.expired:
 415                    return
 416                with ba.Context(activity):
 417
 418                    self._phrases: list[str] = []
 419
 420                    # Show upcoming achievements in non-vr versions
 421                    # (currently too hard to read in vr).
 422                    self._used_phrases = (
 423                        ['__ACH__'] if not ba.app.vr_mode else []
 424                    ) + [s for s in news.split('<br>\n') if s != '']
 425                    self._phrase_change_timer = ba.Timer(
 426                        (self._message_duration + self._message_spacing),
 427                        ba.WeakCall(self._change_phrase),
 428                        repeat=True,
 429                    )
 430
 431                    scl = (
 432                        1.2
 433                        if (
 434                            ba.app.ui.uiscale is ba.UIScale.SMALL
 435                            or ba.app.vr_mode
 436                        )
 437                        else 0.8
 438                    )
 439
 440                    color2 = (
 441                        (1, 1, 1, 1)
 442                        if ba.app.vr_mode
 443                        else (0.7, 0.65, 0.75, 1.0)
 444                    )
 445                    shadow = 1.0 if ba.app.vr_mode else 0.4
 446                    self._text = ba.NodeActor(
 447                        ba.newnode(
 448                            'text',
 449                            attrs={
 450                                'v_attach': 'top',
 451                                'h_attach': 'center',
 452                                'h_align': 'center',
 453                                'vr_depth': -20,
 454                                'shadow': shadow,
 455                                'flatness': 0.8,
 456                                'v_align': 'top',
 457                                'color': color2,
 458                                'scale': scl,
 459                                'maxwidth': 900.0 / scl,
 460                                'position': (0, -10),
 461                            },
 462                        )
 463                    )
 464                    self._change_phrase()
 465
 466        if not (app.demo_mode or app.arcade_mode) and not app.toolbar_test:
 467            self._news = News(self)
 468
 469        # Bring up the last place we were, or start at the main menu otherwise.
 470        with ba.Context('ui'):
 471            from bastd.ui import specialoffer
 472
 473            if bool(False):
 474                uicontroller = ba.app.ui.controller
 475                assert uicontroller is not None
 476                uicontroller.show_main_menu()
 477            else:
 478                main_menu_location = ba.app.ui.get_main_menu_location()
 479
 480                # When coming back from a kiosk-mode game, jump to
 481                # the kiosk start screen.
 482                if ba.app.demo_mode or ba.app.arcade_mode:
 483                    # pylint: disable=cyclic-import
 484                    from bastd.ui.kiosk import KioskWindow
 485
 486                    ba.app.ui.set_main_menu_window(
 487                        KioskWindow().get_root_widget()
 488                    )
 489                # ..or in normal cases go back to the main menu
 490                else:
 491                    if main_menu_location == 'Gather':
 492                        # pylint: disable=cyclic-import
 493                        from bastd.ui.gather import GatherWindow
 494
 495                        ba.app.ui.set_main_menu_window(
 496                            GatherWindow(transition=None).get_root_widget()
 497                        )
 498                    elif main_menu_location == 'Watch':
 499                        # pylint: disable=cyclic-import
 500                        from bastd.ui.watch import WatchWindow
 501
 502                        ba.app.ui.set_main_menu_window(
 503                            WatchWindow(transition=None).get_root_widget()
 504                        )
 505                    elif main_menu_location == 'Team Game Select':
 506                        # pylint: disable=cyclic-import
 507                        from bastd.ui.playlist.browser import (
 508                            PlaylistBrowserWindow,
 509                        )
 510
 511                        ba.app.ui.set_main_menu_window(
 512                            PlaylistBrowserWindow(
 513                                sessiontype=ba.DualTeamSession, transition=None
 514                            ).get_root_widget()
 515                        )
 516                    elif main_menu_location == 'Free-for-All Game Select':
 517                        # pylint: disable=cyclic-import
 518                        from bastd.ui.playlist.browser import (
 519                            PlaylistBrowserWindow,
 520                        )
 521
 522                        ba.app.ui.set_main_menu_window(
 523                            PlaylistBrowserWindow(
 524                                sessiontype=ba.FreeForAllSession,
 525                                transition=None,
 526                            ).get_root_widget()
 527                        )
 528                    elif main_menu_location == 'Coop Select':
 529                        # pylint: disable=cyclic-import
 530                        from bastd.ui.coop.browser import CoopBrowserWindow
 531
 532                        ba.app.ui.set_main_menu_window(
 533                            CoopBrowserWindow(transition=None).get_root_widget()
 534                        )
 535                    elif main_menu_location == 'Benchmarks & Stress Tests':
 536                        # pylint: disable=cyclic-import
 537                        from bastd.ui.debug import DebugWindow
 538
 539                        ba.app.ui.set_main_menu_window(
 540                            DebugWindow(transition=None).get_root_widget()
 541                        )
 542                    else:
 543                        # pylint: disable=cyclic-import
 544                        from bastd.ui.mainmenu import MainMenuWindow
 545
 546                        ba.app.ui.set_main_menu_window(
 547                            MainMenuWindow(transition=None).get_root_widget()
 548                        )
 549
 550                # attempt to show any pending offers immediately.
 551                # If that doesn't work, try again in a few seconds
 552                # (we may not have heard back from the server)
 553                # ..if that doesn't work they'll just have to wait
 554                # until the next opportunity.
 555                if not specialoffer.show_offer():
 556
 557                    def try_again() -> None:
 558                        if not specialoffer.show_offer():
 559                            # Try one last time..
 560                            ba.timer(
 561                                2.0,
 562                                specialoffer.show_offer,
 563                                timetype=ba.TimeType.REAL,
 564                            )
 565
 566                    ba.timer(2.0, try_again, timetype=ba.TimeType.REAL)
 567        ba.app.main_menu_did_initial_transition = True
 568
 569    def _update(self) -> None:
 570        app = ba.app
 571
 572        # Update logo in case it changes.
 573        if self._logo_node:
 574            custom_texture = self._get_custom_logo_tex_name()
 575            if custom_texture != self._custom_logo_tex_name:
 576                self._custom_logo_tex_name = custom_texture
 577                self._logo_node.texture = ba.gettexture(
 578                    custom_texture if custom_texture is not None else 'logo'
 579                )
 580                self._logo_node.model_opaque = (
 581                    None if custom_texture is not None else ba.getmodel('logo')
 582                )
 583                self._logo_node.model_transparent = (
 584                    None
 585                    if custom_texture is not None
 586                    else ba.getmodel('logoTransparent')
 587                )
 588
 589        # If language has changed, recreate our logo text/graphics.
 590        lang = app.lang.language
 591        if lang != self._language:
 592            self._language = lang
 593            y = 20
 594            base_scale = 1.1
 595            self._word_actors = []
 596            base_delay = 1.0
 597            delay = base_delay
 598            delay_inc = 0.02
 599
 600            # Come on faster after the first time.
 601            if app.main_menu_did_initial_transition:
 602                base_delay = 0.0
 603                delay = base_delay
 604                delay_inc = 0.02
 605
 606            # We draw higher in kiosk mode (make sure to test this
 607            # when making adjustments) for now we're hard-coded for
 608            # a few languages.. should maybe look into generalizing this?..
 609            if app.lang.language == 'Chinese':
 610                base_x = -270.0
 611                x = base_x - 20.0
 612                spacing = 85.0 * base_scale
 613                y_extra = 0.0 if (app.demo_mode or app.arcade_mode) else 0.0
 614                self._make_logo(
 615                    x - 110 + 50,
 616                    113 + y + 1.2 * y_extra,
 617                    0.34 * base_scale,
 618                    delay=base_delay + 0.1,
 619                    custom_texture='chTitleChar1',
 620                    jitter_scale=2.0,
 621                    vr_depth_offset=-30,
 622                )
 623                x += spacing
 624                delay += delay_inc
 625                self._make_logo(
 626                    x - 10 + 50,
 627                    110 + y + 1.2 * y_extra,
 628                    0.31 * base_scale,
 629                    delay=base_delay + 0.15,
 630                    custom_texture='chTitleChar2',
 631                    jitter_scale=2.0,
 632                    vr_depth_offset=-30,
 633                )
 634                x += 2.0 * spacing
 635                delay += delay_inc
 636                self._make_logo(
 637                    x + 180 - 140,
 638                    110 + y + 1.2 * y_extra,
 639                    0.3 * base_scale,
 640                    delay=base_delay + 0.25,
 641                    custom_texture='chTitleChar3',
 642                    jitter_scale=2.0,
 643                    vr_depth_offset=-30,
 644                )
 645                x += spacing
 646                delay += delay_inc
 647                self._make_logo(
 648                    x + 241 - 120,
 649                    110 + y + 1.2 * y_extra,
 650                    0.31 * base_scale,
 651                    delay=base_delay + 0.3,
 652                    custom_texture='chTitleChar4',
 653                    jitter_scale=2.0,
 654                    vr_depth_offset=-30,
 655                )
 656                x += spacing
 657                delay += delay_inc
 658                self._make_logo(
 659                    x + 300 - 90,
 660                    105 + y + 1.2 * y_extra,
 661                    0.34 * base_scale,
 662                    delay=base_delay + 0.35,
 663                    custom_texture='chTitleChar5',
 664                    jitter_scale=2.0,
 665                    vr_depth_offset=-30,
 666                )
 667                self._make_logo(
 668                    base_x + 155,
 669                    146 + y + 1.2 * y_extra,
 670                    0.28 * base_scale,
 671                    delay=base_delay + 0.2,
 672                    rotate=-7,
 673                )
 674            else:
 675                base_x = -170
 676                x = base_x - 20
 677                spacing = 55 * base_scale
 678                y_extra = 0 if (app.demo_mode or app.arcade_mode) else 0
 679                xv1 = x
 680                delay1 = delay
 681                for shadow in (True, False):
 682                    x = xv1
 683                    delay = delay1
 684                    self._make_word(
 685                        'B',
 686                        x - 50,
 687                        y - 23 + 0.8 * y_extra,
 688                        scale=1.3 * base_scale,
 689                        delay=delay,
 690                        vr_depth_offset=3,
 691                        shadow=shadow,
 692                    )
 693                    x += spacing
 694                    delay += delay_inc
 695                    self._make_word(
 696                        'm',
 697                        x,
 698                        y + y_extra,
 699                        delay=delay,
 700                        scale=base_scale,
 701                        shadow=shadow,
 702                    )
 703                    x += spacing * 1.25
 704                    delay += delay_inc
 705                    self._make_word(
 706                        'b',
 707                        x,
 708                        y + y_extra - 10,
 709                        delay=delay,
 710                        scale=1.1 * base_scale,
 711                        vr_depth_offset=5,
 712                        shadow=shadow,
 713                    )
 714                    x += spacing * 0.85
 715                    delay += delay_inc
 716                    self._make_word(
 717                        'S',
 718                        x,
 719                        y - 25 + 0.8 * y_extra,
 720                        scale=1.35 * base_scale,
 721                        delay=delay,
 722                        vr_depth_offset=14,
 723                        shadow=shadow,
 724                    )
 725                    x += spacing
 726                    delay += delay_inc
 727                    self._make_word(
 728                        'q',
 729                        x,
 730                        y + y_extra,
 731                        delay=delay,
 732                        scale=base_scale,
 733                        shadow=shadow,
 734                    )
 735                    x += spacing * 0.9
 736                    delay += delay_inc
 737                    self._make_word(
 738                        'u',
 739                        x,
 740                        y + y_extra,
 741                        delay=delay,
 742                        scale=base_scale,
 743                        vr_depth_offset=7,
 744                        shadow=shadow,
 745                    )
 746                    x += spacing * 0.9
 747                    delay += delay_inc
 748                    self._make_word(
 749                        'a',
 750                        x,
 751                        y + y_extra,
 752                        delay=delay,
 753                        scale=base_scale,
 754                        shadow=shadow,
 755                    )
 756                    x += spacing * 0.64
 757                    delay += delay_inc
 758                    self._make_word(
 759                        'd',
 760                        x,
 761                        y + y_extra - 10,
 762                        delay=delay,
 763                        scale=1.1 * base_scale,
 764                        vr_depth_offset=6,
 765                        shadow=shadow,
 766                    )
 767                self._make_logo(
 768                    base_x - 28,
 769                    125 + y + 1.2 * y_extra,
 770                    0.32 * base_scale,
 771                    delay=base_delay,
 772                )
 773
 774    def _make_word(
 775        self,
 776        word: str,
 777        x: float,
 778        y: float,
 779        scale: float = 1.0,
 780        delay: float = 0.0,
 781        vr_depth_offset: float = 0.0,
 782        shadow: bool = False,
 783    ) -> None:
 784        if shadow:
 785            word_obj = ba.NodeActor(
 786                ba.newnode(
 787                    'text',
 788                    attrs={
 789                        'position': (x, y),
 790                        'big': True,
 791                        'color': (0.0, 0.0, 0.2, 0.08),
 792                        'tilt_translate': 0.09,
 793                        'opacity_scales_shadow': False,
 794                        'shadow': 0.2,
 795                        'vr_depth': -130,
 796                        'v_align': 'center',
 797                        'project_scale': 0.97 * scale,
 798                        'scale': 1.0,
 799                        'text': word,
 800                    },
 801                )
 802            )
 803            self._word_actors.append(word_obj)
 804        else:
 805            word_obj = ba.NodeActor(
 806                ba.newnode(
 807                    'text',
 808                    attrs={
 809                        'position': (x, y),
 810                        'big': True,
 811                        'color': (1.2, 1.15, 1.15, 1.0),
 812                        'tilt_translate': 0.11,
 813                        'shadow': 0.2,
 814                        'vr_depth': -40 + vr_depth_offset,
 815                        'v_align': 'center',
 816                        'project_scale': scale,
 817                        'scale': 1.0,
 818                        'text': word,
 819                    },
 820                )
 821            )
 822            self._word_actors.append(word_obj)
 823
 824        # Add a bit of stop-motion-y jitter to the logo
 825        # (unless we're in VR mode in which case its best to
 826        # leave things still).
 827        if not ba.app.vr_mode:
 828            cmb: ba.Node | None
 829            cmb2: ba.Node | None
 830            if not shadow:
 831                cmb = ba.newnode(
 832                    'combine', owner=word_obj.node, attrs={'size': 2}
 833                )
 834            else:
 835                cmb = None
 836            if shadow:
 837                cmb2 = ba.newnode(
 838                    'combine', owner=word_obj.node, attrs={'size': 2}
 839                )
 840            else:
 841                cmb2 = None
 842            if not shadow:
 843                assert cmb and word_obj.node
 844                cmb.connectattr('output', word_obj.node, 'position')
 845            if shadow:
 846                assert cmb2 and word_obj.node
 847                cmb2.connectattr('output', word_obj.node, 'position')
 848            keys = {}
 849            keys2 = {}
 850            time_v = 0.0
 851            for _i in range(10):
 852                val = x + (random.random() - 0.5) * 0.8
 853                val2 = x + (random.random() - 0.5) * 0.8
 854                keys[time_v * self._ts] = val
 855                keys2[time_v * self._ts] = val2 + 5
 856                time_v += random.random() * 0.1
 857            if cmb is not None:
 858                ba.animate(cmb, 'input0', keys, loop=True)
 859            if cmb2 is not None:
 860                ba.animate(cmb2, 'input0', keys2, loop=True)
 861            keys = {}
 862            keys2 = {}
 863            time_v = 0
 864            for _i in range(10):
 865                val = y + (random.random() - 0.5) * 0.8
 866                val2 = y + (random.random() - 0.5) * 0.8
 867                keys[time_v * self._ts] = val
 868                keys2[time_v * self._ts] = val2 - 9
 869                time_v += random.random() * 0.1
 870            if cmb is not None:
 871                ba.animate(cmb, 'input1', keys, loop=True)
 872            if cmb2 is not None:
 873                ba.animate(cmb2, 'input1', keys2, loop=True)
 874
 875        if not shadow:
 876            assert word_obj.node
 877            ba.animate(
 878                word_obj.node,
 879                'project_scale',
 880                {delay: 0.0, delay + 0.1: scale * 1.1, delay + 0.2: scale},
 881            )
 882        else:
 883            assert word_obj.node
 884            ba.animate(
 885                word_obj.node,
 886                'project_scale',
 887                {delay: 0.0, delay + 0.1: scale * 1.1, delay + 0.2: scale},
 888            )
 889
 890    def _get_custom_logo_tex_name(self) -> str | None:
 891        if ba.internal.get_v1_account_misc_read_val('easter', False):
 892            return 'logoEaster'
 893        return None
 894
 895    # Pop the logo and menu in.
 896    def _make_logo(
 897        self,
 898        x: float,
 899        y: float,
 900        scale: float,
 901        delay: float,
 902        custom_texture: str | None = None,
 903        jitter_scale: float = 1.0,
 904        rotate: float = 0.0,
 905        vr_depth_offset: float = 0.0,
 906    ) -> None:
 907
 908        # Temp easter goodness.
 909        if custom_texture is None:
 910            custom_texture = self._get_custom_logo_tex_name()
 911        self._custom_logo_tex_name = custom_texture
 912        ltex = ba.gettexture(
 913            custom_texture if custom_texture is not None else 'logo'
 914        )
 915        mopaque = None if custom_texture is not None else ba.getmodel('logo')
 916        mtrans = (
 917            None
 918            if custom_texture is not None
 919            else ba.getmodel('logoTransparent')
 920        )
 921        logo = ba.NodeActor(
 922            ba.newnode(
 923                'image',
 924                attrs={
 925                    'texture': ltex,
 926                    'model_opaque': mopaque,
 927                    'model_transparent': mtrans,
 928                    'vr_depth': -10 + vr_depth_offset,
 929                    'rotate': rotate,
 930                    'attach': 'center',
 931                    'tilt_translate': 0.21,
 932                    'absolute_scale': True,
 933                },
 934            )
 935        )
 936        self._logo_node = logo.node
 937        self._word_actors.append(logo)
 938
 939        # Add a bit of stop-motion-y jitter to the logo
 940        # (unless we're in VR mode in which case its best to
 941        # leave things still).
 942        assert logo.node
 943        if not ba.app.vr_mode:
 944            cmb = ba.newnode('combine', owner=logo.node, attrs={'size': 2})
 945            cmb.connectattr('output', logo.node, 'position')
 946            keys = {}
 947            time_v = 0.0
 948
 949            # Gen some random keys for that stop-motion-y look
 950            for _i in range(10):
 951                keys[time_v] = x + (random.random() - 0.5) * 0.7 * jitter_scale
 952                time_v += random.random() * 0.1
 953            ba.animate(cmb, 'input0', keys, loop=True)
 954            keys = {}
 955            time_v = 0.0
 956            for _i in range(10):
 957                keys[time_v * self._ts] = (
 958                    y + (random.random() - 0.5) * 0.7 * jitter_scale
 959                )
 960                time_v += random.random() * 0.1
 961            ba.animate(cmb, 'input1', keys, loop=True)
 962        else:
 963            logo.node.position = (x, y)
 964
 965        cmb = ba.newnode('combine', owner=logo.node, attrs={'size': 2})
 966
 967        keys = {
 968            delay: 0.0,
 969            delay + 0.1: 700.0 * scale,
 970            delay + 0.2: 600.0 * scale,
 971        }
 972        ba.animate(cmb, 'input0', keys)
 973        ba.animate(cmb, 'input1', keys)
 974        cmb.connectattr('output', logo.node, 'scale')
 975
 976    def _start_preloads(self) -> None:
 977        # FIXME: The func that calls us back doesn't save/restore state
 978        #  or check for a dead activity so we have to do that ourself.
 979        if self.expired:
 980            return
 981        with ba.Context(self):
 982            _preload1()
 983
 984        ba.timer(0.5, lambda: ba.setmusic(ba.MusicType.MENU))
 985
 986
 987def _preload1() -> None:
 988    """Pre-load some assets a second or two into the main menu.
 989
 990    Helps avoid hitches later on.
 991    """
 992    for mname in [
 993        'plasticEyesTransparent',
 994        'playerLineup1Transparent',
 995        'playerLineup2Transparent',
 996        'playerLineup3Transparent',
 997        'playerLineup4Transparent',
 998        'angryComputerTransparent',
 999        'scrollWidgetShort',
1000        'windowBGBlotch',
1001    ]:
1002        ba.getmodel(mname)
1003    for tname in ['playerLineup', 'lock']:
1004        ba.gettexture(tname)
1005    for tex in [
1006        'iconRunaround',
1007        'iconOnslaught',
1008        'medalComplete',
1009        'medalBronze',
1010        'medalSilver',
1011        'medalGold',
1012        'characterIconMask',
1013    ]:
1014        ba.gettexture(tex)
1015    ba.gettexture('bg')
1016    from bastd.actor.powerupbox import PowerupBoxFactory
1017
1018    PowerupBoxFactory.get()
1019    ba.timer(0.1, _preload2)
1020
1021
1022def _preload2() -> None:
1023    # FIXME: Could integrate these loads with the classes that use them
1024    #  so they don't have to redundantly call the load
1025    #  (even if the actual result is cached).
1026    for mname in ['powerup', 'powerupSimple']:
1027        ba.getmodel(mname)
1028    for tname in [
1029        'powerupBomb',
1030        'powerupSpeed',
1031        'powerupPunch',
1032        'powerupIceBombs',
1033        'powerupStickyBombs',
1034        'powerupShield',
1035        'powerupImpactBombs',
1036        'powerupHealth',
1037    ]:
1038        ba.gettexture(tname)
1039    for sname in [
1040        'powerup01',
1041        'boxDrop',
1042        'boxingBell',
1043        'scoreHit01',
1044        'scoreHit02',
1045        'dripity',
1046        'spawn',
1047        'gong',
1048    ]:
1049        ba.getsound(sname)
1050    from bastd.actor.bomb import BombFactory
1051
1052    BombFactory.get()
1053    ba.timer(0.1, _preload3)
1054
1055
1056def _preload3() -> None:
1057    from bastd.actor.spazfactory import SpazFactory
1058
1059    for mname in ['bomb', 'bombSticky', 'impactBomb']:
1060        ba.getmodel(mname)
1061    for tname in [
1062        'bombColor',
1063        'bombColorIce',
1064        'bombStickyColor',
1065        'impactBombColor',
1066        'impactBombColorLit',
1067    ]:
1068        ba.gettexture(tname)
1069    for sname in ['freeze', 'fuse01', 'activateBeep', 'warnBeep']:
1070        ba.getsound(sname)
1071    SpazFactory.get()
1072    ba.timer(0.2, _preload4)
1073
1074
1075def _preload4() -> None:
1076    for tname in ['bar', 'meter', 'null', 'flagColor', 'achievementOutline']:
1077        ba.gettexture(tname)
1078    for mname in ['frameInset', 'meterTransparent', 'achievementOutline']:
1079        ba.getmodel(mname)
1080    for sname in ['metalHit', 'metalSkid', 'refWhistle', 'achievement']:
1081        ba.getsound(sname)
1082    from bastd.actor.flag import FlagFactory
1083
1084    FlagFactory.get()
1085
1086
1087class MainMenuSession(ba.Session):
1088    """Session that runs the main menu environment."""
1089
1090    def __init__(self) -> None:
1091
1092        # Gather dependencies we'll need (just our activity).
1093        self._activity_deps = ba.DependencySet(ba.Dependency(MainMenuActivity))
1094
1095        super().__init__([self._activity_deps])
1096        self._locked = False
1097        self.setactivity(ba.newactivity(MainMenuActivity))
1098
1099    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
1100        if self._locked:
1101            ba.internal.unlock_all_input()
1102
1103        # Any ending activity leads us into the main menu one.
1104        self.setactivity(ba.newactivity(MainMenuActivity))
1105
1106    def on_player_request(self, player: ba.SessionPlayer) -> bool:
1107        # Reject all player requests.
1108        return False