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

Wrangles news display.

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