bascenev1lib.actor.controlsguide

Defines Actors related to controls guides.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Defines Actors related to controls guides."""
  4
  5from __future__ import annotations
  6
  7from typing import TYPE_CHECKING
  8
  9import bascenev1 as bs
 10
 11if TYPE_CHECKING:
 12    from typing import Any, Sequence
 13
 14
 15class ControlsGuide(bs.Actor):
 16    """A screen overlay of game controls.
 17
 18    category: Gameplay Classes
 19
 20    Shows button mappings based on what controllers are connected.
 21    Handy to show at the start of a series or whenever there might
 22    be newbies watching.
 23    """
 24
 25    def __init__(
 26        self,
 27        position: tuple[float, float] = (390.0, 120.0),
 28        scale: float = 1.0,
 29        delay: float = 0.0,
 30        lifespan: float | None = None,
 31        bright: bool = False,
 32    ):
 33        """Instantiate an overlay.
 34
 35        delay: is the time in seconds before the overlay fades in.
 36
 37        lifespan: if not None, the overlay will fade back out and die after
 38                  that long (in milliseconds).
 39
 40        bright: if True, brighter colors will be used; handy when showing
 41                over gameplay but may be too bright for join-screens, etc.
 42        """
 43        # pylint: disable=too-many-statements
 44        # pylint: disable=too-many-locals
 45        super().__init__()
 46        show_title = True
 47        scale *= 0.75
 48        image_size = 90.0 * scale
 49        offs = 74.0 * scale
 50        offs5 = 43.0 * scale
 51        ouya = False
 52        maxw = 50
 53        self._lifespan = lifespan
 54        self._dead = False
 55        self._bright = bright
 56        self._cancel_timer: bs.Timer | None = None
 57        self._fade_in_timer: bs.Timer | None = None
 58        self._update_timer: bs.Timer | None = None
 59        self._title_text: bs.Node | None
 60        clr: Sequence[float]
 61        punch_pos = (position[0] - offs * 1.1, position[1])
 62        jump_pos = (position[0], position[1] - offs)
 63        bomb_pos = (position[0] + offs * 1.1, position[1])
 64        pickup_pos = (position[0], position[1] + offs)
 65        self._force_hide_button_names = False
 66
 67        if show_title:
 68            self._title_text_pos_top = (
 69                position[0],
 70                position[1] + 139.0 * scale,
 71            )
 72            self._title_text_pos_bottom = (
 73                position[0],
 74                position[1] + 139.0 * scale,
 75            )
 76            clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
 77            tval = bs.Lstr(
 78                value='${A}:', subs=[('${A}', bs.Lstr(resource='controlsText'))]
 79            )
 80            self._title_text = bs.newnode(
 81                'text',
 82                attrs={
 83                    'text': tval,
 84                    'host_only': True,
 85                    'scale': 1.1 * scale,
 86                    'shadow': 0.5,
 87                    'flatness': 1.0,
 88                    'maxwidth': 480,
 89                    'v_align': 'center',
 90                    'h_align': 'center',
 91                    'color': clr,
 92                },
 93            )
 94        else:
 95            self._title_text = None
 96        pos = jump_pos
 97        clr = (0.4, 1, 0.4)
 98        self._jump_image = bs.newnode(
 99            'image',
100            attrs={
101                'texture': bs.gettexture('buttonJump'),
102                'absolute_scale': True,
103                'host_only': True,
104                'vr_depth': 10,
105                'position': pos,
106                'scale': (image_size, image_size),
107                'color': clr,
108            },
109        )
110        self._jump_text = bs.newnode(
111            'text',
112            attrs={
113                'v_align': 'top',
114                'h_align': 'center',
115                'scale': 1.5 * scale,
116                'flatness': 1.0,
117                'host_only': True,
118                'shadow': 1.0,
119                'maxwidth': maxw,
120                'position': (pos[0], pos[1] - offs5),
121                'color': clr,
122            },
123        )
124        clr = (0.2, 0.6, 1) if ouya else (1, 0.7, 0.3)
125        pos = punch_pos
126        self._punch_image = bs.newnode(
127            'image',
128            attrs={
129                'texture': bs.gettexture('buttonPunch'),
130                'absolute_scale': True,
131                'host_only': True,
132                'vr_depth': 10,
133                'position': pos,
134                'scale': (image_size, image_size),
135                'color': clr,
136            },
137        )
138        self._punch_text = bs.newnode(
139            'text',
140            attrs={
141                'v_align': 'top',
142                'h_align': 'center',
143                'scale': 1.5 * scale,
144                'flatness': 1.0,
145                'host_only': True,
146                'shadow': 1.0,
147                'maxwidth': maxw,
148                'position': (pos[0], pos[1] - offs5),
149                'color': clr,
150            },
151        )
152        pos = bomb_pos
153        clr = (1, 0.3, 0.3)
154        self._bomb_image = bs.newnode(
155            'image',
156            attrs={
157                'texture': bs.gettexture('buttonBomb'),
158                'absolute_scale': True,
159                'host_only': True,
160                'vr_depth': 10,
161                'position': pos,
162                'scale': (image_size, image_size),
163                'color': clr,
164            },
165        )
166        self._bomb_text = bs.newnode(
167            'text',
168            attrs={
169                'h_align': 'center',
170                'v_align': 'top',
171                'scale': 1.5 * scale,
172                'flatness': 1.0,
173                'host_only': True,
174                'shadow': 1.0,
175                'maxwidth': maxw,
176                'position': (pos[0], pos[1] - offs5),
177                'color': clr,
178            },
179        )
180        pos = pickup_pos
181        clr = (1, 0.8, 0.3) if ouya else (0.8, 0.5, 1)
182        self._pickup_image = bs.newnode(
183            'image',
184            attrs={
185                'texture': bs.gettexture('buttonPickUp'),
186                'absolute_scale': True,
187                'host_only': True,
188                'vr_depth': 10,
189                'position': pos,
190                'scale': (image_size, image_size),
191                'color': clr,
192            },
193        )
194        self._pick_up_text = bs.newnode(
195            'text',
196            attrs={
197                'v_align': 'top',
198                'h_align': 'center',
199                'scale': 1.5 * scale,
200                'flatness': 1.0,
201                'host_only': True,
202                'shadow': 1.0,
203                'maxwidth': maxw,
204                'position': (pos[0], pos[1] - offs5),
205                'color': clr,
206            },
207        )
208        clr = (0.9, 0.9, 2.0, 1.0) if bright else (0.8, 0.8, 2.0, 1.0)
209        self._run_text_pos_top = (position[0], position[1] - 135.0 * scale)
210        self._run_text_pos_bottom = (position[0], position[1] - 172.0 * scale)
211        sval = 1.0 * scale if bs.app.env.vr else 0.8 * scale
212        self._run_text = bs.newnode(
213            'text',
214            attrs={
215                'scale': sval,
216                'host_only': True,
217                'shadow': 1.0 if bs.app.env.vr else 0.5,
218                'flatness': 1.0,
219                'maxwidth': 380,
220                'v_align': 'top',
221                'h_align': 'center',
222                'color': clr,
223            },
224        )
225        clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
226        self._extra_text = bs.newnode(
227            'text',
228            attrs={
229                'scale': 0.8 * scale,
230                'host_only': True,
231                'shadow': 0.5,
232                'flatness': 1.0,
233                'maxwidth': 380,
234                'v_align': 'top',
235                'h_align': 'center',
236                'color': clr,
237            },
238        )
239
240        self._extra_image_1 = None
241        self._extra_image_2 = None
242
243        self._nodes = [
244            self._bomb_image,
245            self._bomb_text,
246            self._punch_image,
247            self._punch_text,
248            self._jump_image,
249            self._jump_text,
250            self._pickup_image,
251            self._pick_up_text,
252            self._run_text,
253            self._extra_text,
254        ]
255        if show_title:
256            assert self._title_text
257            self._nodes.append(self._title_text)
258
259        # Start everything invisible.
260        for node in self._nodes:
261            node.opacity = 0.0
262
263        # Don't do anything until our delay has passed.
264        bs.timer(delay, bs.WeakCall(self._start_updating))
265
266    @staticmethod
267    def _meaningful_button_name(device: bs.InputDevice, button: int) -> str:
268        """Return a flattened string button name; empty for non-meaningful."""
269        if not device.has_meaningful_button_names:
270            return ''
271        return device.get_button_name(button).evaluate()
272
273    def _start_updating(self) -> None:
274        # Ok, our delay has passed. Now lets periodically see if we can fade
275        # in (if a touch-screen is present we only want to show up if gamepads
276        # are connected, etc).
277        # Also set up a timer so if we haven't faded in by the end of our
278        # duration, abort.
279        if self._lifespan is not None:
280            self._cancel_timer = bs.Timer(
281                self._lifespan,
282                bs.WeakCall(self.handlemessage, bs.DieMessage(immediate=True)),
283            )
284        self._fade_in_timer = bs.Timer(
285            1.0, bs.WeakCall(self._check_fade_in), repeat=True
286        )
287        self._check_fade_in()  # Do one check immediately.
288
289    def _check_fade_in(self) -> None:
290        assert bs.app.classic is not None
291
292        # If we have a touchscreen, we only fade in if we have a player with
293        # an input device that is *not* the touchscreen.
294        # (otherwise it is confusing to see the touchscreen buttons right
295        # next to our display buttons)
296        touchscreen: bs.InputDevice | None = bs.getinputdevice(
297            'TouchScreen', '#1', doraise=False
298        )
299
300        if touchscreen is not None:
301            # We look at the session's players; not the activity's.
302            # We want to get ones who are still in the process of
303            # selecting a character, etc.
304            input_devices = [
305                p.inputdevice for p in bs.getsession().sessionplayers
306            ]
307            input_devices = [
308                i for i in input_devices if i and i is not touchscreen
309            ]
310            fade_in = False
311            if input_devices:
312                # Only count this one if it has non-empty button names
313                # (filters out wiimotes, the remote-app, etc).
314                for device in input_devices:
315                    for name in (
316                        'buttonPunch',
317                        'buttonJump',
318                        'buttonBomb',
319                        'buttonPickUp',
320                    ):
321                        if (
322                            self._meaningful_button_name(
323                                device,
324                                bs.app.classic.get_input_device_mapped_value(
325                                    device, name
326                                ),
327                            )
328                            != ''
329                        ):
330                            fade_in = True
331                            break
332                    if fade_in:
333                        break  # No need to keep looking.
334        else:
335            # No touch-screen; fade in immediately.
336            fade_in = True
337        if fade_in:
338            self._cancel_timer = None  # Didn't need this.
339            self._fade_in_timer = None  # Done with this.
340            self._fade_in()
341
342    def _fade_in(self) -> None:
343        for node in self._nodes:
344            bs.animate(node, 'opacity', {0: 0.0, 2.0: 1.0})
345
346        # If we were given a lifespan, transition out after it.
347        if self._lifespan is not None:
348            bs.timer(
349                self._lifespan, bs.WeakCall(self.handlemessage, bs.DieMessage())
350            )
351        self._update()
352        self._update_timer = bs.Timer(
353            1.0, bs.WeakCall(self._update), repeat=True
354        )
355
356    def _update(self) -> None:
357        # pylint: disable=too-many-statements
358        # pylint: disable=too-many-branches
359        # pylint: disable=too-many-locals
360
361        if self._dead:
362            return
363
364        classic = bs.app.classic
365        assert classic is not None
366
367        punch_button_names = set()
368        jump_button_names = set()
369        pickup_button_names = set()
370        bomb_button_names = set()
371
372        # We look at the session's players; not the activity's - we want to
373        # get ones who are still in the process of selecting a character, etc.
374        input_devices = [p.inputdevice for p in bs.getsession().sessionplayers]
375        input_devices = [i for i in input_devices if i]
376
377        # If there's no players with input devices yet, try to default to
378        # showing keyboard controls.
379        if not input_devices:
380            kbd = bs.getinputdevice('Keyboard', '#1', doraise=False)
381            if kbd is not None:
382                input_devices.append(kbd)
383
384        # We word things specially if we have nothing but keyboards.
385        all_keyboards = input_devices and all(
386            i.name == 'Keyboard' for i in input_devices
387        )
388        only_remote = len(input_devices) == 1 and all(
389            i.name == 'Amazon Fire TV Remote' for i in input_devices
390        )
391
392        right_button_names = set()
393        left_button_names = set()
394        up_button_names = set()
395        down_button_names = set()
396
397        # For each player in the game with an input device,
398        # get the name of the button for each of these 4 actions.
399        # If any of them are uniform across all devices, display the name.
400        for device in input_devices:
401            # We only care about movement buttons in the case of keyboards.
402            if all_keyboards:
403                right_button_names.add(
404                    device.get_button_name(
405                        classic.get_input_device_mapped_value(
406                            device, 'buttonRight'
407                        )
408                    )
409                )
410                left_button_names.add(
411                    device.get_button_name(
412                        classic.get_input_device_mapped_value(
413                            device, 'buttonLeft'
414                        )
415                    )
416                )
417                down_button_names.add(
418                    device.get_button_name(
419                        classic.get_input_device_mapped_value(
420                            device, 'buttonDown'
421                        )
422                    )
423                )
424                up_button_names.add(
425                    device.get_button_name(
426                        classic.get_input_device_mapped_value(
427                            device, 'buttonUp'
428                        )
429                    )
430                )
431
432            # Ignore empty values; things like the remote app or
433            # wiimotes can return these.
434            bname = self._meaningful_button_name(
435                device,
436                classic.get_input_device_mapped_value(device, 'buttonPunch'),
437            )
438            if bname != '':
439                punch_button_names.add(bname)
440            bname = self._meaningful_button_name(
441                device,
442                classic.get_input_device_mapped_value(device, 'buttonJump'),
443            )
444            if bname != '':
445                jump_button_names.add(bname)
446            bname = self._meaningful_button_name(
447                device,
448                classic.get_input_device_mapped_value(device, 'buttonBomb'),
449            )
450            if bname != '':
451                bomb_button_names.add(bname)
452            bname = self._meaningful_button_name(
453                device,
454                classic.get_input_device_mapped_value(device, 'buttonPickUp'),
455            )
456            if bname != '':
457                pickup_button_names.add(bname)
458
459        # If we have no values yet, we may want to throw out some sane
460        # defaults.
461        if all(
462            not lst
463            for lst in (
464                punch_button_names,
465                jump_button_names,
466                bomb_button_names,
467                pickup_button_names,
468            )
469        ):
470            # Otherwise on android show standard buttons.
471            if classic.platform == 'android':
472                punch_button_names.add('X')
473                jump_button_names.add('A')
474                bomb_button_names.add('B')
475                pickup_button_names.add('Y')
476
477        run_text = bs.Lstr(
478            value='${R}: ${B}',
479            subs=[
480                ('${R}', bs.Lstr(resource='runText')),
481                (
482                    '${B}',
483                    bs.Lstr(
484                        resource='holdAnyKeyText'
485                        if all_keyboards
486                        else 'holdAnyButtonText'
487                    ),
488                ),
489            ],
490        )
491
492        # If we're all keyboards, lets show move keys too.
493        if (
494            all_keyboards
495            and len(up_button_names) == 1
496            and len(down_button_names) == 1
497            and len(left_button_names) == 1
498            and len(right_button_names) == 1
499        ):
500            up_text = list(up_button_names)[0]
501            down_text = list(down_button_names)[0]
502            left_text = list(left_button_names)[0]
503            right_text = list(right_button_names)[0]
504            run_text = bs.Lstr(
505                value='${M}: ${U}, ${L}, ${D}, ${R}\n${RUN}',
506                subs=[
507                    ('${M}', bs.Lstr(resource='moveText')),
508                    ('${U}', up_text),
509                    ('${L}', left_text),
510                    ('${D}', down_text),
511                    ('${R}', right_text),
512                    ('${RUN}', run_text),
513                ],
514            )
515
516        if self._force_hide_button_names:
517            jump_button_names.clear()
518            punch_button_names.clear()
519            bomb_button_names.clear()
520            pickup_button_names.clear()
521
522        self._run_text.text = run_text
523        w_text: bs.Lstr | str
524        if only_remote and self._lifespan is None:
525            w_text = bs.Lstr(
526                resource='fireTVRemoteWarningText',
527                subs=[('${REMOTE_APP_NAME}', bs.get_remote_app_name())],
528            )
529        else:
530            w_text = ''
531        self._extra_text.text = w_text
532        if len(punch_button_names) == 1:
533            self._punch_text.text = list(punch_button_names)[0]
534        else:
535            self._punch_text.text = ''
536
537        if len(jump_button_names) == 1:
538            tval = list(jump_button_names)[0]
539        else:
540            tval = ''
541        self._jump_text.text = tval
542        if tval == '':
543            self._run_text.position = self._run_text_pos_top
544            self._extra_text.position = (
545                self._run_text_pos_top[0],
546                self._run_text_pos_top[1] - 50,
547            )
548        else:
549            self._run_text.position = self._run_text_pos_bottom
550            self._extra_text.position = (
551                self._run_text_pos_bottom[0],
552                self._run_text_pos_bottom[1] - 50,
553            )
554        if len(bomb_button_names) == 1:
555            self._bomb_text.text = list(bomb_button_names)[0]
556        else:
557            self._bomb_text.text = ''
558
559        # Also move our title up/down depending on if this is shown.
560        if len(pickup_button_names) == 1:
561            self._pick_up_text.text = list(pickup_button_names)[0]
562            if self._title_text is not None:
563                self._title_text.position = self._title_text_pos_top
564        else:
565            self._pick_up_text.text = ''
566            if self._title_text is not None:
567                self._title_text.position = self._title_text_pos_bottom
568
569    def _die(self) -> None:
570        for node in self._nodes:
571            node.delete()
572        self._nodes = []
573        self._update_timer = None
574        self._dead = True
575
576    def exists(self) -> bool:
577        return not self._dead
578
579    def handlemessage(self, msg: Any) -> Any:
580        assert not self.expired
581        if isinstance(msg, bs.DieMessage):
582            if msg.immediate:
583                self._die()
584            else:
585                # If they don't need immediate,
586                # fade out our nodes and die later.
587                for node in self._nodes:
588                    bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
589                bs.timer(3.1, bs.WeakCall(self._die))
590            return None
591        return super().handlemessage(msg)
class ControlsGuide(bascenev1._actor.Actor):
 16class ControlsGuide(bs.Actor):
 17    """A screen overlay of game controls.
 18
 19    category: Gameplay Classes
 20
 21    Shows button mappings based on what controllers are connected.
 22    Handy to show at the start of a series or whenever there might
 23    be newbies watching.
 24    """
 25
 26    def __init__(
 27        self,
 28        position: tuple[float, float] = (390.0, 120.0),
 29        scale: float = 1.0,
 30        delay: float = 0.0,
 31        lifespan: float | None = None,
 32        bright: bool = False,
 33    ):
 34        """Instantiate an overlay.
 35
 36        delay: is the time in seconds before the overlay fades in.
 37
 38        lifespan: if not None, the overlay will fade back out and die after
 39                  that long (in milliseconds).
 40
 41        bright: if True, brighter colors will be used; handy when showing
 42                over gameplay but may be too bright for join-screens, etc.
 43        """
 44        # pylint: disable=too-many-statements
 45        # pylint: disable=too-many-locals
 46        super().__init__()
 47        show_title = True
 48        scale *= 0.75
 49        image_size = 90.0 * scale
 50        offs = 74.0 * scale
 51        offs5 = 43.0 * scale
 52        ouya = False
 53        maxw = 50
 54        self._lifespan = lifespan
 55        self._dead = False
 56        self._bright = bright
 57        self._cancel_timer: bs.Timer | None = None
 58        self._fade_in_timer: bs.Timer | None = None
 59        self._update_timer: bs.Timer | None = None
 60        self._title_text: bs.Node | None
 61        clr: Sequence[float]
 62        punch_pos = (position[0] - offs * 1.1, position[1])
 63        jump_pos = (position[0], position[1] - offs)
 64        bomb_pos = (position[0] + offs * 1.1, position[1])
 65        pickup_pos = (position[0], position[1] + offs)
 66        self._force_hide_button_names = False
 67
 68        if show_title:
 69            self._title_text_pos_top = (
 70                position[0],
 71                position[1] + 139.0 * scale,
 72            )
 73            self._title_text_pos_bottom = (
 74                position[0],
 75                position[1] + 139.0 * scale,
 76            )
 77            clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
 78            tval = bs.Lstr(
 79                value='${A}:', subs=[('${A}', bs.Lstr(resource='controlsText'))]
 80            )
 81            self._title_text = bs.newnode(
 82                'text',
 83                attrs={
 84                    'text': tval,
 85                    'host_only': True,
 86                    'scale': 1.1 * scale,
 87                    'shadow': 0.5,
 88                    'flatness': 1.0,
 89                    'maxwidth': 480,
 90                    'v_align': 'center',
 91                    'h_align': 'center',
 92                    'color': clr,
 93                },
 94            )
 95        else:
 96            self._title_text = None
 97        pos = jump_pos
 98        clr = (0.4, 1, 0.4)
 99        self._jump_image = bs.newnode(
100            'image',
101            attrs={
102                'texture': bs.gettexture('buttonJump'),
103                'absolute_scale': True,
104                'host_only': True,
105                'vr_depth': 10,
106                'position': pos,
107                'scale': (image_size, image_size),
108                'color': clr,
109            },
110        )
111        self._jump_text = bs.newnode(
112            'text',
113            attrs={
114                'v_align': 'top',
115                'h_align': 'center',
116                'scale': 1.5 * scale,
117                'flatness': 1.0,
118                'host_only': True,
119                'shadow': 1.0,
120                'maxwidth': maxw,
121                'position': (pos[0], pos[1] - offs5),
122                'color': clr,
123            },
124        )
125        clr = (0.2, 0.6, 1) if ouya else (1, 0.7, 0.3)
126        pos = punch_pos
127        self._punch_image = bs.newnode(
128            'image',
129            attrs={
130                'texture': bs.gettexture('buttonPunch'),
131                'absolute_scale': True,
132                'host_only': True,
133                'vr_depth': 10,
134                'position': pos,
135                'scale': (image_size, image_size),
136                'color': clr,
137            },
138        )
139        self._punch_text = bs.newnode(
140            'text',
141            attrs={
142                'v_align': 'top',
143                'h_align': 'center',
144                'scale': 1.5 * scale,
145                'flatness': 1.0,
146                'host_only': True,
147                'shadow': 1.0,
148                'maxwidth': maxw,
149                'position': (pos[0], pos[1] - offs5),
150                'color': clr,
151            },
152        )
153        pos = bomb_pos
154        clr = (1, 0.3, 0.3)
155        self._bomb_image = bs.newnode(
156            'image',
157            attrs={
158                'texture': bs.gettexture('buttonBomb'),
159                'absolute_scale': True,
160                'host_only': True,
161                'vr_depth': 10,
162                'position': pos,
163                'scale': (image_size, image_size),
164                'color': clr,
165            },
166        )
167        self._bomb_text = bs.newnode(
168            'text',
169            attrs={
170                'h_align': 'center',
171                'v_align': 'top',
172                'scale': 1.5 * scale,
173                'flatness': 1.0,
174                'host_only': True,
175                'shadow': 1.0,
176                'maxwidth': maxw,
177                'position': (pos[0], pos[1] - offs5),
178                'color': clr,
179            },
180        )
181        pos = pickup_pos
182        clr = (1, 0.8, 0.3) if ouya else (0.8, 0.5, 1)
183        self._pickup_image = bs.newnode(
184            'image',
185            attrs={
186                'texture': bs.gettexture('buttonPickUp'),
187                'absolute_scale': True,
188                'host_only': True,
189                'vr_depth': 10,
190                'position': pos,
191                'scale': (image_size, image_size),
192                'color': clr,
193            },
194        )
195        self._pick_up_text = bs.newnode(
196            'text',
197            attrs={
198                'v_align': 'top',
199                'h_align': 'center',
200                'scale': 1.5 * scale,
201                'flatness': 1.0,
202                'host_only': True,
203                'shadow': 1.0,
204                'maxwidth': maxw,
205                'position': (pos[0], pos[1] - offs5),
206                'color': clr,
207            },
208        )
209        clr = (0.9, 0.9, 2.0, 1.0) if bright else (0.8, 0.8, 2.0, 1.0)
210        self._run_text_pos_top = (position[0], position[1] - 135.0 * scale)
211        self._run_text_pos_bottom = (position[0], position[1] - 172.0 * scale)
212        sval = 1.0 * scale if bs.app.env.vr else 0.8 * scale
213        self._run_text = bs.newnode(
214            'text',
215            attrs={
216                'scale': sval,
217                'host_only': True,
218                'shadow': 1.0 if bs.app.env.vr else 0.5,
219                'flatness': 1.0,
220                'maxwidth': 380,
221                'v_align': 'top',
222                'h_align': 'center',
223                'color': clr,
224            },
225        )
226        clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
227        self._extra_text = bs.newnode(
228            'text',
229            attrs={
230                'scale': 0.8 * scale,
231                'host_only': True,
232                'shadow': 0.5,
233                'flatness': 1.0,
234                'maxwidth': 380,
235                'v_align': 'top',
236                'h_align': 'center',
237                'color': clr,
238            },
239        )
240
241        self._extra_image_1 = None
242        self._extra_image_2 = None
243
244        self._nodes = [
245            self._bomb_image,
246            self._bomb_text,
247            self._punch_image,
248            self._punch_text,
249            self._jump_image,
250            self._jump_text,
251            self._pickup_image,
252            self._pick_up_text,
253            self._run_text,
254            self._extra_text,
255        ]
256        if show_title:
257            assert self._title_text
258            self._nodes.append(self._title_text)
259
260        # Start everything invisible.
261        for node in self._nodes:
262            node.opacity = 0.0
263
264        # Don't do anything until our delay has passed.
265        bs.timer(delay, bs.WeakCall(self._start_updating))
266
267    @staticmethod
268    def _meaningful_button_name(device: bs.InputDevice, button: int) -> str:
269        """Return a flattened string button name; empty for non-meaningful."""
270        if not device.has_meaningful_button_names:
271            return ''
272        return device.get_button_name(button).evaluate()
273
274    def _start_updating(self) -> None:
275        # Ok, our delay has passed. Now lets periodically see if we can fade
276        # in (if a touch-screen is present we only want to show up if gamepads
277        # are connected, etc).
278        # Also set up a timer so if we haven't faded in by the end of our
279        # duration, abort.
280        if self._lifespan is not None:
281            self._cancel_timer = bs.Timer(
282                self._lifespan,
283                bs.WeakCall(self.handlemessage, bs.DieMessage(immediate=True)),
284            )
285        self._fade_in_timer = bs.Timer(
286            1.0, bs.WeakCall(self._check_fade_in), repeat=True
287        )
288        self._check_fade_in()  # Do one check immediately.
289
290    def _check_fade_in(self) -> None:
291        assert bs.app.classic is not None
292
293        # If we have a touchscreen, we only fade in if we have a player with
294        # an input device that is *not* the touchscreen.
295        # (otherwise it is confusing to see the touchscreen buttons right
296        # next to our display buttons)
297        touchscreen: bs.InputDevice | None = bs.getinputdevice(
298            'TouchScreen', '#1', doraise=False
299        )
300
301        if touchscreen is not None:
302            # We look at the session's players; not the activity's.
303            # We want to get ones who are still in the process of
304            # selecting a character, etc.
305            input_devices = [
306                p.inputdevice for p in bs.getsession().sessionplayers
307            ]
308            input_devices = [
309                i for i in input_devices if i and i is not touchscreen
310            ]
311            fade_in = False
312            if input_devices:
313                # Only count this one if it has non-empty button names
314                # (filters out wiimotes, the remote-app, etc).
315                for device in input_devices:
316                    for name in (
317                        'buttonPunch',
318                        'buttonJump',
319                        'buttonBomb',
320                        'buttonPickUp',
321                    ):
322                        if (
323                            self._meaningful_button_name(
324                                device,
325                                bs.app.classic.get_input_device_mapped_value(
326                                    device, name
327                                ),
328                            )
329                            != ''
330                        ):
331                            fade_in = True
332                            break
333                    if fade_in:
334                        break  # No need to keep looking.
335        else:
336            # No touch-screen; fade in immediately.
337            fade_in = True
338        if fade_in:
339            self._cancel_timer = None  # Didn't need this.
340            self._fade_in_timer = None  # Done with this.
341            self._fade_in()
342
343    def _fade_in(self) -> None:
344        for node in self._nodes:
345            bs.animate(node, 'opacity', {0: 0.0, 2.0: 1.0})
346
347        # If we were given a lifespan, transition out after it.
348        if self._lifespan is not None:
349            bs.timer(
350                self._lifespan, bs.WeakCall(self.handlemessage, bs.DieMessage())
351            )
352        self._update()
353        self._update_timer = bs.Timer(
354            1.0, bs.WeakCall(self._update), repeat=True
355        )
356
357    def _update(self) -> None:
358        # pylint: disable=too-many-statements
359        # pylint: disable=too-many-branches
360        # pylint: disable=too-many-locals
361
362        if self._dead:
363            return
364
365        classic = bs.app.classic
366        assert classic is not None
367
368        punch_button_names = set()
369        jump_button_names = set()
370        pickup_button_names = set()
371        bomb_button_names = set()
372
373        # We look at the session's players; not the activity's - we want to
374        # get ones who are still in the process of selecting a character, etc.
375        input_devices = [p.inputdevice for p in bs.getsession().sessionplayers]
376        input_devices = [i for i in input_devices if i]
377
378        # If there's no players with input devices yet, try to default to
379        # showing keyboard controls.
380        if not input_devices:
381            kbd = bs.getinputdevice('Keyboard', '#1', doraise=False)
382            if kbd is not None:
383                input_devices.append(kbd)
384
385        # We word things specially if we have nothing but keyboards.
386        all_keyboards = input_devices and all(
387            i.name == 'Keyboard' for i in input_devices
388        )
389        only_remote = len(input_devices) == 1 and all(
390            i.name == 'Amazon Fire TV Remote' for i in input_devices
391        )
392
393        right_button_names = set()
394        left_button_names = set()
395        up_button_names = set()
396        down_button_names = set()
397
398        # For each player in the game with an input device,
399        # get the name of the button for each of these 4 actions.
400        # If any of them are uniform across all devices, display the name.
401        for device in input_devices:
402            # We only care about movement buttons in the case of keyboards.
403            if all_keyboards:
404                right_button_names.add(
405                    device.get_button_name(
406                        classic.get_input_device_mapped_value(
407                            device, 'buttonRight'
408                        )
409                    )
410                )
411                left_button_names.add(
412                    device.get_button_name(
413                        classic.get_input_device_mapped_value(
414                            device, 'buttonLeft'
415                        )
416                    )
417                )
418                down_button_names.add(
419                    device.get_button_name(
420                        classic.get_input_device_mapped_value(
421                            device, 'buttonDown'
422                        )
423                    )
424                )
425                up_button_names.add(
426                    device.get_button_name(
427                        classic.get_input_device_mapped_value(
428                            device, 'buttonUp'
429                        )
430                    )
431                )
432
433            # Ignore empty values; things like the remote app or
434            # wiimotes can return these.
435            bname = self._meaningful_button_name(
436                device,
437                classic.get_input_device_mapped_value(device, 'buttonPunch'),
438            )
439            if bname != '':
440                punch_button_names.add(bname)
441            bname = self._meaningful_button_name(
442                device,
443                classic.get_input_device_mapped_value(device, 'buttonJump'),
444            )
445            if bname != '':
446                jump_button_names.add(bname)
447            bname = self._meaningful_button_name(
448                device,
449                classic.get_input_device_mapped_value(device, 'buttonBomb'),
450            )
451            if bname != '':
452                bomb_button_names.add(bname)
453            bname = self._meaningful_button_name(
454                device,
455                classic.get_input_device_mapped_value(device, 'buttonPickUp'),
456            )
457            if bname != '':
458                pickup_button_names.add(bname)
459
460        # If we have no values yet, we may want to throw out some sane
461        # defaults.
462        if all(
463            not lst
464            for lst in (
465                punch_button_names,
466                jump_button_names,
467                bomb_button_names,
468                pickup_button_names,
469            )
470        ):
471            # Otherwise on android show standard buttons.
472            if classic.platform == 'android':
473                punch_button_names.add('X')
474                jump_button_names.add('A')
475                bomb_button_names.add('B')
476                pickup_button_names.add('Y')
477
478        run_text = bs.Lstr(
479            value='${R}: ${B}',
480            subs=[
481                ('${R}', bs.Lstr(resource='runText')),
482                (
483                    '${B}',
484                    bs.Lstr(
485                        resource='holdAnyKeyText'
486                        if all_keyboards
487                        else 'holdAnyButtonText'
488                    ),
489                ),
490            ],
491        )
492
493        # If we're all keyboards, lets show move keys too.
494        if (
495            all_keyboards
496            and len(up_button_names) == 1
497            and len(down_button_names) == 1
498            and len(left_button_names) == 1
499            and len(right_button_names) == 1
500        ):
501            up_text = list(up_button_names)[0]
502            down_text = list(down_button_names)[0]
503            left_text = list(left_button_names)[0]
504            right_text = list(right_button_names)[0]
505            run_text = bs.Lstr(
506                value='${M}: ${U}, ${L}, ${D}, ${R}\n${RUN}',
507                subs=[
508                    ('${M}', bs.Lstr(resource='moveText')),
509                    ('${U}', up_text),
510                    ('${L}', left_text),
511                    ('${D}', down_text),
512                    ('${R}', right_text),
513                    ('${RUN}', run_text),
514                ],
515            )
516
517        if self._force_hide_button_names:
518            jump_button_names.clear()
519            punch_button_names.clear()
520            bomb_button_names.clear()
521            pickup_button_names.clear()
522
523        self._run_text.text = run_text
524        w_text: bs.Lstr | str
525        if only_remote and self._lifespan is None:
526            w_text = bs.Lstr(
527                resource='fireTVRemoteWarningText',
528                subs=[('${REMOTE_APP_NAME}', bs.get_remote_app_name())],
529            )
530        else:
531            w_text = ''
532        self._extra_text.text = w_text
533        if len(punch_button_names) == 1:
534            self._punch_text.text = list(punch_button_names)[0]
535        else:
536            self._punch_text.text = ''
537
538        if len(jump_button_names) == 1:
539            tval = list(jump_button_names)[0]
540        else:
541            tval = ''
542        self._jump_text.text = tval
543        if tval == '':
544            self._run_text.position = self._run_text_pos_top
545            self._extra_text.position = (
546                self._run_text_pos_top[0],
547                self._run_text_pos_top[1] - 50,
548            )
549        else:
550            self._run_text.position = self._run_text_pos_bottom
551            self._extra_text.position = (
552                self._run_text_pos_bottom[0],
553                self._run_text_pos_bottom[1] - 50,
554            )
555        if len(bomb_button_names) == 1:
556            self._bomb_text.text = list(bomb_button_names)[0]
557        else:
558            self._bomb_text.text = ''
559
560        # Also move our title up/down depending on if this is shown.
561        if len(pickup_button_names) == 1:
562            self._pick_up_text.text = list(pickup_button_names)[0]
563            if self._title_text is not None:
564                self._title_text.position = self._title_text_pos_top
565        else:
566            self._pick_up_text.text = ''
567            if self._title_text is not None:
568                self._title_text.position = self._title_text_pos_bottom
569
570    def _die(self) -> None:
571        for node in self._nodes:
572            node.delete()
573        self._nodes = []
574        self._update_timer = None
575        self._dead = True
576
577    def exists(self) -> bool:
578        return not self._dead
579
580    def handlemessage(self, msg: Any) -> Any:
581        assert not self.expired
582        if isinstance(msg, bs.DieMessage):
583            if msg.immediate:
584                self._die()
585            else:
586                # If they don't need immediate,
587                # fade out our nodes and die later.
588                for node in self._nodes:
589                    bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
590                bs.timer(3.1, bs.WeakCall(self._die))
591            return None
592        return super().handlemessage(msg)

A screen overlay of game controls.

category: Gameplay Classes

Shows button mappings based on what controllers are connected. Handy to show at the start of a series or whenever there might be newbies watching.

ControlsGuide( position: tuple[float, float] = (390.0, 120.0), scale: float = 1.0, delay: float = 0.0, lifespan: float | None = None, bright: bool = False)
 26    def __init__(
 27        self,
 28        position: tuple[float, float] = (390.0, 120.0),
 29        scale: float = 1.0,
 30        delay: float = 0.0,
 31        lifespan: float | None = None,
 32        bright: bool = False,
 33    ):
 34        """Instantiate an overlay.
 35
 36        delay: is the time in seconds before the overlay fades in.
 37
 38        lifespan: if not None, the overlay will fade back out and die after
 39                  that long (in milliseconds).
 40
 41        bright: if True, brighter colors will be used; handy when showing
 42                over gameplay but may be too bright for join-screens, etc.
 43        """
 44        # pylint: disable=too-many-statements
 45        # pylint: disable=too-many-locals
 46        super().__init__()
 47        show_title = True
 48        scale *= 0.75
 49        image_size = 90.0 * scale
 50        offs = 74.0 * scale
 51        offs5 = 43.0 * scale
 52        ouya = False
 53        maxw = 50
 54        self._lifespan = lifespan
 55        self._dead = False
 56        self._bright = bright
 57        self._cancel_timer: bs.Timer | None = None
 58        self._fade_in_timer: bs.Timer | None = None
 59        self._update_timer: bs.Timer | None = None
 60        self._title_text: bs.Node | None
 61        clr: Sequence[float]
 62        punch_pos = (position[0] - offs * 1.1, position[1])
 63        jump_pos = (position[0], position[1] - offs)
 64        bomb_pos = (position[0] + offs * 1.1, position[1])
 65        pickup_pos = (position[0], position[1] + offs)
 66        self._force_hide_button_names = False
 67
 68        if show_title:
 69            self._title_text_pos_top = (
 70                position[0],
 71                position[1] + 139.0 * scale,
 72            )
 73            self._title_text_pos_bottom = (
 74                position[0],
 75                position[1] + 139.0 * scale,
 76            )
 77            clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
 78            tval = bs.Lstr(
 79                value='${A}:', subs=[('${A}', bs.Lstr(resource='controlsText'))]
 80            )
 81            self._title_text = bs.newnode(
 82                'text',
 83                attrs={
 84                    'text': tval,
 85                    'host_only': True,
 86                    'scale': 1.1 * scale,
 87                    'shadow': 0.5,
 88                    'flatness': 1.0,
 89                    'maxwidth': 480,
 90                    'v_align': 'center',
 91                    'h_align': 'center',
 92                    'color': clr,
 93                },
 94            )
 95        else:
 96            self._title_text = None
 97        pos = jump_pos
 98        clr = (0.4, 1, 0.4)
 99        self._jump_image = bs.newnode(
100            'image',
101            attrs={
102                'texture': bs.gettexture('buttonJump'),
103                'absolute_scale': True,
104                'host_only': True,
105                'vr_depth': 10,
106                'position': pos,
107                'scale': (image_size, image_size),
108                'color': clr,
109            },
110        )
111        self._jump_text = bs.newnode(
112            'text',
113            attrs={
114                'v_align': 'top',
115                'h_align': 'center',
116                'scale': 1.5 * scale,
117                'flatness': 1.0,
118                'host_only': True,
119                'shadow': 1.0,
120                'maxwidth': maxw,
121                'position': (pos[0], pos[1] - offs5),
122                'color': clr,
123            },
124        )
125        clr = (0.2, 0.6, 1) if ouya else (1, 0.7, 0.3)
126        pos = punch_pos
127        self._punch_image = bs.newnode(
128            'image',
129            attrs={
130                'texture': bs.gettexture('buttonPunch'),
131                'absolute_scale': True,
132                'host_only': True,
133                'vr_depth': 10,
134                'position': pos,
135                'scale': (image_size, image_size),
136                'color': clr,
137            },
138        )
139        self._punch_text = bs.newnode(
140            'text',
141            attrs={
142                'v_align': 'top',
143                'h_align': 'center',
144                'scale': 1.5 * scale,
145                'flatness': 1.0,
146                'host_only': True,
147                'shadow': 1.0,
148                'maxwidth': maxw,
149                'position': (pos[0], pos[1] - offs5),
150                'color': clr,
151            },
152        )
153        pos = bomb_pos
154        clr = (1, 0.3, 0.3)
155        self._bomb_image = bs.newnode(
156            'image',
157            attrs={
158                'texture': bs.gettexture('buttonBomb'),
159                'absolute_scale': True,
160                'host_only': True,
161                'vr_depth': 10,
162                'position': pos,
163                'scale': (image_size, image_size),
164                'color': clr,
165            },
166        )
167        self._bomb_text = bs.newnode(
168            'text',
169            attrs={
170                'h_align': 'center',
171                'v_align': 'top',
172                'scale': 1.5 * scale,
173                'flatness': 1.0,
174                'host_only': True,
175                'shadow': 1.0,
176                'maxwidth': maxw,
177                'position': (pos[0], pos[1] - offs5),
178                'color': clr,
179            },
180        )
181        pos = pickup_pos
182        clr = (1, 0.8, 0.3) if ouya else (0.8, 0.5, 1)
183        self._pickup_image = bs.newnode(
184            'image',
185            attrs={
186                'texture': bs.gettexture('buttonPickUp'),
187                'absolute_scale': True,
188                'host_only': True,
189                'vr_depth': 10,
190                'position': pos,
191                'scale': (image_size, image_size),
192                'color': clr,
193            },
194        )
195        self._pick_up_text = bs.newnode(
196            'text',
197            attrs={
198                'v_align': 'top',
199                'h_align': 'center',
200                'scale': 1.5 * scale,
201                'flatness': 1.0,
202                'host_only': True,
203                'shadow': 1.0,
204                'maxwidth': maxw,
205                'position': (pos[0], pos[1] - offs5),
206                'color': clr,
207            },
208        )
209        clr = (0.9, 0.9, 2.0, 1.0) if bright else (0.8, 0.8, 2.0, 1.0)
210        self._run_text_pos_top = (position[0], position[1] - 135.0 * scale)
211        self._run_text_pos_bottom = (position[0], position[1] - 172.0 * scale)
212        sval = 1.0 * scale if bs.app.env.vr else 0.8 * scale
213        self._run_text = bs.newnode(
214            'text',
215            attrs={
216                'scale': sval,
217                'host_only': True,
218                'shadow': 1.0 if bs.app.env.vr else 0.5,
219                'flatness': 1.0,
220                'maxwidth': 380,
221                'v_align': 'top',
222                'h_align': 'center',
223                'color': clr,
224            },
225        )
226        clr = (1, 1, 1) if bright else (0.7, 0.7, 0.7)
227        self._extra_text = bs.newnode(
228            'text',
229            attrs={
230                'scale': 0.8 * scale,
231                'host_only': True,
232                'shadow': 0.5,
233                'flatness': 1.0,
234                'maxwidth': 380,
235                'v_align': 'top',
236                'h_align': 'center',
237                'color': clr,
238            },
239        )
240
241        self._extra_image_1 = None
242        self._extra_image_2 = None
243
244        self._nodes = [
245            self._bomb_image,
246            self._bomb_text,
247            self._punch_image,
248            self._punch_text,
249            self._jump_image,
250            self._jump_text,
251            self._pickup_image,
252            self._pick_up_text,
253            self._run_text,
254            self._extra_text,
255        ]
256        if show_title:
257            assert self._title_text
258            self._nodes.append(self._title_text)
259
260        # Start everything invisible.
261        for node in self._nodes:
262            node.opacity = 0.0
263
264        # Don't do anything until our delay has passed.
265        bs.timer(delay, bs.WeakCall(self._start_updating))

Instantiate an overlay.

delay: is the time in seconds before the overlay fades in.

lifespan: if not None, the overlay will fade back out and die after that long (in milliseconds).

bright: if True, brighter colors will be used; handy when showing over gameplay but may be too bright for join-screens, etc.

def exists(self) -> bool:
577    def exists(self) -> bool:
578        return not self._dead

Returns whether the Actor is still present in a meaningful way.

Note that a dying character should still return True here as long as their corpse is visible; this is about presence, not being 'alive' (see bascenev1.Actor.is_alive() for that).

If this returns False, it is assumed the Actor can be completely deleted without affecting the game; this call is often used when pruning lists of Actors, such as with bascenev1.Actor.autoretain()

The default implementation of this method always return True.

Note that the boolean operator for the Actor class calls this method, so a simple "if myactor" test will conveniently do the right thing even if myactor is set to None.

def handlemessage(self, msg: Any) -> Any:
580    def handlemessage(self, msg: Any) -> Any:
581        assert not self.expired
582        if isinstance(msg, bs.DieMessage):
583            if msg.immediate:
584                self._die()
585            else:
586                # If they don't need immediate,
587                # fade out our nodes and die later.
588                for node in self._nodes:
589                    bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
590                bs.timer(3.1, bs.WeakCall(self._die))
591            return None
592        return super().handlemessage(msg)

General message handling; can be passed any message object.

Inherited Members
bascenev1._actor.Actor
autoretain
on_expire
expired
is_alive
activity
getactivity