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        *,
 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)
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        *,
 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=(
461                            'holdAnyKeyText'
462                            if all_keyboards
463                            else 'holdAnyButtonText'
464                        )
465                    ),
466                ),
467            ],
468        )
469
470        # If we're all keyboards, lets show move keys too.
471        if (
472            all_keyboards
473            and len(up_button_names) == 1
474            and len(down_button_names) == 1
475            and len(left_button_names) == 1
476            and len(right_button_names) == 1
477        ):
478            up_text = list(up_button_names)[0]
479            down_text = list(down_button_names)[0]
480            left_text = list(left_button_names)[0]
481            right_text = list(right_button_names)[0]
482            run_text = bs.Lstr(
483                value='${M}: ${U}, ${L}, ${D}, ${R}\n${RUN}',
484                subs=[
485                    ('${M}', bs.Lstr(resource='moveText')),
486                    ('${U}', up_text),
487                    ('${L}', left_text),
488                    ('${D}', down_text),
489                    ('${R}', right_text),
490                    ('${RUN}', run_text),
491                ],
492            )
493
494        if self._force_hide_button_names:
495            jump_button_names.clear()
496            punch_button_names.clear()
497            bomb_button_names.clear()
498            pickup_button_names.clear()
499
500        self._run_text.text = run_text
501        w_text: bs.Lstr | str
502        if only_remote and self._lifespan is None:
503            w_text = bs.Lstr(
504                resource='fireTVRemoteWarningText',
505                subs=[('${REMOTE_APP_NAME}', bs.get_remote_app_name())],
506            )
507        else:
508            w_text = ''
509        self._extra_text.text = w_text
510        if len(punch_button_names) == 1:
511            self._punch_text.text = list(punch_button_names)[0]
512        else:
513            self._punch_text.text = ''
514
515        if len(jump_button_names) == 1:
516            tval = list(jump_button_names)[0]
517        else:
518            tval = ''
519        self._jump_text.text = tval
520        if tval == '':
521            self._run_text.position = self._run_text_pos_top
522            self._extra_text.position = (
523                self._run_text_pos_top[0],
524                self._run_text_pos_top[1] - 50,
525            )
526        else:
527            self._run_text.position = self._run_text_pos_bottom
528            self._extra_text.position = (
529                self._run_text_pos_bottom[0],
530                self._run_text_pos_bottom[1] - 50,
531            )
532        if len(bomb_button_names) == 1:
533            self._bomb_text.text = list(bomb_button_names)[0]
534        else:
535            self._bomb_text.text = ''
536
537        # Also move our title up/down depending on if this is shown.
538        if len(pickup_button_names) == 1:
539            self._pick_up_text.text = list(pickup_button_names)[0]
540            if self._title_text is not None:
541                self._title_text.position = self._title_text_pos_top
542        else:
543            self._pick_up_text.text = ''
544            if self._title_text is not None:
545                self._title_text.position = self._title_text_pos_bottom
546
547    def _die(self) -> None:
548        for node in self._nodes:
549            node.delete()
550        self._nodes = []
551        self._update_timer = None
552        self._dead = True
553
554    @override
555    def exists(self) -> bool:
556        return not self._dead
557
558    @override
559    def handlemessage(self, msg: Any) -> Any:
560        assert not self.expired
561        if isinstance(msg, bs.DieMessage):
562            if msg.immediate:
563                self._die()
564            else:
565                # If they don't need immediate, fade out our nodes and
566                # die later.
567                for node in self._nodes:
568                    bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
569                bs.timer(3.1, bs.WeakCall(self._die))
570            return None
571        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        *,
 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:
554    @override
555    def exists(self) -> bool:
556        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:
558    @override
559    def handlemessage(self, msg: Any) -> Any:
560        assert not self.expired
561        if isinstance(msg, bs.DieMessage):
562            if msg.immediate:
563                self._die()
564            else:
565                # If they don't need immediate, fade out our nodes and
566                # die later.
567                for node in self._nodes:
568                    bs.animate(node, 'opacity', {0: node.opacity, 3.0: 0.0})
569                bs.timer(3.1, bs.WeakCall(self._die))
570            return None
571        return super().handlemessage(msg)

General message handling; can be passed any message object.