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