bascenev1lib.game.runaround
Defines the runaround co-op game.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines the runaround co-op game.""" 4 5# We wear the cone of shame. 6# pylint: disable=too-many-lines 7 8# ba_meta require api 8 9# (see https://ballistica.net/wiki/meta-tag-system) 10 11from __future__ import annotations 12 13import random 14import logging 15from enum import Enum 16from dataclasses import dataclass 17from typing import TYPE_CHECKING, cast, Sequence, override 18 19import bascenev1 as bs 20 21from bascenev1lib.actor.popuptext import PopupText 22from bascenev1lib.actor.bomb import TNTSpawner 23from bascenev1lib.actor.scoreboard import Scoreboard 24from bascenev1lib.actor.respawnicon import RespawnIcon 25from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory 26from bascenev1lib.gameutils import SharedObjects 27from bascenev1lib.actor.spazbot import ( 28 SpazBotSet, 29 SpazBot, 30 SpazBotDiedMessage, 31 BomberBot, 32 BrawlerBot, 33 TriggerBot, 34 TriggerBotPro, 35 BomberBotProShielded, 36 TriggerBotProShielded, 37 ChargerBot, 38 ChargerBotProShielded, 39 StickyBot, 40 ExplodeyBot, 41 BrawlerBotProShielded, 42 BomberBotPro, 43 BrawlerBotPro, 44) 45 46if TYPE_CHECKING: 47 from typing import Any 48 49 50class Preset(Enum): 51 """Play presets.""" 52 53 ENDLESS = 'endless' 54 ENDLESS_TOURNAMENT = 'endless_tournament' 55 PRO = 'pro' 56 PRO_EASY = 'pro_easy' 57 UBER = 'uber' 58 UBER_EASY = 'uber_easy' 59 TOURNAMENT = 'tournament' 60 TOURNAMENT_UBER = 'tournament_uber' 61 62 63class Point(Enum): 64 """Where we can spawn stuff and the corresponding map attr name.""" 65 66 BOTTOM_LEFT = 'bot_spawn_bottom_left' 67 BOTTOM_RIGHT = 'bot_spawn_bottom_right' 68 START = 'bot_spawn_start' 69 70 71@dataclass 72class Spawn: 73 """Defines a bot spawn event.""" 74 75 # noinspection PyUnresolvedReferences 76 type: type[SpazBot] 77 path: int = 0 78 point: Point | None = None 79 80 81@dataclass 82class Spacing: 83 """Defines spacing between spawns.""" 84 85 duration: float 86 87 88@dataclass 89class Wave: 90 """Defines a wave of enemies.""" 91 92 entries: list[Spawn | Spacing | None] 93 94 95class Player(bs.Player['Team']): 96 """Our player type for this game.""" 97 98 def __init__(self) -> None: 99 self.respawn_timer: bs.Timer | None = None 100 self.respawn_icon: RespawnIcon | None = None 101 102 103class Team(bs.Team[Player]): 104 """Our team type for this game.""" 105 106 107class RunaroundGame(bs.CoopGameActivity[Player, Team]): 108 """Game involving trying to bomb bots as they walk through the map.""" 109 110 name = 'Runaround' 111 description = 'Prevent enemies from reaching the exit.' 112 tips = [ 113 'Jump just as you\'re throwing to get bombs up to the highest levels.', 114 'No, you can\'t get up on the ledge. You have to throw bombs.', 115 'Whip back and forth to get more distance on your throws..', 116 ] 117 default_music = bs.MusicType.MARCHING 118 119 # How fast our various bot types walk. 120 _bot_speed_map: dict[type[SpazBot], float] = { 121 BomberBot: 0.48, 122 BomberBotPro: 0.48, 123 BomberBotProShielded: 0.48, 124 BrawlerBot: 0.57, 125 BrawlerBotPro: 0.57, 126 BrawlerBotProShielded: 0.57, 127 TriggerBot: 0.73, 128 TriggerBotPro: 0.78, 129 TriggerBotProShielded: 0.78, 130 ChargerBot: 1.0, 131 ChargerBotProShielded: 1.0, 132 ExplodeyBot: 1.0, 133 StickyBot: 0.5, 134 } 135 136 def __init__(self, settings: dict): 137 settings['map'] = 'Tower D' 138 super().__init__(settings) 139 shared = SharedObjects.get() 140 self._preset = Preset(settings.get('preset', 'pro')) 141 142 self._player_death_sound = bs.getsound('playerDeath') 143 self._new_wave_sound = bs.getsound('scoreHit01') 144 self._winsound = bs.getsound('score') 145 self._cashregistersound = bs.getsound('cashRegister') 146 self._bad_guy_score_sound = bs.getsound('shieldDown') 147 self._heart_tex = bs.gettexture('heart') 148 self._heart_mesh_opaque = bs.getmesh('heartOpaque') 149 self._heart_mesh_transparent = bs.getmesh('heartTransparent') 150 151 self._a_player_has_been_killed = False 152 self._spawn_center = self._map_type.defs.points['spawn1'][0:3] 153 self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3] 154 self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3] 155 self._powerup_spread = ( 156 self._map_type.defs.boxes['powerup_region'][6] * 0.5, 157 self._map_type.defs.boxes['powerup_region'][8] * 0.5, 158 ) 159 160 self._score_region_material = bs.Material() 161 self._score_region_material.add_actions( 162 conditions=('they_have_material', shared.player_material), 163 actions=( 164 ('modify_part_collision', 'collide', True), 165 ('modify_part_collision', 'physical', False), 166 ('call', 'at_connect', self._handle_reached_end), 167 ), 168 ) 169 170 self._last_wave_end_time = bs.time() 171 self._player_has_picked_up_powerup = False 172 self._scoreboard: Scoreboard | None = None 173 self._game_over = False 174 self._wavenum = 0 175 self._can_end_wave = True 176 self._score = 0 177 self._time_bonus = 0 178 self._score_region: bs.Actor | None = None 179 self._dingsound = bs.getsound('dingSmall') 180 self._dingsoundhigh = bs.getsound('dingSmallHigh') 181 self._exclude_powerups: list[str] | None = None 182 self._have_tnt: bool | None = None 183 self._waves: list[Wave] | None = None 184 self._bots = SpazBotSet() 185 self._tntspawner: TNTSpawner | None = None 186 self._lives_bg: bs.NodeActor | None = None 187 self._start_lives = 10 188 self._lives = self._start_lives 189 self._lives_text: bs.NodeActor | None = None 190 self._flawless = True 191 self._time_bonus_timer: bs.Timer | None = None 192 self._lives_hbtime: bs.Timer | None = None 193 self._time_bonus_text: bs.NodeActor | None = None 194 self._time_bonus_mult: float | None = None 195 self._wave_text: bs.NodeActor | None = None 196 self._flawless_bonus: int | None = None 197 self._wave_update_timer: bs.Timer | None = None 198 199 @override 200 def on_transition_in(self) -> None: 201 super().on_transition_in() 202 self._scoreboard = Scoreboard( 203 label=bs.Lstr(resource='scoreText'), score_split=0.5 204 ) 205 self._score_region = bs.NodeActor( 206 bs.newnode( 207 'region', 208 attrs={ 209 'position': self.map.defs.boxes['score_region'][0:3], 210 'scale': self.map.defs.boxes['score_region'][6:9], 211 'type': 'box', 212 'materials': [self._score_region_material], 213 }, 214 ) 215 ) 216 217 @override 218 def on_begin(self) -> None: 219 super().on_begin() 220 player_count = len(self.players) 221 hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY} 222 223 if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}: 224 self._exclude_powerups = ['curse'] 225 self._have_tnt = True 226 self._waves = [ 227 Wave( 228 entries=[ 229 Spawn(BomberBot, path=3 if hard else 2), 230 Spawn(BomberBot, path=2), 231 Spawn(BomberBot, path=2) if hard else None, 232 Spawn(BomberBot, path=2) if player_count > 1 else None, 233 Spawn(BomberBot, path=1) if hard else None, 234 Spawn(BomberBot, path=1) if player_count > 2 else None, 235 Spawn(BomberBot, path=1) if player_count > 3 else None, 236 ] 237 ), 238 Wave( 239 entries=[ 240 Spawn(BomberBot, path=1) if hard else None, 241 Spawn(BomberBot, path=2) if hard else None, 242 Spawn(BomberBot, path=2), 243 Spawn(BomberBot, path=2), 244 Spawn(BomberBot, path=2) if player_count > 3 else None, 245 Spawn(BrawlerBot, path=3), 246 Spawn(BrawlerBot, path=3), 247 Spawn(BrawlerBot, path=3) if hard else None, 248 Spawn(BrawlerBot, path=3) if player_count > 1 else None, 249 Spawn(BrawlerBot, path=3) if player_count > 2 else None, 250 ] 251 ), 252 Wave( 253 entries=[ 254 Spawn(ChargerBot, path=2) if hard else None, 255 Spawn(ChargerBot, path=2) if player_count > 2 else None, 256 Spawn(TriggerBot, path=2), 257 Spawn(TriggerBot, path=2) if player_count > 1 else None, 258 Spacing(duration=3.0), 259 Spawn(BomberBot, path=2) if hard else None, 260 Spawn(BomberBot, path=2) if hard else None, 261 Spawn(BomberBot, path=2), 262 Spawn(BomberBot, path=3) if hard else None, 263 Spawn(BomberBot, path=3), 264 Spawn(BomberBot, path=3), 265 Spawn(BomberBot, path=3) if player_count > 3 else None, 266 ] 267 ), 268 Wave( 269 entries=[ 270 Spawn(TriggerBot, path=1) if hard else None, 271 Spacing(duration=1.0) if hard else None, 272 Spawn(TriggerBot, path=2), 273 Spacing(duration=1.0), 274 Spawn(TriggerBot, path=3), 275 Spacing(duration=1.0), 276 Spawn(TriggerBot, path=1) if hard else None, 277 Spacing(duration=1.0) if hard else None, 278 Spawn(TriggerBot, path=2), 279 Spacing(duration=1.0), 280 Spawn(TriggerBot, path=3), 281 Spacing(duration=1.0), 282 ( 283 Spawn(TriggerBot, path=1) 284 if (player_count > 1 and hard) 285 else None 286 ), 287 Spacing(duration=1.0), 288 Spawn(TriggerBot, path=2) if player_count > 2 else None, 289 Spacing(duration=1.0), 290 Spawn(TriggerBot, path=3) if player_count > 3 else None, 291 Spacing(duration=1.0), 292 ] 293 ), 294 Wave( 295 entries=[ 296 Spawn( 297 ChargerBotProShielded if hard else ChargerBot, 298 path=1, 299 ), 300 Spawn(BrawlerBot, path=2) if hard else None, 301 Spawn(BrawlerBot, path=2), 302 Spawn(BrawlerBot, path=2), 303 Spawn(BrawlerBot, path=3) if hard else None, 304 Spawn(BrawlerBot, path=3), 305 Spawn(BrawlerBot, path=3), 306 Spawn(BrawlerBot, path=3) if player_count > 1 else None, 307 Spawn(BrawlerBot, path=3) if player_count > 2 else None, 308 Spawn(BrawlerBot, path=3) if player_count > 3 else None, 309 ] 310 ), 311 Wave( 312 entries=[ 313 Spawn(BomberBotProShielded, path=3), 314 Spacing(duration=1.5), 315 Spawn(BomberBotProShielded, path=2), 316 Spacing(duration=1.5), 317 Spawn(BomberBotProShielded, path=1) if hard else None, 318 Spacing(duration=1.0) if hard else None, 319 Spawn(BomberBotProShielded, path=3), 320 Spacing(duration=1.5), 321 Spawn(BomberBotProShielded, path=2), 322 Spacing(duration=1.5), 323 Spawn(BomberBotProShielded, path=1) if hard else None, 324 Spacing(duration=1.5) if hard else None, 325 ( 326 Spawn(BomberBotProShielded, path=3) 327 if player_count > 1 328 else None 329 ), 330 Spacing(duration=1.5), 331 ( 332 Spawn(BomberBotProShielded, path=2) 333 if player_count > 2 334 else None 335 ), 336 Spacing(duration=1.5), 337 ( 338 Spawn(BomberBotProShielded, path=1) 339 if player_count > 3 340 else None 341 ), 342 ] 343 ), 344 ] 345 elif self._preset in { 346 Preset.UBER_EASY, 347 Preset.UBER, 348 Preset.TOURNAMENT_UBER, 349 }: 350 self._exclude_powerups = [] 351 self._have_tnt = True 352 self._waves = [ 353 Wave( 354 entries=[ 355 Spawn(TriggerBot, path=1) if hard else None, 356 Spawn(TriggerBot, path=2), 357 Spawn(TriggerBot, path=2), 358 Spawn(TriggerBot, path=3), 359 Spawn( 360 BrawlerBotPro if hard else BrawlerBot, 361 point=Point.BOTTOM_LEFT, 362 ), 363 ( 364 Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT) 365 if player_count > 2 366 else None 367 ), 368 ] 369 ), 370 Wave( 371 entries=[ 372 Spawn(ChargerBot, path=2), 373 Spawn(ChargerBot, path=3), 374 Spawn(ChargerBot, path=1) if hard else None, 375 Spawn(ChargerBot, path=2), 376 Spawn(ChargerBot, path=3), 377 Spawn(ChargerBot, path=1) if player_count > 2 else None, 378 ] 379 ), 380 Wave( 381 entries=[ 382 Spawn(BomberBotProShielded, path=1) if hard else None, 383 Spawn(BomberBotProShielded, path=2), 384 Spawn(BomberBotProShielded, path=2), 385 Spawn(BomberBotProShielded, path=3), 386 Spawn(BomberBotProShielded, path=3), 387 Spawn(ChargerBot, point=Point.BOTTOM_RIGHT), 388 ( 389 Spawn(ChargerBot, point=Point.BOTTOM_LEFT) 390 if player_count > 2 391 else None 392 ), 393 ] 394 ), 395 Wave( 396 entries=[ 397 Spawn(TriggerBotPro, path=1) if hard else None, 398 Spawn(TriggerBotPro, path=1 if hard else 2), 399 Spawn(TriggerBotPro, path=1 if hard else 2), 400 Spawn(TriggerBotPro, path=1 if hard else 2), 401 Spawn(TriggerBotPro, path=1 if hard else 2), 402 Spawn(TriggerBotPro, path=1 if hard else 2), 403 ( 404 Spawn(TriggerBotPro, path=1 if hard else 2) 405 if player_count > 1 406 else None 407 ), 408 ( 409 Spawn(TriggerBotPro, path=1 if hard else 2) 410 if player_count > 3 411 else None 412 ), 413 ] 414 ), 415 Wave( 416 entries=[ 417 Spawn( 418 TriggerBotProShielded if hard else TriggerBotPro, 419 point=Point.BOTTOM_LEFT, 420 ), 421 ( 422 Spawn( 423 TriggerBotProShielded, point=Point.BOTTOM_RIGHT 424 ) 425 if hard 426 else None 427 ), 428 ( 429 Spawn( 430 TriggerBotProShielded, point=Point.BOTTOM_RIGHT 431 ) 432 if player_count > 2 433 else None 434 ), 435 Spawn(BomberBot, path=3), 436 Spawn(BomberBot, path=3), 437 Spacing(duration=5.0), 438 Spawn(BrawlerBot, path=2), 439 Spawn(BrawlerBot, path=2), 440 Spacing(duration=5.0), 441 Spawn(TriggerBot, path=1) if hard else None, 442 Spawn(TriggerBot, path=1) if hard else None, 443 ] 444 ), 445 Wave( 446 entries=[ 447 Spawn(BomberBotProShielded, path=2), 448 Spawn(BomberBotProShielded, path=2) if hard else None, 449 Spawn(StickyBot, point=Point.BOTTOM_RIGHT), 450 Spawn(BomberBotProShielded, path=2), 451 Spawn(BomberBotProShielded, path=2), 452 ( 453 Spawn(StickyBot, point=Point.BOTTOM_RIGHT) 454 if player_count > 2 455 else None 456 ), 457 Spawn(BomberBotProShielded, path=2), 458 Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT), 459 Spawn(BomberBotProShielded, path=2), 460 ( 461 Spawn(BomberBotProShielded, path=2) 462 if player_count > 1 463 else None 464 ), 465 Spacing(duration=5.0), 466 Spawn(StickyBot, point=Point.BOTTOM_LEFT), 467 Spacing(duration=2.0), 468 Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT), 469 ] 470 ), 471 ] 472 elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 473 self._exclude_powerups = [] 474 self._have_tnt = True 475 476 # Spit out a few powerups and start dropping more shortly. 477 self._drop_powerups(standard_points=True) 478 bs.timer(4.0, self._start_powerup_drops) 479 self.setup_low_life_warning_sound() 480 self._update_scores() 481 482 # Our TNT spawner (if applicable). 483 if self._have_tnt: 484 self._tntspawner = TNTSpawner(position=self._tntspawnpos) 485 486 # Make sure to stay out of the way of menu/party buttons in the corner. 487 assert bs.app.classic is not None 488 uiscale = bs.app.ui_v1.uiscale 489 l_offs = ( 490 -80 491 if uiscale is bs.UIScale.SMALL 492 else -40 if uiscale is bs.UIScale.MEDIUM else 0 493 ) 494 495 self._lives_bg = bs.NodeActor( 496 bs.newnode( 497 'image', 498 attrs={ 499 'texture': self._heart_tex, 500 'mesh_opaque': self._heart_mesh_opaque, 501 'mesh_transparent': self._heart_mesh_transparent, 502 'attach': 'topRight', 503 'scale': (90, 90), 504 'position': (-110 + l_offs, -50), 505 'color': (1, 0.2, 0.2), 506 }, 507 ) 508 ) 509 # FIXME; should not set things based on vr mode. 510 # (won't look right to non-vr connected clients, etc) 511 vrmode = bs.app.env.vr 512 self._lives_text = bs.NodeActor( 513 bs.newnode( 514 'text', 515 attrs={ 516 'v_attach': 'top', 517 'h_attach': 'right', 518 'h_align': 'center', 519 'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0), 520 'flatness': 1.0 if vrmode else 0.5, 521 'shadow': 1.0 if vrmode else 0.5, 522 'vr_depth': 10, 523 'position': (-113 + l_offs, -69), 524 'scale': 1.3, 525 'text': str(self._lives), 526 }, 527 ) 528 ) 529 530 bs.timer(2.0, self._start_updating_waves) 531 532 def _handle_reached_end(self) -> None: 533 spaz = bs.getcollision().opposingnode.getdelegate(SpazBot, True) 534 if not spaz.is_alive(): 535 return # Ignore bodies flying in. 536 537 self._flawless = False 538 pos = spaz.node.position 539 self._bad_guy_score_sound.play(position=pos) 540 light = bs.newnode( 541 'light', attrs={'position': pos, 'radius': 0.5, 'color': (1, 0, 0)} 542 ) 543 bs.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False) 544 bs.timer(1.0, light.delete) 545 spaz.handlemessage( 546 bs.DieMessage(immediate=True, how=bs.DeathType.REACHED_GOAL) 547 ) 548 549 if self._lives > 0: 550 self._lives -= 1 551 if self._lives == 0: 552 self._bots.stop_moving() 553 self.continue_or_end_game() 554 555 # Heartbeat behavior 556 if self._lives < 5: 557 hbtime = 0.39 + (0.21 * self._lives) 558 self._lives_hbtime = bs.Timer( 559 hbtime, lambda: self.heart_dyin(True, hbtime), repeat=True 560 ) 561 self.heart_dyin(True) 562 else: 563 self._lives_hbtime = None 564 self.heart_dyin(False) 565 566 assert self._lives_text is not None 567 assert self._lives_text.node 568 self._lives_text.node.text = str(self._lives) 569 delay = 0.0 570 571 def _safesetattr(node: bs.Node, attr: str, value: Any) -> None: 572 if node: 573 setattr(node, attr, value) 574 575 for _i in range(4): 576 bs.timer( 577 delay, 578 bs.Call( 579 _safesetattr, 580 self._lives_text.node, 581 'color', 582 (1, 0, 0, 1.0), 583 ), 584 ) 585 assert self._lives_bg is not None 586 assert self._lives_bg.node 587 bs.timer( 588 delay, 589 bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5), 590 ) 591 delay += 0.125 592 bs.timer( 593 delay, 594 bs.Call( 595 _safesetattr, 596 self._lives_text.node, 597 'color', 598 (1.0, 1.0, 0.0, 1.0), 599 ), 600 ) 601 bs.timer( 602 delay, 603 bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0), 604 ) 605 delay += 0.125 606 bs.timer( 607 delay, 608 bs.Call( 609 _safesetattr, 610 self._lives_text.node, 611 'color', 612 (0.8, 0.8, 0.8, 1.0), 613 ), 614 ) 615 616 @override 617 def on_continue(self) -> None: 618 self._lives = 3 619 assert self._lives_text is not None 620 assert self._lives_text.node 621 self._lives_text.node.text = str(self._lives) 622 self._bots.start_moving() 623 624 @override 625 def spawn_player(self, player: Player) -> bs.Actor: 626 pos = ( 627 self._spawn_center[0] + random.uniform(-1.5, 1.5), 628 self._spawn_center[1], 629 self._spawn_center[2] + random.uniform(-1.5, 1.5), 630 ) 631 spaz = self.spawn_player_spaz(player, position=pos) 632 if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}: 633 spaz.impact_scale = 0.25 634 635 # Add the material that causes us to hit the player-wall. 636 spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup 637 return spaz 638 639 def _on_player_picked_up_powerup(self, player: bs.Actor) -> None: 640 del player # Unused. 641 self._player_has_picked_up_powerup = True 642 643 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 644 if poweruptype is None: 645 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 646 excludetypes=self._exclude_powerups 647 ) 648 PowerupBox( 649 position=self.map.powerup_spawn_points[index], 650 poweruptype=poweruptype, 651 ).autoretain() 652 653 def _start_powerup_drops(self) -> None: 654 bs.timer(3.0, self._drop_powerups, repeat=True) 655 656 def _drop_powerups( 657 self, standard_points: bool = False, force_first: str | None = None 658 ) -> None: 659 """Generic powerup drop.""" 660 661 # If its been a minute since our last wave finished emerging, stop 662 # giving out land-mine powerups. (prevents players from waiting 663 # around for them on purpose and filling the map up) 664 if bs.time() - self._last_wave_end_time > 60.0: 665 extra_excludes = ['land_mines'] 666 else: 667 extra_excludes = [] 668 669 if standard_points: 670 points = self.map.powerup_spawn_points 671 for i in range(len(points)): 672 bs.timer( 673 1.0 + i * 0.5, 674 bs.Call( 675 self._drop_powerup, i, force_first if i == 0 else None 676 ), 677 ) 678 else: 679 pos = ( 680 self._powerup_center[0] 681 + random.uniform( 682 -1.0 * self._powerup_spread[0], 683 1.0 * self._powerup_spread[0], 684 ), 685 self._powerup_center[1], 686 self._powerup_center[2] 687 + random.uniform( 688 -self._powerup_spread[1], self._powerup_spread[1] 689 ), 690 ) 691 692 # drop one random one somewhere.. 693 assert self._exclude_powerups is not None 694 PowerupBox( 695 position=pos, 696 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 697 excludetypes=self._exclude_powerups + extra_excludes 698 ), 699 ).autoretain() 700 701 @override 702 def end_game(self) -> None: 703 bs.pushcall(bs.Call(self.do_end, 'defeat')) 704 bs.setmusic(None) 705 self._player_death_sound.play() 706 707 def do_end(self, outcome: str) -> None: 708 """End the game now with the provided outcome.""" 709 710 if outcome == 'defeat': 711 delay = 2.0 712 self.fade_to_red() 713 else: 714 delay = 0 715 716 score: int | None 717 if self._wavenum >= 2: 718 score = self._score 719 fail_message = None 720 else: 721 score = None 722 fail_message = bs.Lstr(resource='reachWave2Text') 723 724 self.end( 725 delay=delay, 726 results={ 727 'outcome': outcome, 728 'score': score, 729 'fail_message': fail_message, 730 'playerinfos': self.initialplayerinfos, 731 }, 732 ) 733 734 def _update_waves(self) -> None: 735 # pylint: disable=too-many-branches 736 737 # If we have no living bots, go to the next wave. 738 if ( 739 self._can_end_wave 740 and not self._bots.have_living_bots() 741 and not self._game_over 742 and self._lives > 0 743 ): 744 self._can_end_wave = False 745 self._time_bonus_timer = None 746 self._time_bonus_text = None 747 748 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 749 won = False 750 else: 751 assert self._waves is not None 752 won = self._wavenum == len(self._waves) 753 754 # Reward time bonus. 755 base_delay = 4.0 if won else 0 756 if self._time_bonus > 0: 757 bs.timer(0, self._cashregistersound.play) 758 bs.timer( 759 base_delay, 760 bs.Call(self._award_time_bonus, self._time_bonus), 761 ) 762 base_delay += 1.0 763 764 # Reward flawless bonus. 765 if self._wavenum > 0 and self._flawless: 766 bs.timer(base_delay, self._award_flawless_bonus) 767 base_delay += 1.0 768 769 self._flawless = True # reset 770 771 if won: 772 # Completion achievements: 773 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 774 self._award_achievement( 775 'Pro Runaround Victory', sound=False 776 ) 777 if self._lives == self._start_lives: 778 self._award_achievement('The Wall', sound=False) 779 if not self._player_has_picked_up_powerup: 780 self._award_achievement( 781 'Precision Bombing', sound=False 782 ) 783 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 784 self._award_achievement( 785 'Uber Runaround Victory', sound=False 786 ) 787 if self._lives == self._start_lives: 788 self._award_achievement('The Great Wall', sound=False) 789 if not self._a_player_has_been_killed: 790 self._award_achievement('Stayin\' Alive', sound=False) 791 792 # Give remaining players some points and have them celebrate. 793 self.show_zoom_message( 794 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 795 ) 796 797 self.celebrate(10.0) 798 bs.timer(base_delay, self._award_lives_bonus) 799 base_delay += 1.0 800 bs.timer(base_delay, self._award_completion_bonus) 801 base_delay += 0.85 802 self._winsound.play() 803 bs.cameraflash() 804 bs.setmusic(bs.MusicType.VICTORY) 805 self._game_over = True 806 bs.timer(base_delay, bs.Call(self.do_end, 'victory')) 807 return 808 809 self._wavenum += 1 810 811 # Short celebration after waves. 812 if self._wavenum > 1: 813 self.celebrate(0.5) 814 815 bs.timer(base_delay, self._start_next_wave) 816 817 def _award_completion_bonus(self) -> None: 818 bonus = 200 819 self._cashregistersound.play() 820 PopupText( 821 bs.Lstr( 822 value='+${A} ${B}', 823 subs=[ 824 ('${A}', str(bonus)), 825 ('${B}', bs.Lstr(resource='completionBonusText')), 826 ], 827 ), 828 color=(0.7, 0.7, 1.0, 1), 829 scale=1.6, 830 position=(0, 1.5, -1), 831 ).autoretain() 832 self._score += bonus 833 self._update_scores() 834 835 def _award_lives_bonus(self) -> None: 836 bonus = self._lives * 30 837 self._cashregistersound.play() 838 PopupText( 839 bs.Lstr( 840 value='+${A} ${B}', 841 subs=[ 842 ('${A}', str(bonus)), 843 ('${B}', bs.Lstr(resource='livesBonusText')), 844 ], 845 ), 846 color=(0.7, 1.0, 0.3, 1), 847 scale=1.3, 848 position=(0, 1, -1), 849 ).autoretain() 850 self._score += bonus 851 self._update_scores() 852 853 def _award_time_bonus(self, bonus: int) -> None: 854 self._cashregistersound.play() 855 PopupText( 856 bs.Lstr( 857 value='+${A} ${B}', 858 subs=[ 859 ('${A}', str(bonus)), 860 ('${B}', bs.Lstr(resource='timeBonusText')), 861 ], 862 ), 863 color=(1, 1, 0.5, 1), 864 scale=1.0, 865 position=(0, 3, -1), 866 ).autoretain() 867 868 self._score += self._time_bonus 869 self._update_scores() 870 871 def _award_flawless_bonus(self) -> None: 872 self._cashregistersound.play() 873 PopupText( 874 bs.Lstr( 875 value='+${A} ${B}', 876 subs=[ 877 ('${A}', str(self._flawless_bonus)), 878 ('${B}', bs.Lstr(resource='perfectWaveText')), 879 ], 880 ), 881 color=(1, 1, 0.2, 1), 882 scale=1.2, 883 position=(0, 2, -1), 884 ).autoretain() 885 886 assert self._flawless_bonus is not None 887 self._score += self._flawless_bonus 888 self._update_scores() 889 890 def _start_time_bonus_timer(self) -> None: 891 self._time_bonus_timer = bs.Timer( 892 1.0, self._update_time_bonus, repeat=True 893 ) 894 895 def _start_next_wave(self) -> None: 896 # FIXME: Need to split this up. 897 # pylint: disable=too-many-locals 898 # pylint: disable=too-many-branches 899 # pylint: disable=too-many-statements 900 self.show_zoom_message( 901 bs.Lstr( 902 value='${A} ${B}', 903 subs=[ 904 ('${A}', bs.Lstr(resource='waveText')), 905 ('${B}', str(self._wavenum)), 906 ], 907 ), 908 scale=1.0, 909 duration=1.0, 910 trail=True, 911 ) 912 bs.timer(0.4, self._new_wave_sound.play) 913 t_sec = 0.0 914 base_delay = 0.5 915 delay = 0.0 916 bot_types: list[Spawn | Spacing | None] = [] 917 918 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 919 level = self._wavenum 920 target_points = (level + 1) * 8.0 921 group_count = random.randint(1, 3) 922 entries: list[Spawn | Spacing | None] = [] 923 spaz_types: list[tuple[type[SpazBot], float]] = [] 924 if level < 6: 925 spaz_types += [(BomberBot, 5.0)] 926 if level < 10: 927 spaz_types += [(BrawlerBot, 5.0)] 928 if level < 15: 929 spaz_types += [(TriggerBot, 6.0)] 930 if level > 5: 931 spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7) 932 if level > 2: 933 spaz_types += [(BomberBotProShielded, 8.0)] * ( 934 1 + (level - 2) // 6 935 ) 936 if level > 6: 937 spaz_types += [(TriggerBotProShielded, 12.0)] * ( 938 1 + (level - 6) // 5 939 ) 940 if level > 1: 941 spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4) 942 if level > 7: 943 spaz_types += [(ChargerBotProShielded, 15.0)] * ( 944 1 + (level - 7) // 3 945 ) 946 947 # Bot type, their effect on target points. 948 defender_types: list[tuple[type[SpazBot], float]] = [ 949 (BomberBot, 0.9), 950 (BrawlerBot, 0.9), 951 (TriggerBot, 0.85), 952 ] 953 if level > 2: 954 defender_types += [(ChargerBot, 0.75)] 955 if level > 4: 956 defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6) 957 if level > 6: 958 defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5) 959 if level > 8: 960 defender_types += [(BrawlerBotProShielded, 0.65)] * ( 961 1 + (level - 5) // 4 962 ) 963 if level > 10: 964 defender_types += [(TriggerBotProShielded, 0.6)] * ( 965 1 + (level - 6) // 3 966 ) 967 968 for group in range(group_count): 969 this_target_point_s = target_points / group_count 970 971 # Adding spacing makes things slightly harder. 972 rval = random.random() 973 if rval < 0.07: 974 spacing = 1.5 975 this_target_point_s *= 0.85 976 elif rval < 0.15: 977 spacing = 1.0 978 this_target_point_s *= 0.9 979 else: 980 spacing = 0.0 981 982 path = random.randint(1, 3) 983 984 # Don't allow hard paths on early levels. 985 if level < 3: 986 if path == 1: 987 path = 3 988 989 # Easy path. 990 if path == 3: 991 pass 992 993 # Harder path. 994 elif path == 2: 995 this_target_point_s *= 0.8 996 997 # Even harder path. 998 elif path == 1: 999 this_target_point_s *= 0.7 1000 1001 # Looping forward. 1002 elif path == 4: 1003 this_target_point_s *= 0.7 1004 1005 # Looping backward. 1006 elif path == 5: 1007 this_target_point_s *= 0.7 1008 1009 # Random. 1010 elif path == 6: 1011 this_target_point_s *= 0.7 1012 1013 def _add_defender( 1014 defender_type: tuple[type[SpazBot], float], pnt: Point 1015 ) -> tuple[float, Spawn]: 1016 # This is ok because we call it immediately. 1017 # pylint: disable=cell-var-from-loop 1018 return this_target_point_s * defender_type[1], Spawn( 1019 defender_type[0], point=pnt 1020 ) 1021 1022 # Add defenders. 1023 defender_type1 = defender_types[ 1024 random.randrange(len(defender_types)) 1025 ] 1026 defender_type2 = defender_types[ 1027 random.randrange(len(defender_types)) 1028 ] 1029 defender1 = defender2 = None 1030 if ( 1031 (group == 0) 1032 or (group == 1 and level > 3) 1033 or (group == 2 and level > 5) 1034 ): 1035 if random.random() < min(0.75, (level - 1) * 0.11): 1036 this_target_point_s, defender1 = _add_defender( 1037 defender_type1, Point.BOTTOM_LEFT 1038 ) 1039 if random.random() < min(0.75, (level - 1) * 0.04): 1040 this_target_point_s, defender2 = _add_defender( 1041 defender_type2, Point.BOTTOM_RIGHT 1042 ) 1043 1044 spaz_type = spaz_types[random.randrange(len(spaz_types))] 1045 member_count = max( 1046 1, int(round(this_target_point_s / spaz_type[1])) 1047 ) 1048 for i, _member in enumerate(range(member_count)): 1049 if path == 4: 1050 this_path = i % 3 # Looping forward. 1051 elif path == 5: 1052 this_path = 3 - (i % 3) # Looping backward. 1053 elif path == 6: 1054 this_path = random.randint(1, 3) # Random. 1055 else: 1056 this_path = path 1057 entries.append(Spawn(spaz_type[0], path=this_path)) 1058 if spacing != 0.0: 1059 entries.append(Spacing(duration=spacing)) 1060 1061 if defender1 is not None: 1062 entries.append(defender1) 1063 if defender2 is not None: 1064 entries.append(defender2) 1065 1066 # Some spacing between groups. 1067 rval = random.random() 1068 if rval < 0.1: 1069 spacing = 5.0 1070 elif rval < 0.5: 1071 spacing = 1.0 1072 else: 1073 spacing = 1.0 1074 entries.append(Spacing(duration=spacing)) 1075 1076 wave = Wave(entries=entries) 1077 1078 else: 1079 assert self._waves is not None 1080 wave = self._waves[self._wavenum - 1] 1081 1082 bot_types += wave.entries 1083 self._time_bonus_mult = 1.0 1084 this_flawless_bonus = 0 1085 non_runner_spawn_time = 1.0 1086 1087 for info in bot_types: 1088 if info is None: 1089 continue 1090 if isinstance(info, Spacing): 1091 t_sec += info.duration 1092 continue 1093 bot_type = info.type 1094 path = info.path 1095 self._time_bonus_mult += bot_type.points_mult * 0.02 1096 this_flawless_bonus += bot_type.points_mult * 5 1097 1098 # If its got a position, use that. 1099 if info.point is not None: 1100 point = info.point 1101 else: 1102 point = Point.START 1103 1104 # Space our our slower bots. 1105 delay = base_delay 1106 delay /= self._get_bot_speed(bot_type) 1107 t_sec += delay * 0.5 1108 tcall = bs.Call( 1109 self.add_bot_at_point, 1110 point, 1111 bot_type, 1112 path, 1113 0.1 if point is Point.START else non_runner_spawn_time, 1114 ) 1115 bs.timer(t_sec, tcall) 1116 t_sec += delay * 0.5 1117 1118 # We can end the wave after all the spawning happens. 1119 bs.timer( 1120 t_sec - delay * 0.5 + non_runner_spawn_time + 0.01, 1121 self._set_can_end_wave, 1122 ) 1123 1124 # Reset our time bonus. 1125 # In this game we use a constant time bonus so it erodes away in 1126 # roughly the same time (since the time limit a wave can take is 1127 # relatively constant) ..we then post-multiply a modifier to adjust 1128 # points. 1129 self._time_bonus = 150 1130 self._flawless_bonus = this_flawless_bonus 1131 assert self._time_bonus_mult is not None 1132 txtval = bs.Lstr( 1133 value='${A}: ${B}', 1134 subs=[ 1135 ('${A}', bs.Lstr(resource='timeBonusText')), 1136 ('${B}', str(int(self._time_bonus * self._time_bonus_mult))), 1137 ], 1138 ) 1139 self._time_bonus_text = bs.NodeActor( 1140 bs.newnode( 1141 'text', 1142 attrs={ 1143 'v_attach': 'top', 1144 'h_attach': 'center', 1145 'h_align': 'center', 1146 'color': (1, 1, 0.0, 1), 1147 'shadow': 1.0, 1148 'vr_depth': -30, 1149 'flatness': 1.0, 1150 'position': (0, -60), 1151 'scale': 0.8, 1152 'text': txtval, 1153 }, 1154 ) 1155 ) 1156 1157 bs.timer(t_sec, self._start_time_bonus_timer) 1158 1159 # Keep track of when this wave finishes emerging. We wanna stop 1160 # dropping land-mines powerups at some point (otherwise a crafty 1161 # player could fill the whole map with them) 1162 self._last_wave_end_time = bs.Time(bs.time() + t_sec) 1163 totalwaves = str(len(self._waves)) if self._waves is not None else '??' 1164 txtval = bs.Lstr( 1165 value='${A} ${B}', 1166 subs=[ 1167 ('${A}', bs.Lstr(resource='waveText')), 1168 ( 1169 '${B}', 1170 str(self._wavenum) 1171 + ( 1172 '' 1173 if self._preset 1174 in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT} 1175 else f'/{totalwaves}' 1176 ), 1177 ), 1178 ], 1179 ) 1180 self._wave_text = bs.NodeActor( 1181 bs.newnode( 1182 'text', 1183 attrs={ 1184 'v_attach': 'top', 1185 'h_attach': 'center', 1186 'h_align': 'center', 1187 'vr_depth': -10, 1188 'color': (1, 1, 1, 1), 1189 'shadow': 1.0, 1190 'flatness': 1.0, 1191 'position': (0, -40), 1192 'scale': 1.3, 1193 'text': txtval, 1194 }, 1195 ) 1196 ) 1197 1198 def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None: 1199 # Add our custom update callback and set some info for this bot. 1200 spaz_type = type(spaz) 1201 assert spaz is not None 1202 spaz.update_callback = self._update_bot 1203 1204 # Tack some custom attrs onto the spaz. 1205 setattr(spaz, 'r_walk_row', path) 1206 setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type)) 1207 1208 def add_bot_at_point( 1209 self, 1210 point: Point, 1211 spaztype: type[SpazBot], 1212 path: int, 1213 spawn_time: float = 0.1, 1214 ) -> None: 1215 """Add the given type bot with the given delay (in seconds).""" 1216 1217 # Don't add if the game has ended. 1218 if self._game_over: 1219 return 1220 pos = self.map.defs.points[point.value][:3] 1221 self._bots.spawn_bot( 1222 spaztype, 1223 pos=pos, 1224 spawn_time=spawn_time, 1225 on_spawn_call=bs.Call(self._on_bot_spawn, path), 1226 ) 1227 1228 def _update_time_bonus(self) -> None: 1229 self._time_bonus = int(self._time_bonus * 0.91) 1230 if self._time_bonus > 0 and self._time_bonus_text is not None: 1231 assert self._time_bonus_text.node 1232 assert self._time_bonus_mult 1233 self._time_bonus_text.node.text = bs.Lstr( 1234 value='${A}: ${B}', 1235 subs=[ 1236 ('${A}', bs.Lstr(resource='timeBonusText')), 1237 ( 1238 '${B}', 1239 str(int(self._time_bonus * self._time_bonus_mult)), 1240 ), 1241 ], 1242 ) 1243 else: 1244 self._time_bonus_text = None 1245 1246 def _start_updating_waves(self) -> None: 1247 self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True) 1248 1249 def _update_scores(self) -> None: 1250 score = self._score 1251 if self._preset is Preset.ENDLESS: 1252 if score >= 500: 1253 self._award_achievement('Runaround Master') 1254 if score >= 1000: 1255 self._award_achievement('Runaround Wizard') 1256 if score >= 2000: 1257 self._award_achievement('Runaround God') 1258 1259 assert self._scoreboard is not None 1260 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1261 1262 def _update_bot(self, bot: SpazBot) -> bool: 1263 # Yup; that's a lot of return statements right there. 1264 # pylint: disable=too-many-return-statements 1265 1266 if not bool(bot): 1267 return True 1268 1269 assert bot.node 1270 1271 # FIXME: Do this in a type safe way. 1272 r_walk_speed: float = getattr(bot, 'r_walk_speed') 1273 r_walk_row: int = getattr(bot, 'r_walk_row') 1274 1275 speed = r_walk_speed 1276 pos = bot.node.position 1277 boxes = self.map.defs.boxes 1278 1279 # Bots in row 1 attempt the high road.. 1280 if r_walk_row == 1: 1281 if bs.is_point_in_box(pos, boxes['b4']): 1282 bot.node.move_up_down = speed 1283 bot.node.move_left_right = 0 1284 bot.node.run = 0.0 1285 return True 1286 1287 # Row 1 and 2 bots attempt the middle road.. 1288 if r_walk_row in [1, 2]: 1289 if bs.is_point_in_box(pos, boxes['b1']): 1290 bot.node.move_up_down = speed 1291 bot.node.move_left_right = 0 1292 bot.node.run = 0.0 1293 return True 1294 1295 # All bots settle for the third row. 1296 if bs.is_point_in_box(pos, boxes['b7']): 1297 bot.node.move_up_down = speed 1298 bot.node.move_left_right = 0 1299 bot.node.run = 0.0 1300 return True 1301 if bs.is_point_in_box(pos, boxes['b2']): 1302 bot.node.move_up_down = -speed 1303 bot.node.move_left_right = 0 1304 bot.node.run = 0.0 1305 return True 1306 if bs.is_point_in_box(pos, boxes['b3']): 1307 bot.node.move_up_down = -speed 1308 bot.node.move_left_right = 0 1309 bot.node.run = 0.0 1310 return True 1311 if bs.is_point_in_box(pos, boxes['b5']): 1312 bot.node.move_up_down = -speed 1313 bot.node.move_left_right = 0 1314 bot.node.run = 0.0 1315 return True 1316 if bs.is_point_in_box(pos, boxes['b6']): 1317 bot.node.move_up_down = speed 1318 bot.node.move_left_right = 0 1319 bot.node.run = 0.0 1320 return True 1321 if ( 1322 bs.is_point_in_box(pos, boxes['b8']) 1323 and not bs.is_point_in_box(pos, boxes['b9']) 1324 ) or pos == (0.0, 0.0, 0.0): 1325 # Default to walking right if we're still in the walking area. 1326 bot.node.move_left_right = speed 1327 bot.node.move_up_down = 0 1328 bot.node.run = 0.0 1329 return True 1330 1331 # Revert to normal bot behavior otherwise.. 1332 return False 1333 1334 @override 1335 def handlemessage(self, msg: Any) -> Any: 1336 if isinstance(msg, bs.PlayerScoredMessage): 1337 self._score += msg.score 1338 self._update_scores() 1339 1340 elif isinstance(msg, bs.PlayerDiedMessage): 1341 # Augment standard behavior. 1342 super().handlemessage(msg) 1343 1344 self._a_player_has_been_killed = True 1345 1346 # Respawn them shortly. 1347 player = msg.getplayer(Player) 1348 assert self.initialplayerinfos is not None 1349 respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 1350 player.respawn_timer = bs.Timer( 1351 respawn_time, bs.Call(self.spawn_player_if_exists, player) 1352 ) 1353 player.respawn_icon = RespawnIcon(player, respawn_time) 1354 1355 elif isinstance(msg, SpazBotDiedMessage): 1356 if msg.how is bs.DeathType.REACHED_GOAL: 1357 return None 1358 pts, importance = msg.spazbot.get_death_points(msg.how) 1359 if msg.killerplayer is not None: 1360 target: Sequence[float] | None 1361 try: 1362 assert msg.spazbot is not None 1363 assert msg.spazbot.node 1364 target = msg.spazbot.node.position 1365 except Exception: 1366 logging.exception('Error getting SpazBotDied target.') 1367 target = None 1368 try: 1369 if msg.killerplayer: 1370 self.stats.player_scored( 1371 msg.killerplayer, 1372 pts, 1373 target=target, 1374 kill=True, 1375 screenmessage=False, 1376 importance=importance, 1377 ) 1378 dingsound = ( 1379 self._dingsound 1380 if importance == 1 1381 else self._dingsoundhigh 1382 ) 1383 dingsound.play(volume=0.6) 1384 except Exception: 1385 logging.exception('Error on SpazBotDiedMessage.') 1386 1387 # Normally we pull scores from the score-set, but if there's no 1388 # player lets be explicit. 1389 else: 1390 self._score += pts 1391 self._update_scores() 1392 1393 else: 1394 return super().handlemessage(msg) 1395 return None 1396 1397 def _get_bot_speed(self, bot_type: type[SpazBot]) -> float: 1398 speed = self._bot_speed_map.get(bot_type) 1399 if speed is None: 1400 raise TypeError( 1401 'Invalid bot type to _get_bot_speed(): ' + str(bot_type) 1402 ) 1403 return speed 1404 1405 def _set_can_end_wave(self) -> None: 1406 self._can_end_wave = True 1407 1408 def heart_dyin(self, status: bool, time: float = 1.22) -> None: 1409 """Makes the UI heart beat at low health.""" 1410 assert self._lives_bg is not None 1411 if self._lives_bg.node.exists(): 1412 return 1413 heart = self._lives_bg.node 1414 1415 # Make the heart beat intensely! 1416 if status: 1417 bs.animate_array( 1418 heart, 1419 'scale', 1420 2, 1421 { 1422 0: (90, 90), 1423 time * 0.1: (105, 105), 1424 time * 0.21: (88, 88), 1425 time * 0.42: (90, 90), 1426 time * 0.52: (105, 105), 1427 time * 0.63: (88, 88), 1428 time: (90, 90), 1429 }, 1430 ) 1431 1432 # Neutralize heartbeat (Done did when dead.) 1433 else: 1434 # Ew; janky old scenev1 has a single 'Node' Python type so 1435 # it thinks heart.scale could be a few different things 1436 # (float, Sequence[float], etc.). So we have to force the 1437 # issue with a cast(). This should go away with scenev2/etc. 1438 bs.animate_array( 1439 heart, 1440 'scale', 1441 2, 1442 { 1443 0.0: cast(Sequence[float], heart.scale), 1444 time: (90, 90), 1445 }, 1446 )
51class Preset(Enum): 52 """Play presets.""" 53 54 ENDLESS = 'endless' 55 ENDLESS_TOURNAMENT = 'endless_tournament' 56 PRO = 'pro' 57 PRO_EASY = 'pro_easy' 58 UBER = 'uber' 59 UBER_EASY = 'uber_easy' 60 TOURNAMENT = 'tournament' 61 TOURNAMENT_UBER = 'tournament_uber'
Play presets.
Inherited Members
- enum.Enum
- name
- value
64class Point(Enum): 65 """Where we can spawn stuff and the corresponding map attr name.""" 66 67 BOTTOM_LEFT = 'bot_spawn_bottom_left' 68 BOTTOM_RIGHT = 'bot_spawn_bottom_right' 69 START = 'bot_spawn_start'
Where we can spawn stuff and the corresponding map attr name.
Inherited Members
- enum.Enum
- name
- value
72@dataclass 73class Spawn: 74 """Defines a bot spawn event.""" 75 76 # noinspection PyUnresolvedReferences 77 type: type[SpazBot] 78 path: int = 0 79 point: Point | None = None
Defines a bot spawn event.
Defines spacing between spawns.
89@dataclass 90class Wave: 91 """Defines a wave of enemies.""" 92 93 entries: list[Spawn | Spacing | None]
Defines a wave of enemies.
96class Player(bs.Player['Team']): 97 """Our player type for this game.""" 98 99 def __init__(self) -> None: 100 self.respawn_timer: bs.Timer | None = None 101 self.respawn_icon: RespawnIcon | None = None
Our player type for this game.
Inherited Members
- bascenev1._player.Player
- character
- actor
- color
- highlight
- on_expire
- team
- customdata
- sessionplayer
- node
- position
- exists
- getname
- is_alive
- get_icon
- assigninput
- resetinput
Our team type for this game.
Inherited Members
- bascenev1._team.Team
- players
- id
- name
- color
- manual_init
- customdata
- on_expire
- sessionteam
108class RunaroundGame(bs.CoopGameActivity[Player, Team]): 109 """Game involving trying to bomb bots as they walk through the map.""" 110 111 name = 'Runaround' 112 description = 'Prevent enemies from reaching the exit.' 113 tips = [ 114 'Jump just as you\'re throwing to get bombs up to the highest levels.', 115 'No, you can\'t get up on the ledge. You have to throw bombs.', 116 'Whip back and forth to get more distance on your throws..', 117 ] 118 default_music = bs.MusicType.MARCHING 119 120 # How fast our various bot types walk. 121 _bot_speed_map: dict[type[SpazBot], float] = { 122 BomberBot: 0.48, 123 BomberBotPro: 0.48, 124 BomberBotProShielded: 0.48, 125 BrawlerBot: 0.57, 126 BrawlerBotPro: 0.57, 127 BrawlerBotProShielded: 0.57, 128 TriggerBot: 0.73, 129 TriggerBotPro: 0.78, 130 TriggerBotProShielded: 0.78, 131 ChargerBot: 1.0, 132 ChargerBotProShielded: 1.0, 133 ExplodeyBot: 1.0, 134 StickyBot: 0.5, 135 } 136 137 def __init__(self, settings: dict): 138 settings['map'] = 'Tower D' 139 super().__init__(settings) 140 shared = SharedObjects.get() 141 self._preset = Preset(settings.get('preset', 'pro')) 142 143 self._player_death_sound = bs.getsound('playerDeath') 144 self._new_wave_sound = bs.getsound('scoreHit01') 145 self._winsound = bs.getsound('score') 146 self._cashregistersound = bs.getsound('cashRegister') 147 self._bad_guy_score_sound = bs.getsound('shieldDown') 148 self._heart_tex = bs.gettexture('heart') 149 self._heart_mesh_opaque = bs.getmesh('heartOpaque') 150 self._heart_mesh_transparent = bs.getmesh('heartTransparent') 151 152 self._a_player_has_been_killed = False 153 self._spawn_center = self._map_type.defs.points['spawn1'][0:3] 154 self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3] 155 self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3] 156 self._powerup_spread = ( 157 self._map_type.defs.boxes['powerup_region'][6] * 0.5, 158 self._map_type.defs.boxes['powerup_region'][8] * 0.5, 159 ) 160 161 self._score_region_material = bs.Material() 162 self._score_region_material.add_actions( 163 conditions=('they_have_material', shared.player_material), 164 actions=( 165 ('modify_part_collision', 'collide', True), 166 ('modify_part_collision', 'physical', False), 167 ('call', 'at_connect', self._handle_reached_end), 168 ), 169 ) 170 171 self._last_wave_end_time = bs.time() 172 self._player_has_picked_up_powerup = False 173 self._scoreboard: Scoreboard | None = None 174 self._game_over = False 175 self._wavenum = 0 176 self._can_end_wave = True 177 self._score = 0 178 self._time_bonus = 0 179 self._score_region: bs.Actor | None = None 180 self._dingsound = bs.getsound('dingSmall') 181 self._dingsoundhigh = bs.getsound('dingSmallHigh') 182 self._exclude_powerups: list[str] | None = None 183 self._have_tnt: bool | None = None 184 self._waves: list[Wave] | None = None 185 self._bots = SpazBotSet() 186 self._tntspawner: TNTSpawner | None = None 187 self._lives_bg: bs.NodeActor | None = None 188 self._start_lives = 10 189 self._lives = self._start_lives 190 self._lives_text: bs.NodeActor | None = None 191 self._flawless = True 192 self._time_bonus_timer: bs.Timer | None = None 193 self._lives_hbtime: bs.Timer | None = None 194 self._time_bonus_text: bs.NodeActor | None = None 195 self._time_bonus_mult: float | None = None 196 self._wave_text: bs.NodeActor | None = None 197 self._flawless_bonus: int | None = None 198 self._wave_update_timer: bs.Timer | None = None 199 200 @override 201 def on_transition_in(self) -> None: 202 super().on_transition_in() 203 self._scoreboard = Scoreboard( 204 label=bs.Lstr(resource='scoreText'), score_split=0.5 205 ) 206 self._score_region = bs.NodeActor( 207 bs.newnode( 208 'region', 209 attrs={ 210 'position': self.map.defs.boxes['score_region'][0:3], 211 'scale': self.map.defs.boxes['score_region'][6:9], 212 'type': 'box', 213 'materials': [self._score_region_material], 214 }, 215 ) 216 ) 217 218 @override 219 def on_begin(self) -> None: 220 super().on_begin() 221 player_count = len(self.players) 222 hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY} 223 224 if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}: 225 self._exclude_powerups = ['curse'] 226 self._have_tnt = True 227 self._waves = [ 228 Wave( 229 entries=[ 230 Spawn(BomberBot, path=3 if hard else 2), 231 Spawn(BomberBot, path=2), 232 Spawn(BomberBot, path=2) if hard else None, 233 Spawn(BomberBot, path=2) if player_count > 1 else None, 234 Spawn(BomberBot, path=1) if hard else None, 235 Spawn(BomberBot, path=1) if player_count > 2 else None, 236 Spawn(BomberBot, path=1) if player_count > 3 else None, 237 ] 238 ), 239 Wave( 240 entries=[ 241 Spawn(BomberBot, path=1) if hard else None, 242 Spawn(BomberBot, path=2) if hard else None, 243 Spawn(BomberBot, path=2), 244 Spawn(BomberBot, path=2), 245 Spawn(BomberBot, path=2) if player_count > 3 else None, 246 Spawn(BrawlerBot, path=3), 247 Spawn(BrawlerBot, path=3), 248 Spawn(BrawlerBot, path=3) if hard else None, 249 Spawn(BrawlerBot, path=3) if player_count > 1 else None, 250 Spawn(BrawlerBot, path=3) if player_count > 2 else None, 251 ] 252 ), 253 Wave( 254 entries=[ 255 Spawn(ChargerBot, path=2) if hard else None, 256 Spawn(ChargerBot, path=2) if player_count > 2 else None, 257 Spawn(TriggerBot, path=2), 258 Spawn(TriggerBot, path=2) if player_count > 1 else None, 259 Spacing(duration=3.0), 260 Spawn(BomberBot, path=2) if hard else None, 261 Spawn(BomberBot, path=2) if hard else None, 262 Spawn(BomberBot, path=2), 263 Spawn(BomberBot, path=3) if hard else None, 264 Spawn(BomberBot, path=3), 265 Spawn(BomberBot, path=3), 266 Spawn(BomberBot, path=3) if player_count > 3 else None, 267 ] 268 ), 269 Wave( 270 entries=[ 271 Spawn(TriggerBot, path=1) if hard else None, 272 Spacing(duration=1.0) if hard else None, 273 Spawn(TriggerBot, path=2), 274 Spacing(duration=1.0), 275 Spawn(TriggerBot, path=3), 276 Spacing(duration=1.0), 277 Spawn(TriggerBot, path=1) if hard else None, 278 Spacing(duration=1.0) if hard else None, 279 Spawn(TriggerBot, path=2), 280 Spacing(duration=1.0), 281 Spawn(TriggerBot, path=3), 282 Spacing(duration=1.0), 283 ( 284 Spawn(TriggerBot, path=1) 285 if (player_count > 1 and hard) 286 else None 287 ), 288 Spacing(duration=1.0), 289 Spawn(TriggerBot, path=2) if player_count > 2 else None, 290 Spacing(duration=1.0), 291 Spawn(TriggerBot, path=3) if player_count > 3 else None, 292 Spacing(duration=1.0), 293 ] 294 ), 295 Wave( 296 entries=[ 297 Spawn( 298 ChargerBotProShielded if hard else ChargerBot, 299 path=1, 300 ), 301 Spawn(BrawlerBot, path=2) if hard else None, 302 Spawn(BrawlerBot, path=2), 303 Spawn(BrawlerBot, path=2), 304 Spawn(BrawlerBot, path=3) if hard else None, 305 Spawn(BrawlerBot, path=3), 306 Spawn(BrawlerBot, path=3), 307 Spawn(BrawlerBot, path=3) if player_count > 1 else None, 308 Spawn(BrawlerBot, path=3) if player_count > 2 else None, 309 Spawn(BrawlerBot, path=3) if player_count > 3 else None, 310 ] 311 ), 312 Wave( 313 entries=[ 314 Spawn(BomberBotProShielded, path=3), 315 Spacing(duration=1.5), 316 Spawn(BomberBotProShielded, path=2), 317 Spacing(duration=1.5), 318 Spawn(BomberBotProShielded, path=1) if hard else None, 319 Spacing(duration=1.0) if hard else None, 320 Spawn(BomberBotProShielded, path=3), 321 Spacing(duration=1.5), 322 Spawn(BomberBotProShielded, path=2), 323 Spacing(duration=1.5), 324 Spawn(BomberBotProShielded, path=1) if hard else None, 325 Spacing(duration=1.5) if hard else None, 326 ( 327 Spawn(BomberBotProShielded, path=3) 328 if player_count > 1 329 else None 330 ), 331 Spacing(duration=1.5), 332 ( 333 Spawn(BomberBotProShielded, path=2) 334 if player_count > 2 335 else None 336 ), 337 Spacing(duration=1.5), 338 ( 339 Spawn(BomberBotProShielded, path=1) 340 if player_count > 3 341 else None 342 ), 343 ] 344 ), 345 ] 346 elif self._preset in { 347 Preset.UBER_EASY, 348 Preset.UBER, 349 Preset.TOURNAMENT_UBER, 350 }: 351 self._exclude_powerups = [] 352 self._have_tnt = True 353 self._waves = [ 354 Wave( 355 entries=[ 356 Spawn(TriggerBot, path=1) if hard else None, 357 Spawn(TriggerBot, path=2), 358 Spawn(TriggerBot, path=2), 359 Spawn(TriggerBot, path=3), 360 Spawn( 361 BrawlerBotPro if hard else BrawlerBot, 362 point=Point.BOTTOM_LEFT, 363 ), 364 ( 365 Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT) 366 if player_count > 2 367 else None 368 ), 369 ] 370 ), 371 Wave( 372 entries=[ 373 Spawn(ChargerBot, path=2), 374 Spawn(ChargerBot, path=3), 375 Spawn(ChargerBot, path=1) if hard else None, 376 Spawn(ChargerBot, path=2), 377 Spawn(ChargerBot, path=3), 378 Spawn(ChargerBot, path=1) if player_count > 2 else None, 379 ] 380 ), 381 Wave( 382 entries=[ 383 Spawn(BomberBotProShielded, path=1) if hard else None, 384 Spawn(BomberBotProShielded, path=2), 385 Spawn(BomberBotProShielded, path=2), 386 Spawn(BomberBotProShielded, path=3), 387 Spawn(BomberBotProShielded, path=3), 388 Spawn(ChargerBot, point=Point.BOTTOM_RIGHT), 389 ( 390 Spawn(ChargerBot, point=Point.BOTTOM_LEFT) 391 if player_count > 2 392 else None 393 ), 394 ] 395 ), 396 Wave( 397 entries=[ 398 Spawn(TriggerBotPro, path=1) if hard else None, 399 Spawn(TriggerBotPro, path=1 if hard else 2), 400 Spawn(TriggerBotPro, path=1 if hard else 2), 401 Spawn(TriggerBotPro, path=1 if hard else 2), 402 Spawn(TriggerBotPro, path=1 if hard else 2), 403 Spawn(TriggerBotPro, path=1 if hard else 2), 404 ( 405 Spawn(TriggerBotPro, path=1 if hard else 2) 406 if player_count > 1 407 else None 408 ), 409 ( 410 Spawn(TriggerBotPro, path=1 if hard else 2) 411 if player_count > 3 412 else None 413 ), 414 ] 415 ), 416 Wave( 417 entries=[ 418 Spawn( 419 TriggerBotProShielded if hard else TriggerBotPro, 420 point=Point.BOTTOM_LEFT, 421 ), 422 ( 423 Spawn( 424 TriggerBotProShielded, point=Point.BOTTOM_RIGHT 425 ) 426 if hard 427 else None 428 ), 429 ( 430 Spawn( 431 TriggerBotProShielded, point=Point.BOTTOM_RIGHT 432 ) 433 if player_count > 2 434 else None 435 ), 436 Spawn(BomberBot, path=3), 437 Spawn(BomberBot, path=3), 438 Spacing(duration=5.0), 439 Spawn(BrawlerBot, path=2), 440 Spawn(BrawlerBot, path=2), 441 Spacing(duration=5.0), 442 Spawn(TriggerBot, path=1) if hard else None, 443 Spawn(TriggerBot, path=1) if hard else None, 444 ] 445 ), 446 Wave( 447 entries=[ 448 Spawn(BomberBotProShielded, path=2), 449 Spawn(BomberBotProShielded, path=2) if hard else None, 450 Spawn(StickyBot, point=Point.BOTTOM_RIGHT), 451 Spawn(BomberBotProShielded, path=2), 452 Spawn(BomberBotProShielded, path=2), 453 ( 454 Spawn(StickyBot, point=Point.BOTTOM_RIGHT) 455 if player_count > 2 456 else None 457 ), 458 Spawn(BomberBotProShielded, path=2), 459 Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT), 460 Spawn(BomberBotProShielded, path=2), 461 ( 462 Spawn(BomberBotProShielded, path=2) 463 if player_count > 1 464 else None 465 ), 466 Spacing(duration=5.0), 467 Spawn(StickyBot, point=Point.BOTTOM_LEFT), 468 Spacing(duration=2.0), 469 Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT), 470 ] 471 ), 472 ] 473 elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 474 self._exclude_powerups = [] 475 self._have_tnt = True 476 477 # Spit out a few powerups and start dropping more shortly. 478 self._drop_powerups(standard_points=True) 479 bs.timer(4.0, self._start_powerup_drops) 480 self.setup_low_life_warning_sound() 481 self._update_scores() 482 483 # Our TNT spawner (if applicable). 484 if self._have_tnt: 485 self._tntspawner = TNTSpawner(position=self._tntspawnpos) 486 487 # Make sure to stay out of the way of menu/party buttons in the corner. 488 assert bs.app.classic is not None 489 uiscale = bs.app.ui_v1.uiscale 490 l_offs = ( 491 -80 492 if uiscale is bs.UIScale.SMALL 493 else -40 if uiscale is bs.UIScale.MEDIUM else 0 494 ) 495 496 self._lives_bg = bs.NodeActor( 497 bs.newnode( 498 'image', 499 attrs={ 500 'texture': self._heart_tex, 501 'mesh_opaque': self._heart_mesh_opaque, 502 'mesh_transparent': self._heart_mesh_transparent, 503 'attach': 'topRight', 504 'scale': (90, 90), 505 'position': (-110 + l_offs, -50), 506 'color': (1, 0.2, 0.2), 507 }, 508 ) 509 ) 510 # FIXME; should not set things based on vr mode. 511 # (won't look right to non-vr connected clients, etc) 512 vrmode = bs.app.env.vr 513 self._lives_text = bs.NodeActor( 514 bs.newnode( 515 'text', 516 attrs={ 517 'v_attach': 'top', 518 'h_attach': 'right', 519 'h_align': 'center', 520 'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0), 521 'flatness': 1.0 if vrmode else 0.5, 522 'shadow': 1.0 if vrmode else 0.5, 523 'vr_depth': 10, 524 'position': (-113 + l_offs, -69), 525 'scale': 1.3, 526 'text': str(self._lives), 527 }, 528 ) 529 ) 530 531 bs.timer(2.0, self._start_updating_waves) 532 533 def _handle_reached_end(self) -> None: 534 spaz = bs.getcollision().opposingnode.getdelegate(SpazBot, True) 535 if not spaz.is_alive(): 536 return # Ignore bodies flying in. 537 538 self._flawless = False 539 pos = spaz.node.position 540 self._bad_guy_score_sound.play(position=pos) 541 light = bs.newnode( 542 'light', attrs={'position': pos, 'radius': 0.5, 'color': (1, 0, 0)} 543 ) 544 bs.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False) 545 bs.timer(1.0, light.delete) 546 spaz.handlemessage( 547 bs.DieMessage(immediate=True, how=bs.DeathType.REACHED_GOAL) 548 ) 549 550 if self._lives > 0: 551 self._lives -= 1 552 if self._lives == 0: 553 self._bots.stop_moving() 554 self.continue_or_end_game() 555 556 # Heartbeat behavior 557 if self._lives < 5: 558 hbtime = 0.39 + (0.21 * self._lives) 559 self._lives_hbtime = bs.Timer( 560 hbtime, lambda: self.heart_dyin(True, hbtime), repeat=True 561 ) 562 self.heart_dyin(True) 563 else: 564 self._lives_hbtime = None 565 self.heart_dyin(False) 566 567 assert self._lives_text is not None 568 assert self._lives_text.node 569 self._lives_text.node.text = str(self._lives) 570 delay = 0.0 571 572 def _safesetattr(node: bs.Node, attr: str, value: Any) -> None: 573 if node: 574 setattr(node, attr, value) 575 576 for _i in range(4): 577 bs.timer( 578 delay, 579 bs.Call( 580 _safesetattr, 581 self._lives_text.node, 582 'color', 583 (1, 0, 0, 1.0), 584 ), 585 ) 586 assert self._lives_bg is not None 587 assert self._lives_bg.node 588 bs.timer( 589 delay, 590 bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5), 591 ) 592 delay += 0.125 593 bs.timer( 594 delay, 595 bs.Call( 596 _safesetattr, 597 self._lives_text.node, 598 'color', 599 (1.0, 1.0, 0.0, 1.0), 600 ), 601 ) 602 bs.timer( 603 delay, 604 bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0), 605 ) 606 delay += 0.125 607 bs.timer( 608 delay, 609 bs.Call( 610 _safesetattr, 611 self._lives_text.node, 612 'color', 613 (0.8, 0.8, 0.8, 1.0), 614 ), 615 ) 616 617 @override 618 def on_continue(self) -> None: 619 self._lives = 3 620 assert self._lives_text is not None 621 assert self._lives_text.node 622 self._lives_text.node.text = str(self._lives) 623 self._bots.start_moving() 624 625 @override 626 def spawn_player(self, player: Player) -> bs.Actor: 627 pos = ( 628 self._spawn_center[0] + random.uniform(-1.5, 1.5), 629 self._spawn_center[1], 630 self._spawn_center[2] + random.uniform(-1.5, 1.5), 631 ) 632 spaz = self.spawn_player_spaz(player, position=pos) 633 if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}: 634 spaz.impact_scale = 0.25 635 636 # Add the material that causes us to hit the player-wall. 637 spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup 638 return spaz 639 640 def _on_player_picked_up_powerup(self, player: bs.Actor) -> None: 641 del player # Unused. 642 self._player_has_picked_up_powerup = True 643 644 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 645 if poweruptype is None: 646 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 647 excludetypes=self._exclude_powerups 648 ) 649 PowerupBox( 650 position=self.map.powerup_spawn_points[index], 651 poweruptype=poweruptype, 652 ).autoretain() 653 654 def _start_powerup_drops(self) -> None: 655 bs.timer(3.0, self._drop_powerups, repeat=True) 656 657 def _drop_powerups( 658 self, standard_points: bool = False, force_first: str | None = None 659 ) -> None: 660 """Generic powerup drop.""" 661 662 # If its been a minute since our last wave finished emerging, stop 663 # giving out land-mine powerups. (prevents players from waiting 664 # around for them on purpose and filling the map up) 665 if bs.time() - self._last_wave_end_time > 60.0: 666 extra_excludes = ['land_mines'] 667 else: 668 extra_excludes = [] 669 670 if standard_points: 671 points = self.map.powerup_spawn_points 672 for i in range(len(points)): 673 bs.timer( 674 1.0 + i * 0.5, 675 bs.Call( 676 self._drop_powerup, i, force_first if i == 0 else None 677 ), 678 ) 679 else: 680 pos = ( 681 self._powerup_center[0] 682 + random.uniform( 683 -1.0 * self._powerup_spread[0], 684 1.0 * self._powerup_spread[0], 685 ), 686 self._powerup_center[1], 687 self._powerup_center[2] 688 + random.uniform( 689 -self._powerup_spread[1], self._powerup_spread[1] 690 ), 691 ) 692 693 # drop one random one somewhere.. 694 assert self._exclude_powerups is not None 695 PowerupBox( 696 position=pos, 697 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 698 excludetypes=self._exclude_powerups + extra_excludes 699 ), 700 ).autoretain() 701 702 @override 703 def end_game(self) -> None: 704 bs.pushcall(bs.Call(self.do_end, 'defeat')) 705 bs.setmusic(None) 706 self._player_death_sound.play() 707 708 def do_end(self, outcome: str) -> None: 709 """End the game now with the provided outcome.""" 710 711 if outcome == 'defeat': 712 delay = 2.0 713 self.fade_to_red() 714 else: 715 delay = 0 716 717 score: int | None 718 if self._wavenum >= 2: 719 score = self._score 720 fail_message = None 721 else: 722 score = None 723 fail_message = bs.Lstr(resource='reachWave2Text') 724 725 self.end( 726 delay=delay, 727 results={ 728 'outcome': outcome, 729 'score': score, 730 'fail_message': fail_message, 731 'playerinfos': self.initialplayerinfos, 732 }, 733 ) 734 735 def _update_waves(self) -> None: 736 # pylint: disable=too-many-branches 737 738 # If we have no living bots, go to the next wave. 739 if ( 740 self._can_end_wave 741 and not self._bots.have_living_bots() 742 and not self._game_over 743 and self._lives > 0 744 ): 745 self._can_end_wave = False 746 self._time_bonus_timer = None 747 self._time_bonus_text = None 748 749 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 750 won = False 751 else: 752 assert self._waves is not None 753 won = self._wavenum == len(self._waves) 754 755 # Reward time bonus. 756 base_delay = 4.0 if won else 0 757 if self._time_bonus > 0: 758 bs.timer(0, self._cashregistersound.play) 759 bs.timer( 760 base_delay, 761 bs.Call(self._award_time_bonus, self._time_bonus), 762 ) 763 base_delay += 1.0 764 765 # Reward flawless bonus. 766 if self._wavenum > 0 and self._flawless: 767 bs.timer(base_delay, self._award_flawless_bonus) 768 base_delay += 1.0 769 770 self._flawless = True # reset 771 772 if won: 773 # Completion achievements: 774 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 775 self._award_achievement( 776 'Pro Runaround Victory', sound=False 777 ) 778 if self._lives == self._start_lives: 779 self._award_achievement('The Wall', sound=False) 780 if not self._player_has_picked_up_powerup: 781 self._award_achievement( 782 'Precision Bombing', sound=False 783 ) 784 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 785 self._award_achievement( 786 'Uber Runaround Victory', sound=False 787 ) 788 if self._lives == self._start_lives: 789 self._award_achievement('The Great Wall', sound=False) 790 if not self._a_player_has_been_killed: 791 self._award_achievement('Stayin\' Alive', sound=False) 792 793 # Give remaining players some points and have them celebrate. 794 self.show_zoom_message( 795 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 796 ) 797 798 self.celebrate(10.0) 799 bs.timer(base_delay, self._award_lives_bonus) 800 base_delay += 1.0 801 bs.timer(base_delay, self._award_completion_bonus) 802 base_delay += 0.85 803 self._winsound.play() 804 bs.cameraflash() 805 bs.setmusic(bs.MusicType.VICTORY) 806 self._game_over = True 807 bs.timer(base_delay, bs.Call(self.do_end, 'victory')) 808 return 809 810 self._wavenum += 1 811 812 # Short celebration after waves. 813 if self._wavenum > 1: 814 self.celebrate(0.5) 815 816 bs.timer(base_delay, self._start_next_wave) 817 818 def _award_completion_bonus(self) -> None: 819 bonus = 200 820 self._cashregistersound.play() 821 PopupText( 822 bs.Lstr( 823 value='+${A} ${B}', 824 subs=[ 825 ('${A}', str(bonus)), 826 ('${B}', bs.Lstr(resource='completionBonusText')), 827 ], 828 ), 829 color=(0.7, 0.7, 1.0, 1), 830 scale=1.6, 831 position=(0, 1.5, -1), 832 ).autoretain() 833 self._score += bonus 834 self._update_scores() 835 836 def _award_lives_bonus(self) -> None: 837 bonus = self._lives * 30 838 self._cashregistersound.play() 839 PopupText( 840 bs.Lstr( 841 value='+${A} ${B}', 842 subs=[ 843 ('${A}', str(bonus)), 844 ('${B}', bs.Lstr(resource='livesBonusText')), 845 ], 846 ), 847 color=(0.7, 1.0, 0.3, 1), 848 scale=1.3, 849 position=(0, 1, -1), 850 ).autoretain() 851 self._score += bonus 852 self._update_scores() 853 854 def _award_time_bonus(self, bonus: int) -> None: 855 self._cashregistersound.play() 856 PopupText( 857 bs.Lstr( 858 value='+${A} ${B}', 859 subs=[ 860 ('${A}', str(bonus)), 861 ('${B}', bs.Lstr(resource='timeBonusText')), 862 ], 863 ), 864 color=(1, 1, 0.5, 1), 865 scale=1.0, 866 position=(0, 3, -1), 867 ).autoretain() 868 869 self._score += self._time_bonus 870 self._update_scores() 871 872 def _award_flawless_bonus(self) -> None: 873 self._cashregistersound.play() 874 PopupText( 875 bs.Lstr( 876 value='+${A} ${B}', 877 subs=[ 878 ('${A}', str(self._flawless_bonus)), 879 ('${B}', bs.Lstr(resource='perfectWaveText')), 880 ], 881 ), 882 color=(1, 1, 0.2, 1), 883 scale=1.2, 884 position=(0, 2, -1), 885 ).autoretain() 886 887 assert self._flawless_bonus is not None 888 self._score += self._flawless_bonus 889 self._update_scores() 890 891 def _start_time_bonus_timer(self) -> None: 892 self._time_bonus_timer = bs.Timer( 893 1.0, self._update_time_bonus, repeat=True 894 ) 895 896 def _start_next_wave(self) -> None: 897 # FIXME: Need to split this up. 898 # pylint: disable=too-many-locals 899 # pylint: disable=too-many-branches 900 # pylint: disable=too-many-statements 901 self.show_zoom_message( 902 bs.Lstr( 903 value='${A} ${B}', 904 subs=[ 905 ('${A}', bs.Lstr(resource='waveText')), 906 ('${B}', str(self._wavenum)), 907 ], 908 ), 909 scale=1.0, 910 duration=1.0, 911 trail=True, 912 ) 913 bs.timer(0.4, self._new_wave_sound.play) 914 t_sec = 0.0 915 base_delay = 0.5 916 delay = 0.0 917 bot_types: list[Spawn | Spacing | None] = [] 918 919 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 920 level = self._wavenum 921 target_points = (level + 1) * 8.0 922 group_count = random.randint(1, 3) 923 entries: list[Spawn | Spacing | None] = [] 924 spaz_types: list[tuple[type[SpazBot], float]] = [] 925 if level < 6: 926 spaz_types += [(BomberBot, 5.0)] 927 if level < 10: 928 spaz_types += [(BrawlerBot, 5.0)] 929 if level < 15: 930 spaz_types += [(TriggerBot, 6.0)] 931 if level > 5: 932 spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7) 933 if level > 2: 934 spaz_types += [(BomberBotProShielded, 8.0)] * ( 935 1 + (level - 2) // 6 936 ) 937 if level > 6: 938 spaz_types += [(TriggerBotProShielded, 12.0)] * ( 939 1 + (level - 6) // 5 940 ) 941 if level > 1: 942 spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4) 943 if level > 7: 944 spaz_types += [(ChargerBotProShielded, 15.0)] * ( 945 1 + (level - 7) // 3 946 ) 947 948 # Bot type, their effect on target points. 949 defender_types: list[tuple[type[SpazBot], float]] = [ 950 (BomberBot, 0.9), 951 (BrawlerBot, 0.9), 952 (TriggerBot, 0.85), 953 ] 954 if level > 2: 955 defender_types += [(ChargerBot, 0.75)] 956 if level > 4: 957 defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6) 958 if level > 6: 959 defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5) 960 if level > 8: 961 defender_types += [(BrawlerBotProShielded, 0.65)] * ( 962 1 + (level - 5) // 4 963 ) 964 if level > 10: 965 defender_types += [(TriggerBotProShielded, 0.6)] * ( 966 1 + (level - 6) // 3 967 ) 968 969 for group in range(group_count): 970 this_target_point_s = target_points / group_count 971 972 # Adding spacing makes things slightly harder. 973 rval = random.random() 974 if rval < 0.07: 975 spacing = 1.5 976 this_target_point_s *= 0.85 977 elif rval < 0.15: 978 spacing = 1.0 979 this_target_point_s *= 0.9 980 else: 981 spacing = 0.0 982 983 path = random.randint(1, 3) 984 985 # Don't allow hard paths on early levels. 986 if level < 3: 987 if path == 1: 988 path = 3 989 990 # Easy path. 991 if path == 3: 992 pass 993 994 # Harder path. 995 elif path == 2: 996 this_target_point_s *= 0.8 997 998 # Even harder path. 999 elif path == 1: 1000 this_target_point_s *= 0.7 1001 1002 # Looping forward. 1003 elif path == 4: 1004 this_target_point_s *= 0.7 1005 1006 # Looping backward. 1007 elif path == 5: 1008 this_target_point_s *= 0.7 1009 1010 # Random. 1011 elif path == 6: 1012 this_target_point_s *= 0.7 1013 1014 def _add_defender( 1015 defender_type: tuple[type[SpazBot], float], pnt: Point 1016 ) -> tuple[float, Spawn]: 1017 # This is ok because we call it immediately. 1018 # pylint: disable=cell-var-from-loop 1019 return this_target_point_s * defender_type[1], Spawn( 1020 defender_type[0], point=pnt 1021 ) 1022 1023 # Add defenders. 1024 defender_type1 = defender_types[ 1025 random.randrange(len(defender_types)) 1026 ] 1027 defender_type2 = defender_types[ 1028 random.randrange(len(defender_types)) 1029 ] 1030 defender1 = defender2 = None 1031 if ( 1032 (group == 0) 1033 or (group == 1 and level > 3) 1034 or (group == 2 and level > 5) 1035 ): 1036 if random.random() < min(0.75, (level - 1) * 0.11): 1037 this_target_point_s, defender1 = _add_defender( 1038 defender_type1, Point.BOTTOM_LEFT 1039 ) 1040 if random.random() < min(0.75, (level - 1) * 0.04): 1041 this_target_point_s, defender2 = _add_defender( 1042 defender_type2, Point.BOTTOM_RIGHT 1043 ) 1044 1045 spaz_type = spaz_types[random.randrange(len(spaz_types))] 1046 member_count = max( 1047 1, int(round(this_target_point_s / spaz_type[1])) 1048 ) 1049 for i, _member in enumerate(range(member_count)): 1050 if path == 4: 1051 this_path = i % 3 # Looping forward. 1052 elif path == 5: 1053 this_path = 3 - (i % 3) # Looping backward. 1054 elif path == 6: 1055 this_path = random.randint(1, 3) # Random. 1056 else: 1057 this_path = path 1058 entries.append(Spawn(spaz_type[0], path=this_path)) 1059 if spacing != 0.0: 1060 entries.append(Spacing(duration=spacing)) 1061 1062 if defender1 is not None: 1063 entries.append(defender1) 1064 if defender2 is not None: 1065 entries.append(defender2) 1066 1067 # Some spacing between groups. 1068 rval = random.random() 1069 if rval < 0.1: 1070 spacing = 5.0 1071 elif rval < 0.5: 1072 spacing = 1.0 1073 else: 1074 spacing = 1.0 1075 entries.append(Spacing(duration=spacing)) 1076 1077 wave = Wave(entries=entries) 1078 1079 else: 1080 assert self._waves is not None 1081 wave = self._waves[self._wavenum - 1] 1082 1083 bot_types += wave.entries 1084 self._time_bonus_mult = 1.0 1085 this_flawless_bonus = 0 1086 non_runner_spawn_time = 1.0 1087 1088 for info in bot_types: 1089 if info is None: 1090 continue 1091 if isinstance(info, Spacing): 1092 t_sec += info.duration 1093 continue 1094 bot_type = info.type 1095 path = info.path 1096 self._time_bonus_mult += bot_type.points_mult * 0.02 1097 this_flawless_bonus += bot_type.points_mult * 5 1098 1099 # If its got a position, use that. 1100 if info.point is not None: 1101 point = info.point 1102 else: 1103 point = Point.START 1104 1105 # Space our our slower bots. 1106 delay = base_delay 1107 delay /= self._get_bot_speed(bot_type) 1108 t_sec += delay * 0.5 1109 tcall = bs.Call( 1110 self.add_bot_at_point, 1111 point, 1112 bot_type, 1113 path, 1114 0.1 if point is Point.START else non_runner_spawn_time, 1115 ) 1116 bs.timer(t_sec, tcall) 1117 t_sec += delay * 0.5 1118 1119 # We can end the wave after all the spawning happens. 1120 bs.timer( 1121 t_sec - delay * 0.5 + non_runner_spawn_time + 0.01, 1122 self._set_can_end_wave, 1123 ) 1124 1125 # Reset our time bonus. 1126 # In this game we use a constant time bonus so it erodes away in 1127 # roughly the same time (since the time limit a wave can take is 1128 # relatively constant) ..we then post-multiply a modifier to adjust 1129 # points. 1130 self._time_bonus = 150 1131 self._flawless_bonus = this_flawless_bonus 1132 assert self._time_bonus_mult is not None 1133 txtval = bs.Lstr( 1134 value='${A}: ${B}', 1135 subs=[ 1136 ('${A}', bs.Lstr(resource='timeBonusText')), 1137 ('${B}', str(int(self._time_bonus * self._time_bonus_mult))), 1138 ], 1139 ) 1140 self._time_bonus_text = bs.NodeActor( 1141 bs.newnode( 1142 'text', 1143 attrs={ 1144 'v_attach': 'top', 1145 'h_attach': 'center', 1146 'h_align': 'center', 1147 'color': (1, 1, 0.0, 1), 1148 'shadow': 1.0, 1149 'vr_depth': -30, 1150 'flatness': 1.0, 1151 'position': (0, -60), 1152 'scale': 0.8, 1153 'text': txtval, 1154 }, 1155 ) 1156 ) 1157 1158 bs.timer(t_sec, self._start_time_bonus_timer) 1159 1160 # Keep track of when this wave finishes emerging. We wanna stop 1161 # dropping land-mines powerups at some point (otherwise a crafty 1162 # player could fill the whole map with them) 1163 self._last_wave_end_time = bs.Time(bs.time() + t_sec) 1164 totalwaves = str(len(self._waves)) if self._waves is not None else '??' 1165 txtval = bs.Lstr( 1166 value='${A} ${B}', 1167 subs=[ 1168 ('${A}', bs.Lstr(resource='waveText')), 1169 ( 1170 '${B}', 1171 str(self._wavenum) 1172 + ( 1173 '' 1174 if self._preset 1175 in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT} 1176 else f'/{totalwaves}' 1177 ), 1178 ), 1179 ], 1180 ) 1181 self._wave_text = bs.NodeActor( 1182 bs.newnode( 1183 'text', 1184 attrs={ 1185 'v_attach': 'top', 1186 'h_attach': 'center', 1187 'h_align': 'center', 1188 'vr_depth': -10, 1189 'color': (1, 1, 1, 1), 1190 'shadow': 1.0, 1191 'flatness': 1.0, 1192 'position': (0, -40), 1193 'scale': 1.3, 1194 'text': txtval, 1195 }, 1196 ) 1197 ) 1198 1199 def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None: 1200 # Add our custom update callback and set some info for this bot. 1201 spaz_type = type(spaz) 1202 assert spaz is not None 1203 spaz.update_callback = self._update_bot 1204 1205 # Tack some custom attrs onto the spaz. 1206 setattr(spaz, 'r_walk_row', path) 1207 setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type)) 1208 1209 def add_bot_at_point( 1210 self, 1211 point: Point, 1212 spaztype: type[SpazBot], 1213 path: int, 1214 spawn_time: float = 0.1, 1215 ) -> None: 1216 """Add the given type bot with the given delay (in seconds).""" 1217 1218 # Don't add if the game has ended. 1219 if self._game_over: 1220 return 1221 pos = self.map.defs.points[point.value][:3] 1222 self._bots.spawn_bot( 1223 spaztype, 1224 pos=pos, 1225 spawn_time=spawn_time, 1226 on_spawn_call=bs.Call(self._on_bot_spawn, path), 1227 ) 1228 1229 def _update_time_bonus(self) -> None: 1230 self._time_bonus = int(self._time_bonus * 0.91) 1231 if self._time_bonus > 0 and self._time_bonus_text is not None: 1232 assert self._time_bonus_text.node 1233 assert self._time_bonus_mult 1234 self._time_bonus_text.node.text = bs.Lstr( 1235 value='${A}: ${B}', 1236 subs=[ 1237 ('${A}', bs.Lstr(resource='timeBonusText')), 1238 ( 1239 '${B}', 1240 str(int(self._time_bonus * self._time_bonus_mult)), 1241 ), 1242 ], 1243 ) 1244 else: 1245 self._time_bonus_text = None 1246 1247 def _start_updating_waves(self) -> None: 1248 self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True) 1249 1250 def _update_scores(self) -> None: 1251 score = self._score 1252 if self._preset is Preset.ENDLESS: 1253 if score >= 500: 1254 self._award_achievement('Runaround Master') 1255 if score >= 1000: 1256 self._award_achievement('Runaround Wizard') 1257 if score >= 2000: 1258 self._award_achievement('Runaround God') 1259 1260 assert self._scoreboard is not None 1261 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1262 1263 def _update_bot(self, bot: SpazBot) -> bool: 1264 # Yup; that's a lot of return statements right there. 1265 # pylint: disable=too-many-return-statements 1266 1267 if not bool(bot): 1268 return True 1269 1270 assert bot.node 1271 1272 # FIXME: Do this in a type safe way. 1273 r_walk_speed: float = getattr(bot, 'r_walk_speed') 1274 r_walk_row: int = getattr(bot, 'r_walk_row') 1275 1276 speed = r_walk_speed 1277 pos = bot.node.position 1278 boxes = self.map.defs.boxes 1279 1280 # Bots in row 1 attempt the high road.. 1281 if r_walk_row == 1: 1282 if bs.is_point_in_box(pos, boxes['b4']): 1283 bot.node.move_up_down = speed 1284 bot.node.move_left_right = 0 1285 bot.node.run = 0.0 1286 return True 1287 1288 # Row 1 and 2 bots attempt the middle road.. 1289 if r_walk_row in [1, 2]: 1290 if bs.is_point_in_box(pos, boxes['b1']): 1291 bot.node.move_up_down = speed 1292 bot.node.move_left_right = 0 1293 bot.node.run = 0.0 1294 return True 1295 1296 # All bots settle for the third row. 1297 if bs.is_point_in_box(pos, boxes['b7']): 1298 bot.node.move_up_down = speed 1299 bot.node.move_left_right = 0 1300 bot.node.run = 0.0 1301 return True 1302 if bs.is_point_in_box(pos, boxes['b2']): 1303 bot.node.move_up_down = -speed 1304 bot.node.move_left_right = 0 1305 bot.node.run = 0.0 1306 return True 1307 if bs.is_point_in_box(pos, boxes['b3']): 1308 bot.node.move_up_down = -speed 1309 bot.node.move_left_right = 0 1310 bot.node.run = 0.0 1311 return True 1312 if bs.is_point_in_box(pos, boxes['b5']): 1313 bot.node.move_up_down = -speed 1314 bot.node.move_left_right = 0 1315 bot.node.run = 0.0 1316 return True 1317 if bs.is_point_in_box(pos, boxes['b6']): 1318 bot.node.move_up_down = speed 1319 bot.node.move_left_right = 0 1320 bot.node.run = 0.0 1321 return True 1322 if ( 1323 bs.is_point_in_box(pos, boxes['b8']) 1324 and not bs.is_point_in_box(pos, boxes['b9']) 1325 ) or pos == (0.0, 0.0, 0.0): 1326 # Default to walking right if we're still in the walking area. 1327 bot.node.move_left_right = speed 1328 bot.node.move_up_down = 0 1329 bot.node.run = 0.0 1330 return True 1331 1332 # Revert to normal bot behavior otherwise.. 1333 return False 1334 1335 @override 1336 def handlemessage(self, msg: Any) -> Any: 1337 if isinstance(msg, bs.PlayerScoredMessage): 1338 self._score += msg.score 1339 self._update_scores() 1340 1341 elif isinstance(msg, bs.PlayerDiedMessage): 1342 # Augment standard behavior. 1343 super().handlemessage(msg) 1344 1345 self._a_player_has_been_killed = True 1346 1347 # Respawn them shortly. 1348 player = msg.getplayer(Player) 1349 assert self.initialplayerinfos is not None 1350 respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 1351 player.respawn_timer = bs.Timer( 1352 respawn_time, bs.Call(self.spawn_player_if_exists, player) 1353 ) 1354 player.respawn_icon = RespawnIcon(player, respawn_time) 1355 1356 elif isinstance(msg, SpazBotDiedMessage): 1357 if msg.how is bs.DeathType.REACHED_GOAL: 1358 return None 1359 pts, importance = msg.spazbot.get_death_points(msg.how) 1360 if msg.killerplayer is not None: 1361 target: Sequence[float] | None 1362 try: 1363 assert msg.spazbot is not None 1364 assert msg.spazbot.node 1365 target = msg.spazbot.node.position 1366 except Exception: 1367 logging.exception('Error getting SpazBotDied target.') 1368 target = None 1369 try: 1370 if msg.killerplayer: 1371 self.stats.player_scored( 1372 msg.killerplayer, 1373 pts, 1374 target=target, 1375 kill=True, 1376 screenmessage=False, 1377 importance=importance, 1378 ) 1379 dingsound = ( 1380 self._dingsound 1381 if importance == 1 1382 else self._dingsoundhigh 1383 ) 1384 dingsound.play(volume=0.6) 1385 except Exception: 1386 logging.exception('Error on SpazBotDiedMessage.') 1387 1388 # Normally we pull scores from the score-set, but if there's no 1389 # player lets be explicit. 1390 else: 1391 self._score += pts 1392 self._update_scores() 1393 1394 else: 1395 return super().handlemessage(msg) 1396 return None 1397 1398 def _get_bot_speed(self, bot_type: type[SpazBot]) -> float: 1399 speed = self._bot_speed_map.get(bot_type) 1400 if speed is None: 1401 raise TypeError( 1402 'Invalid bot type to _get_bot_speed(): ' + str(bot_type) 1403 ) 1404 return speed 1405 1406 def _set_can_end_wave(self) -> None: 1407 self._can_end_wave = True 1408 1409 def heart_dyin(self, status: bool, time: float = 1.22) -> None: 1410 """Makes the UI heart beat at low health.""" 1411 assert self._lives_bg is not None 1412 if self._lives_bg.node.exists(): 1413 return 1414 heart = self._lives_bg.node 1415 1416 # Make the heart beat intensely! 1417 if status: 1418 bs.animate_array( 1419 heart, 1420 'scale', 1421 2, 1422 { 1423 0: (90, 90), 1424 time * 0.1: (105, 105), 1425 time * 0.21: (88, 88), 1426 time * 0.42: (90, 90), 1427 time * 0.52: (105, 105), 1428 time * 0.63: (88, 88), 1429 time: (90, 90), 1430 }, 1431 ) 1432 1433 # Neutralize heartbeat (Done did when dead.) 1434 else: 1435 # Ew; janky old scenev1 has a single 'Node' Python type so 1436 # it thinks heart.scale could be a few different things 1437 # (float, Sequence[float], etc.). So we have to force the 1438 # issue with a cast(). This should go away with scenev2/etc. 1439 bs.animate_array( 1440 heart, 1441 'scale', 1442 2, 1443 { 1444 0.0: cast(Sequence[float], heart.scale), 1445 time: (90, 90), 1446 }, 1447 )
Game involving trying to bomb bots as they walk through the map.
137 def __init__(self, settings: dict): 138 settings['map'] = 'Tower D' 139 super().__init__(settings) 140 shared = SharedObjects.get() 141 self._preset = Preset(settings.get('preset', 'pro')) 142 143 self._player_death_sound = bs.getsound('playerDeath') 144 self._new_wave_sound = bs.getsound('scoreHit01') 145 self._winsound = bs.getsound('score') 146 self._cashregistersound = bs.getsound('cashRegister') 147 self._bad_guy_score_sound = bs.getsound('shieldDown') 148 self._heart_tex = bs.gettexture('heart') 149 self._heart_mesh_opaque = bs.getmesh('heartOpaque') 150 self._heart_mesh_transparent = bs.getmesh('heartTransparent') 151 152 self._a_player_has_been_killed = False 153 self._spawn_center = self._map_type.defs.points['spawn1'][0:3] 154 self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3] 155 self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3] 156 self._powerup_spread = ( 157 self._map_type.defs.boxes['powerup_region'][6] * 0.5, 158 self._map_type.defs.boxes['powerup_region'][8] * 0.5, 159 ) 160 161 self._score_region_material = bs.Material() 162 self._score_region_material.add_actions( 163 conditions=('they_have_material', shared.player_material), 164 actions=( 165 ('modify_part_collision', 'collide', True), 166 ('modify_part_collision', 'physical', False), 167 ('call', 'at_connect', self._handle_reached_end), 168 ), 169 ) 170 171 self._last_wave_end_time = bs.time() 172 self._player_has_picked_up_powerup = False 173 self._scoreboard: Scoreboard | None = None 174 self._game_over = False 175 self._wavenum = 0 176 self._can_end_wave = True 177 self._score = 0 178 self._time_bonus = 0 179 self._score_region: bs.Actor | None = None 180 self._dingsound = bs.getsound('dingSmall') 181 self._dingsoundhigh = bs.getsound('dingSmallHigh') 182 self._exclude_powerups: list[str] | None = None 183 self._have_tnt: bool | None = None 184 self._waves: list[Wave] | None = None 185 self._bots = SpazBotSet() 186 self._tntspawner: TNTSpawner | None = None 187 self._lives_bg: bs.NodeActor | None = None 188 self._start_lives = 10 189 self._lives = self._start_lives 190 self._lives_text: bs.NodeActor | None = None 191 self._flawless = True 192 self._time_bonus_timer: bs.Timer | None = None 193 self._lives_hbtime: bs.Timer | None = None 194 self._time_bonus_text: bs.NodeActor | None = None 195 self._time_bonus_mult: float | None = None 196 self._wave_text: bs.NodeActor | None = None 197 self._flawless_bonus: int | None = None 198 self._wave_update_timer: bs.Timer | None = None
Instantiate the Activity.
200 @override 201 def on_transition_in(self) -> None: 202 super().on_transition_in() 203 self._scoreboard = Scoreboard( 204 label=bs.Lstr(resource='scoreText'), score_split=0.5 205 ) 206 self._score_region = bs.NodeActor( 207 bs.newnode( 208 'region', 209 attrs={ 210 'position': self.map.defs.boxes['score_region'][0:3], 211 'scale': self.map.defs.boxes['score_region'][6:9], 212 'type': 'box', 213 'materials': [self._score_region_material], 214 }, 215 ) 216 )
Called when the Activity is first becoming visible.
Upon this call, the Activity should fade in backgrounds, start playing music, etc. It does not yet have access to players or teams, however. They remain owned by the previous Activity up until bascenev1.Activity.on_begin() is called.
218 @override 219 def on_begin(self) -> None: 220 super().on_begin() 221 player_count = len(self.players) 222 hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY} 223 224 if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}: 225 self._exclude_powerups = ['curse'] 226 self._have_tnt = True 227 self._waves = [ 228 Wave( 229 entries=[ 230 Spawn(BomberBot, path=3 if hard else 2), 231 Spawn(BomberBot, path=2), 232 Spawn(BomberBot, path=2) if hard else None, 233 Spawn(BomberBot, path=2) if player_count > 1 else None, 234 Spawn(BomberBot, path=1) if hard else None, 235 Spawn(BomberBot, path=1) if player_count > 2 else None, 236 Spawn(BomberBot, path=1) if player_count > 3 else None, 237 ] 238 ), 239 Wave( 240 entries=[ 241 Spawn(BomberBot, path=1) if hard else None, 242 Spawn(BomberBot, path=2) if hard else None, 243 Spawn(BomberBot, path=2), 244 Spawn(BomberBot, path=2), 245 Spawn(BomberBot, path=2) if player_count > 3 else None, 246 Spawn(BrawlerBot, path=3), 247 Spawn(BrawlerBot, path=3), 248 Spawn(BrawlerBot, path=3) if hard else None, 249 Spawn(BrawlerBot, path=3) if player_count > 1 else None, 250 Spawn(BrawlerBot, path=3) if player_count > 2 else None, 251 ] 252 ), 253 Wave( 254 entries=[ 255 Spawn(ChargerBot, path=2) if hard else None, 256 Spawn(ChargerBot, path=2) if player_count > 2 else None, 257 Spawn(TriggerBot, path=2), 258 Spawn(TriggerBot, path=2) if player_count > 1 else None, 259 Spacing(duration=3.0), 260 Spawn(BomberBot, path=2) if hard else None, 261 Spawn(BomberBot, path=2) if hard else None, 262 Spawn(BomberBot, path=2), 263 Spawn(BomberBot, path=3) if hard else None, 264 Spawn(BomberBot, path=3), 265 Spawn(BomberBot, path=3), 266 Spawn(BomberBot, path=3) if player_count > 3 else None, 267 ] 268 ), 269 Wave( 270 entries=[ 271 Spawn(TriggerBot, path=1) if hard else None, 272 Spacing(duration=1.0) if hard else None, 273 Spawn(TriggerBot, path=2), 274 Spacing(duration=1.0), 275 Spawn(TriggerBot, path=3), 276 Spacing(duration=1.0), 277 Spawn(TriggerBot, path=1) if hard else None, 278 Spacing(duration=1.0) if hard else None, 279 Spawn(TriggerBot, path=2), 280 Spacing(duration=1.0), 281 Spawn(TriggerBot, path=3), 282 Spacing(duration=1.0), 283 ( 284 Spawn(TriggerBot, path=1) 285 if (player_count > 1 and hard) 286 else None 287 ), 288 Spacing(duration=1.0), 289 Spawn(TriggerBot, path=2) if player_count > 2 else None, 290 Spacing(duration=1.0), 291 Spawn(TriggerBot, path=3) if player_count > 3 else None, 292 Spacing(duration=1.0), 293 ] 294 ), 295 Wave( 296 entries=[ 297 Spawn( 298 ChargerBotProShielded if hard else ChargerBot, 299 path=1, 300 ), 301 Spawn(BrawlerBot, path=2) if hard else None, 302 Spawn(BrawlerBot, path=2), 303 Spawn(BrawlerBot, path=2), 304 Spawn(BrawlerBot, path=3) if hard else None, 305 Spawn(BrawlerBot, path=3), 306 Spawn(BrawlerBot, path=3), 307 Spawn(BrawlerBot, path=3) if player_count > 1 else None, 308 Spawn(BrawlerBot, path=3) if player_count > 2 else None, 309 Spawn(BrawlerBot, path=3) if player_count > 3 else None, 310 ] 311 ), 312 Wave( 313 entries=[ 314 Spawn(BomberBotProShielded, path=3), 315 Spacing(duration=1.5), 316 Spawn(BomberBotProShielded, path=2), 317 Spacing(duration=1.5), 318 Spawn(BomberBotProShielded, path=1) if hard else None, 319 Spacing(duration=1.0) if hard else None, 320 Spawn(BomberBotProShielded, path=3), 321 Spacing(duration=1.5), 322 Spawn(BomberBotProShielded, path=2), 323 Spacing(duration=1.5), 324 Spawn(BomberBotProShielded, path=1) if hard else None, 325 Spacing(duration=1.5) if hard else None, 326 ( 327 Spawn(BomberBotProShielded, path=3) 328 if player_count > 1 329 else None 330 ), 331 Spacing(duration=1.5), 332 ( 333 Spawn(BomberBotProShielded, path=2) 334 if player_count > 2 335 else None 336 ), 337 Spacing(duration=1.5), 338 ( 339 Spawn(BomberBotProShielded, path=1) 340 if player_count > 3 341 else None 342 ), 343 ] 344 ), 345 ] 346 elif self._preset in { 347 Preset.UBER_EASY, 348 Preset.UBER, 349 Preset.TOURNAMENT_UBER, 350 }: 351 self._exclude_powerups = [] 352 self._have_tnt = True 353 self._waves = [ 354 Wave( 355 entries=[ 356 Spawn(TriggerBot, path=1) if hard else None, 357 Spawn(TriggerBot, path=2), 358 Spawn(TriggerBot, path=2), 359 Spawn(TriggerBot, path=3), 360 Spawn( 361 BrawlerBotPro if hard else BrawlerBot, 362 point=Point.BOTTOM_LEFT, 363 ), 364 ( 365 Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT) 366 if player_count > 2 367 else None 368 ), 369 ] 370 ), 371 Wave( 372 entries=[ 373 Spawn(ChargerBot, path=2), 374 Spawn(ChargerBot, path=3), 375 Spawn(ChargerBot, path=1) if hard else None, 376 Spawn(ChargerBot, path=2), 377 Spawn(ChargerBot, path=3), 378 Spawn(ChargerBot, path=1) if player_count > 2 else None, 379 ] 380 ), 381 Wave( 382 entries=[ 383 Spawn(BomberBotProShielded, path=1) if hard else None, 384 Spawn(BomberBotProShielded, path=2), 385 Spawn(BomberBotProShielded, path=2), 386 Spawn(BomberBotProShielded, path=3), 387 Spawn(BomberBotProShielded, path=3), 388 Spawn(ChargerBot, point=Point.BOTTOM_RIGHT), 389 ( 390 Spawn(ChargerBot, point=Point.BOTTOM_LEFT) 391 if player_count > 2 392 else None 393 ), 394 ] 395 ), 396 Wave( 397 entries=[ 398 Spawn(TriggerBotPro, path=1) if hard else None, 399 Spawn(TriggerBotPro, path=1 if hard else 2), 400 Spawn(TriggerBotPro, path=1 if hard else 2), 401 Spawn(TriggerBotPro, path=1 if hard else 2), 402 Spawn(TriggerBotPro, path=1 if hard else 2), 403 Spawn(TriggerBotPro, path=1 if hard else 2), 404 ( 405 Spawn(TriggerBotPro, path=1 if hard else 2) 406 if player_count > 1 407 else None 408 ), 409 ( 410 Spawn(TriggerBotPro, path=1 if hard else 2) 411 if player_count > 3 412 else None 413 ), 414 ] 415 ), 416 Wave( 417 entries=[ 418 Spawn( 419 TriggerBotProShielded if hard else TriggerBotPro, 420 point=Point.BOTTOM_LEFT, 421 ), 422 ( 423 Spawn( 424 TriggerBotProShielded, point=Point.BOTTOM_RIGHT 425 ) 426 if hard 427 else None 428 ), 429 ( 430 Spawn( 431 TriggerBotProShielded, point=Point.BOTTOM_RIGHT 432 ) 433 if player_count > 2 434 else None 435 ), 436 Spawn(BomberBot, path=3), 437 Spawn(BomberBot, path=3), 438 Spacing(duration=5.0), 439 Spawn(BrawlerBot, path=2), 440 Spawn(BrawlerBot, path=2), 441 Spacing(duration=5.0), 442 Spawn(TriggerBot, path=1) if hard else None, 443 Spawn(TriggerBot, path=1) if hard else None, 444 ] 445 ), 446 Wave( 447 entries=[ 448 Spawn(BomberBotProShielded, path=2), 449 Spawn(BomberBotProShielded, path=2) if hard else None, 450 Spawn(StickyBot, point=Point.BOTTOM_RIGHT), 451 Spawn(BomberBotProShielded, path=2), 452 Spawn(BomberBotProShielded, path=2), 453 ( 454 Spawn(StickyBot, point=Point.BOTTOM_RIGHT) 455 if player_count > 2 456 else None 457 ), 458 Spawn(BomberBotProShielded, path=2), 459 Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT), 460 Spawn(BomberBotProShielded, path=2), 461 ( 462 Spawn(BomberBotProShielded, path=2) 463 if player_count > 1 464 else None 465 ), 466 Spacing(duration=5.0), 467 Spawn(StickyBot, point=Point.BOTTOM_LEFT), 468 Spacing(duration=2.0), 469 Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT), 470 ] 471 ), 472 ] 473 elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 474 self._exclude_powerups = [] 475 self._have_tnt = True 476 477 # Spit out a few powerups and start dropping more shortly. 478 self._drop_powerups(standard_points=True) 479 bs.timer(4.0, self._start_powerup_drops) 480 self.setup_low_life_warning_sound() 481 self._update_scores() 482 483 # Our TNT spawner (if applicable). 484 if self._have_tnt: 485 self._tntspawner = TNTSpawner(position=self._tntspawnpos) 486 487 # Make sure to stay out of the way of menu/party buttons in the corner. 488 assert bs.app.classic is not None 489 uiscale = bs.app.ui_v1.uiscale 490 l_offs = ( 491 -80 492 if uiscale is bs.UIScale.SMALL 493 else -40 if uiscale is bs.UIScale.MEDIUM else 0 494 ) 495 496 self._lives_bg = bs.NodeActor( 497 bs.newnode( 498 'image', 499 attrs={ 500 'texture': self._heart_tex, 501 'mesh_opaque': self._heart_mesh_opaque, 502 'mesh_transparent': self._heart_mesh_transparent, 503 'attach': 'topRight', 504 'scale': (90, 90), 505 'position': (-110 + l_offs, -50), 506 'color': (1, 0.2, 0.2), 507 }, 508 ) 509 ) 510 # FIXME; should not set things based on vr mode. 511 # (won't look right to non-vr connected clients, etc) 512 vrmode = bs.app.env.vr 513 self._lives_text = bs.NodeActor( 514 bs.newnode( 515 'text', 516 attrs={ 517 'v_attach': 'top', 518 'h_attach': 'right', 519 'h_align': 'center', 520 'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0), 521 'flatness': 1.0 if vrmode else 0.5, 522 'shadow': 1.0 if vrmode else 0.5, 523 'vr_depth': 10, 524 'position': (-113 + l_offs, -69), 525 'scale': 1.3, 526 'text': str(self._lives), 527 }, 528 ) 529 ) 530 531 bs.timer(2.0, self._start_updating_waves)
Called once the previous Activity has finished transitioning out.
At this point the activity's initial players and teams are filled in and it should begin its actual game logic.
617 @override 618 def on_continue(self) -> None: 619 self._lives = 3 620 assert self._lives_text is not None 621 assert self._lives_text.node 622 self._lives_text.node.text = str(self._lives) 623 self._bots.start_moving()
This is called if a game supports and offers a continue and the player accepts. In this case the player should be given an extra life or whatever is relevant to keep the game going.
625 @override 626 def spawn_player(self, player: Player) -> bs.Actor: 627 pos = ( 628 self._spawn_center[0] + random.uniform(-1.5, 1.5), 629 self._spawn_center[1], 630 self._spawn_center[2] + random.uniform(-1.5, 1.5), 631 ) 632 spaz = self.spawn_player_spaz(player, position=pos) 633 if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}: 634 spaz.impact_scale = 0.25 635 636 # Add the material that causes us to hit the player-wall. 637 spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup 638 return spaz
Spawn something for the provided bascenev1.Player.
The default implementation simply calls spawn_player_spaz().
702 @override 703 def end_game(self) -> None: 704 bs.pushcall(bs.Call(self.do_end, 'defeat')) 705 bs.setmusic(None) 706 self._player_death_sound.play()
Tell the game to wrap up and call bascenev1.Activity.end().
This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (bascenev1.GameActivity.setup_standard_time_limit()) will work with the game.
708 def do_end(self, outcome: str) -> None: 709 """End the game now with the provided outcome.""" 710 711 if outcome == 'defeat': 712 delay = 2.0 713 self.fade_to_red() 714 else: 715 delay = 0 716 717 score: int | None 718 if self._wavenum >= 2: 719 score = self._score 720 fail_message = None 721 else: 722 score = None 723 fail_message = bs.Lstr(resource='reachWave2Text') 724 725 self.end( 726 delay=delay, 727 results={ 728 'outcome': outcome, 729 'score': score, 730 'fail_message': fail_message, 731 'playerinfos': self.initialplayerinfos, 732 }, 733 )
End the game now with the provided outcome.
1209 def add_bot_at_point( 1210 self, 1211 point: Point, 1212 spaztype: type[SpazBot], 1213 path: int, 1214 spawn_time: float = 0.1, 1215 ) -> None: 1216 """Add the given type bot with the given delay (in seconds).""" 1217 1218 # Don't add if the game has ended. 1219 if self._game_over: 1220 return 1221 pos = self.map.defs.points[point.value][:3] 1222 self._bots.spawn_bot( 1223 spaztype, 1224 pos=pos, 1225 spawn_time=spawn_time, 1226 on_spawn_call=bs.Call(self._on_bot_spawn, path), 1227 )
Add the given type bot with the given delay (in seconds).
1335 @override 1336 def handlemessage(self, msg: Any) -> Any: 1337 if isinstance(msg, bs.PlayerScoredMessage): 1338 self._score += msg.score 1339 self._update_scores() 1340 1341 elif isinstance(msg, bs.PlayerDiedMessage): 1342 # Augment standard behavior. 1343 super().handlemessage(msg) 1344 1345 self._a_player_has_been_killed = True 1346 1347 # Respawn them shortly. 1348 player = msg.getplayer(Player) 1349 assert self.initialplayerinfos is not None 1350 respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 1351 player.respawn_timer = bs.Timer( 1352 respawn_time, bs.Call(self.spawn_player_if_exists, player) 1353 ) 1354 player.respawn_icon = RespawnIcon(player, respawn_time) 1355 1356 elif isinstance(msg, SpazBotDiedMessage): 1357 if msg.how is bs.DeathType.REACHED_GOAL: 1358 return None 1359 pts, importance = msg.spazbot.get_death_points(msg.how) 1360 if msg.killerplayer is not None: 1361 target: Sequence[float] | None 1362 try: 1363 assert msg.spazbot is not None 1364 assert msg.spazbot.node 1365 target = msg.spazbot.node.position 1366 except Exception: 1367 logging.exception('Error getting SpazBotDied target.') 1368 target = None 1369 try: 1370 if msg.killerplayer: 1371 self.stats.player_scored( 1372 msg.killerplayer, 1373 pts, 1374 target=target, 1375 kill=True, 1376 screenmessage=False, 1377 importance=importance, 1378 ) 1379 dingsound = ( 1380 self._dingsound 1381 if importance == 1 1382 else self._dingsoundhigh 1383 ) 1384 dingsound.play(volume=0.6) 1385 except Exception: 1386 logging.exception('Error on SpazBotDiedMessage.') 1387 1388 # Normally we pull scores from the score-set, but if there's no 1389 # player lets be explicit. 1390 else: 1391 self._score += pts 1392 self._update_scores() 1393 1394 else: 1395 return super().handlemessage(msg) 1396 return None
General message handling; can be passed any message object.
1409 def heart_dyin(self, status: bool, time: float = 1.22) -> None: 1410 """Makes the UI heart beat at low health.""" 1411 assert self._lives_bg is not None 1412 if self._lives_bg.node.exists(): 1413 return 1414 heart = self._lives_bg.node 1415 1416 # Make the heart beat intensely! 1417 if status: 1418 bs.animate_array( 1419 heart, 1420 'scale', 1421 2, 1422 { 1423 0: (90, 90), 1424 time * 0.1: (105, 105), 1425 time * 0.21: (88, 88), 1426 time * 0.42: (90, 90), 1427 time * 0.52: (105, 105), 1428 time * 0.63: (88, 88), 1429 time: (90, 90), 1430 }, 1431 ) 1432 1433 # Neutralize heartbeat (Done did when dead.) 1434 else: 1435 # Ew; janky old scenev1 has a single 'Node' Python type so 1436 # it thinks heart.scale could be a few different things 1437 # (float, Sequence[float], etc.). So we have to force the 1438 # issue with a cast(). This should go away with scenev2/etc. 1439 bs.animate_array( 1440 heart, 1441 'scale', 1442 2, 1443 { 1444 0.0: cast(Sequence[float], heart.scale), 1445 time: (90, 90), 1446 }, 1447 )
Makes the UI heart beat at low health.
Inherited Members
- bascenev1._coopgame.CoopGameActivity
- session
- supports_session_type
- get_score_type
- celebrate
- spawn_player_spaz
- fade_to_red
- setup_low_life_warning_sound
- bascenev1._gameactivity.GameActivity
- available_settings
- scoreconfig
- allow_pausing
- allow_kick_idle_players
- show_kill_points
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- initialplayerinfos
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- end
- respawn_player
- spawn_player_if_exists
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- bascenev1._activity.Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- paused_text
- preloads
- lobby
- context
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
- bascenev1._dependency.DependencyComponent
- dep_is_present
- get_dynamic_deps