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

Wrangles news display.

NewsDisplay(activity: bascenev1.Activity)
715    def __init__(self, activity: bs.Activity):
716        self._valid = True
717        self._message_duration = 10.0
718        self._message_spacing = 2.0
719        self._text: bs.NodeActor | None = None
720        self._activity = weakref.ref(activity)
721        self._phrases: list[str] = []
722        self._used_phrases: list[str] = []
723        self._phrase_change_timer: bs.Timer | None = None
724
725        # If we're signed in, fetch news immediately. Otherwise wait
726        # until we are signed in.
727        self._fetch_timer: bs.Timer | None = bs.Timer(
728            1.0, bs.WeakCall(self._try_fetching_news), repeat=True
729        )
730        self._try_fetching_news()