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

Wrangles news display.

NewsDisplay(activity: bascenev1.Activity)
676    def __init__(self, activity: bs.Activity):
677        self._valid = True
678        self._message_duration = 10.0
679        self._message_spacing = 2.0
680        self._text: bs.NodeActor | None = None
681        self._activity = weakref.ref(activity)
682        self._phrases: list[str] = []
683        self._used_phrases: list[str] = []
684        self._phrase_change_timer: bs.Timer | None = None
685
686        # If we're signed in, fetch news immediately. Otherwise wait
687        # until we are signed in.
688        self._fetch_timer: bs.Timer | None = bs.Timer(
689            1.0, bs.WeakCall(self._try_fetching_news), repeat=True
690        )
691        self._try_fetching_news()