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)
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.
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.
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.
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.