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

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

General message handling; can be passed any message object.

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