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

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

@override
def exists(self) -> bool:
553    @override
554    def exists(self) -> bool:
555        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.

@override
def handlemessage(self, msg: Any) -> Any:
557    @override
558    def handlemessage(self, msg: Any) -> Any:
559        assert not self.expired
560        if isinstance(msg, bs.DieMessage):
561            if msg.immediate:
562                self._die()
563            else:
564                # If they don't need immediate, fade out our nodes and
565                # die later.
566                for node in self._nodes:
567                    bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
568                bs.timer(3.1, bs.WeakCall(self._die))
569            return None
570        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