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 9 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.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 spawn_player(self, player: Player) -> bs.Actor: 618 pos = ( 619 self._spawn_center[0] + random.uniform(-1.5, 1.5), 620 self._spawn_center[1], 621 self._spawn_center[2] + random.uniform(-1.5, 1.5), 622 ) 623 spaz = self.spawn_player_spaz(player, position=pos) 624 if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}: 625 spaz.impact_scale = 0.25 626 627 # Add the material that causes us to hit the player-wall. 628 spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup 629 return spaz 630 631 def _on_player_picked_up_powerup(self, player: bs.Actor) -> None: 632 del player # Unused. 633 self._player_has_picked_up_powerup = True 634 635 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 636 if poweruptype is None: 637 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 638 excludetypes=self._exclude_powerups 639 ) 640 PowerupBox( 641 position=self.map.powerup_spawn_points[index], 642 poweruptype=poweruptype, 643 ).autoretain() 644 645 def _start_powerup_drops(self) -> None: 646 bs.timer(3.0, self._drop_powerups, repeat=True) 647 648 def _drop_powerups( 649 self, standard_points: bool = False, force_first: str | None = None 650 ) -> None: 651 """Generic powerup drop.""" 652 653 # If its been a minute since our last wave finished emerging, stop 654 # giving out land-mine powerups. (prevents players from waiting 655 # around for them on purpose and filling the map up) 656 if bs.time() - self._last_wave_end_time > 60.0: 657 extra_excludes = ['land_mines'] 658 else: 659 extra_excludes = [] 660 661 if standard_points: 662 points = self.map.powerup_spawn_points 663 for i in range(len(points)): 664 bs.timer( 665 1.0 + i * 0.5, 666 bs.Call( 667 self._drop_powerup, i, force_first if i == 0 else None 668 ), 669 ) 670 else: 671 pos = ( 672 self._powerup_center[0] 673 + random.uniform( 674 -1.0 * self._powerup_spread[0], 675 1.0 * self._powerup_spread[0], 676 ), 677 self._powerup_center[1], 678 self._powerup_center[2] 679 + random.uniform( 680 -self._powerup_spread[1], self._powerup_spread[1] 681 ), 682 ) 683 684 # drop one random one somewhere.. 685 assert self._exclude_powerups is not None 686 PowerupBox( 687 position=pos, 688 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 689 excludetypes=self._exclude_powerups + extra_excludes 690 ), 691 ).autoretain() 692 693 @override 694 def end_game(self) -> None: 695 bs.pushcall(bs.Call(self.do_end, 'defeat')) 696 bs.setmusic(None) 697 self._player_death_sound.play() 698 699 def do_end(self, outcome: str) -> None: 700 """End the game now with the provided outcome.""" 701 702 if outcome == 'defeat': 703 delay = 2.0 704 self.fade_to_red() 705 else: 706 delay = 0 707 708 score: int | None 709 if self._wavenum >= 2: 710 score = self._score 711 fail_message = None 712 else: 713 score = None 714 fail_message = bs.Lstr(resource='reachWave2Text') 715 716 self.end( 717 delay=delay, 718 results={ 719 'outcome': outcome, 720 'score': score, 721 'fail_message': fail_message, 722 'playerinfos': self.initialplayerinfos, 723 }, 724 ) 725 726 def _update_waves(self) -> None: 727 # pylint: disable=too-many-branches 728 729 # If we have no living bots, go to the next wave. 730 if ( 731 self._can_end_wave 732 and not self._bots.have_living_bots() 733 and not self._game_over 734 and self._lives > 0 735 ): 736 self._can_end_wave = False 737 self._time_bonus_timer = None 738 self._time_bonus_text = None 739 740 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 741 won = False 742 else: 743 assert self._waves is not None 744 won = self._wavenum == len(self._waves) 745 746 # Reward time bonus. 747 base_delay = 4.0 if won else 0 748 if self._time_bonus > 0: 749 bs.timer(0, self._cashregistersound.play) 750 bs.timer( 751 base_delay, 752 bs.Call(self._award_time_bonus, self._time_bonus), 753 ) 754 base_delay += 1.0 755 756 # Reward flawless bonus. 757 if self._wavenum > 0 and self._flawless: 758 bs.timer(base_delay, self._award_flawless_bonus) 759 base_delay += 1.0 760 761 self._flawless = True # reset 762 763 if won: 764 # Completion achievements: 765 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 766 self._award_achievement( 767 'Pro Runaround Victory', sound=False 768 ) 769 if self._lives == self._start_lives: 770 self._award_achievement('The Wall', sound=False) 771 if not self._player_has_picked_up_powerup: 772 self._award_achievement( 773 'Precision Bombing', sound=False 774 ) 775 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 776 self._award_achievement( 777 'Uber Runaround Victory', sound=False 778 ) 779 if self._lives == self._start_lives: 780 self._award_achievement('The Great Wall', sound=False) 781 if not self._a_player_has_been_killed: 782 self._award_achievement('Stayin\' Alive', sound=False) 783 784 # Give remaining players some points and have them celebrate. 785 self.show_zoom_message( 786 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 787 ) 788 789 self.celebrate(10.0) 790 bs.timer(base_delay, self._award_lives_bonus) 791 base_delay += 1.0 792 bs.timer(base_delay, self._award_completion_bonus) 793 base_delay += 0.85 794 self._winsound.play() 795 bs.cameraflash() 796 bs.setmusic(bs.MusicType.VICTORY) 797 self._game_over = True 798 bs.timer(base_delay, bs.Call(self.do_end, 'victory')) 799 return 800 801 self._wavenum += 1 802 803 # Short celebration after waves. 804 if self._wavenum > 1: 805 self.celebrate(0.5) 806 807 bs.timer(base_delay, self._start_next_wave) 808 809 def _award_completion_bonus(self) -> None: 810 bonus = 200 811 self._cashregistersound.play() 812 PopupText( 813 bs.Lstr( 814 value='+${A} ${B}', 815 subs=[ 816 ('${A}', str(bonus)), 817 ('${B}', bs.Lstr(resource='completionBonusText')), 818 ], 819 ), 820 color=(0.7, 0.7, 1.0, 1), 821 scale=1.6, 822 position=(0, 1.5, -1), 823 ).autoretain() 824 self._score += bonus 825 self._update_scores() 826 827 def _award_lives_bonus(self) -> None: 828 bonus = self._lives * 30 829 self._cashregistersound.play() 830 PopupText( 831 bs.Lstr( 832 value='+${A} ${B}', 833 subs=[ 834 ('${A}', str(bonus)), 835 ('${B}', bs.Lstr(resource='livesBonusText')), 836 ], 837 ), 838 color=(0.7, 1.0, 0.3, 1), 839 scale=1.3, 840 position=(0, 1, -1), 841 ).autoretain() 842 self._score += bonus 843 self._update_scores() 844 845 def _award_time_bonus(self, bonus: int) -> None: 846 self._cashregistersound.play() 847 PopupText( 848 bs.Lstr( 849 value='+${A} ${B}', 850 subs=[ 851 ('${A}', str(bonus)), 852 ('${B}', bs.Lstr(resource='timeBonusText')), 853 ], 854 ), 855 color=(1, 1, 0.5, 1), 856 scale=1.0, 857 position=(0, 3, -1), 858 ).autoretain() 859 860 self._score += self._time_bonus 861 self._update_scores() 862 863 def _award_flawless_bonus(self) -> None: 864 self._cashregistersound.play() 865 PopupText( 866 bs.Lstr( 867 value='+${A} ${B}', 868 subs=[ 869 ('${A}', str(self._flawless_bonus)), 870 ('${B}', bs.Lstr(resource='perfectWaveText')), 871 ], 872 ), 873 color=(1, 1, 0.2, 1), 874 scale=1.2, 875 position=(0, 2, -1), 876 ).autoretain() 877 878 assert self._flawless_bonus is not None 879 self._score += self._flawless_bonus 880 self._update_scores() 881 882 def _start_time_bonus_timer(self) -> None: 883 self._time_bonus_timer = bs.Timer( 884 1.0, self._update_time_bonus, repeat=True 885 ) 886 887 def _start_next_wave(self) -> None: 888 # FIXME: Need to split this up. 889 # pylint: disable=too-many-locals 890 # pylint: disable=too-many-branches 891 # pylint: disable=too-many-statements 892 self.show_zoom_message( 893 bs.Lstr( 894 value='${A} ${B}', 895 subs=[ 896 ('${A}', bs.Lstr(resource='waveText')), 897 ('${B}', str(self._wavenum)), 898 ], 899 ), 900 scale=1.0, 901 duration=1.0, 902 trail=True, 903 ) 904 bs.timer(0.4, self._new_wave_sound.play) 905 t_sec = 0.0 906 base_delay = 0.5 907 delay = 0.0 908 bot_types: list[Spawn | Spacing | None] = [] 909 910 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 911 level = self._wavenum 912 target_points = (level + 1) * 8.0 913 group_count = random.randint(1, 3) 914 entries: list[Spawn | Spacing | None] = [] 915 spaz_types: list[tuple[type[SpazBot], float]] = [] 916 if level < 6: 917 spaz_types += [(BomberBot, 5.0)] 918 if level < 10: 919 spaz_types += [(BrawlerBot, 5.0)] 920 if level < 15: 921 spaz_types += [(TriggerBot, 6.0)] 922 if level > 5: 923 spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7) 924 if level > 2: 925 spaz_types += [(BomberBotProShielded, 8.0)] * ( 926 1 + (level - 2) // 6 927 ) 928 if level > 6: 929 spaz_types += [(TriggerBotProShielded, 12.0)] * ( 930 1 + (level - 6) // 5 931 ) 932 if level > 1: 933 spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4) 934 if level > 7: 935 spaz_types += [(ChargerBotProShielded, 15.0)] * ( 936 1 + (level - 7) // 3 937 ) 938 939 # Bot type, their effect on target points. 940 defender_types: list[tuple[type[SpazBot], float]] = [ 941 (BomberBot, 0.9), 942 (BrawlerBot, 0.9), 943 (TriggerBot, 0.85), 944 ] 945 if level > 2: 946 defender_types += [(ChargerBot, 0.75)] 947 if level > 4: 948 defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6) 949 if level > 6: 950 defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5) 951 if level > 8: 952 defender_types += [(BrawlerBotProShielded, 0.65)] * ( 953 1 + (level - 5) // 4 954 ) 955 if level > 10: 956 defender_types += [(TriggerBotProShielded, 0.6)] * ( 957 1 + (level - 6) // 3 958 ) 959 960 for group in range(group_count): 961 this_target_point_s = target_points / group_count 962 963 # Adding spacing makes things slightly harder. 964 rval = random.random() 965 if rval < 0.07: 966 spacing = 1.5 967 this_target_point_s *= 0.85 968 elif rval < 0.15: 969 spacing = 1.0 970 this_target_point_s *= 0.9 971 else: 972 spacing = 0.0 973 974 path = random.randint(1, 3) 975 976 # Don't allow hard paths on early levels. 977 if level < 3: 978 if path == 1: 979 path = 3 980 981 # Easy path. 982 if path == 3: 983 pass 984 985 # Harder path. 986 elif path == 2: 987 this_target_point_s *= 0.8 988 989 # Even harder path. 990 elif path == 1: 991 this_target_point_s *= 0.7 992 993 # Looping forward. 994 elif path == 4: 995 this_target_point_s *= 0.7 996 997 # Looping backward. 998 elif path == 5: 999 this_target_point_s *= 0.7 1000 1001 # Random. 1002 elif path == 6: 1003 this_target_point_s *= 0.7 1004 1005 def _add_defender( 1006 defender_type: tuple[type[SpazBot], float], pnt: Point 1007 ) -> tuple[float, Spawn]: 1008 # This is ok because we call it immediately. 1009 # pylint: disable=cell-var-from-loop 1010 return this_target_point_s * defender_type[1], Spawn( 1011 defender_type[0], point=pnt 1012 ) 1013 1014 # Add defenders. 1015 defender_type1 = defender_types[ 1016 random.randrange(len(defender_types)) 1017 ] 1018 defender_type2 = defender_types[ 1019 random.randrange(len(defender_types)) 1020 ] 1021 defender1 = defender2 = None 1022 if ( 1023 (group == 0) 1024 or (group == 1 and level > 3) 1025 or (group == 2 and level > 5) 1026 ): 1027 if random.random() < min(0.75, (level - 1) * 0.11): 1028 this_target_point_s, defender1 = _add_defender( 1029 defender_type1, Point.BOTTOM_LEFT 1030 ) 1031 if random.random() < min(0.75, (level - 1) * 0.04): 1032 this_target_point_s, defender2 = _add_defender( 1033 defender_type2, Point.BOTTOM_RIGHT 1034 ) 1035 1036 spaz_type = spaz_types[random.randrange(len(spaz_types))] 1037 member_count = max( 1038 1, int(round(this_target_point_s / spaz_type[1])) 1039 ) 1040 for i, _member in enumerate(range(member_count)): 1041 if path == 4: 1042 this_path = i % 3 # Looping forward. 1043 elif path == 5: 1044 this_path = 3 - (i % 3) # Looping backward. 1045 elif path == 6: 1046 this_path = random.randint(1, 3) # Random. 1047 else: 1048 this_path = path 1049 entries.append(Spawn(spaz_type[0], path=this_path)) 1050 if spacing != 0.0: 1051 entries.append(Spacing(duration=spacing)) 1052 1053 if defender1 is not None: 1054 entries.append(defender1) 1055 if defender2 is not None: 1056 entries.append(defender2) 1057 1058 # Some spacing between groups. 1059 rval = random.random() 1060 if rval < 0.1: 1061 spacing = 5.0 1062 elif rval < 0.5: 1063 spacing = 1.0 1064 else: 1065 spacing = 1.0 1066 entries.append(Spacing(duration=spacing)) 1067 1068 wave = Wave(entries=entries) 1069 1070 else: 1071 assert self._waves is not None 1072 wave = self._waves[self._wavenum - 1] 1073 1074 bot_types += wave.entries 1075 self._time_bonus_mult = 1.0 1076 this_flawless_bonus = 0 1077 non_runner_spawn_time = 1.0 1078 1079 for info in bot_types: 1080 if info is None: 1081 continue 1082 if isinstance(info, Spacing): 1083 t_sec += info.duration 1084 continue 1085 bot_type = info.type 1086 path = info.path 1087 self._time_bonus_mult += bot_type.points_mult * 0.02 1088 this_flawless_bonus += bot_type.points_mult * 5 1089 1090 # If its got a position, use that. 1091 if info.point is not None: 1092 point = info.point 1093 else: 1094 point = Point.START 1095 1096 # Space our our slower bots. 1097 delay = base_delay 1098 delay /= self._get_bot_speed(bot_type) 1099 t_sec += delay * 0.5 1100 tcall = bs.Call( 1101 self.add_bot_at_point, 1102 point, 1103 bot_type, 1104 path, 1105 0.1 if point is Point.START else non_runner_spawn_time, 1106 ) 1107 bs.timer(t_sec, tcall) 1108 t_sec += delay * 0.5 1109 1110 # We can end the wave after all the spawning happens. 1111 bs.timer( 1112 t_sec - delay * 0.5 + non_runner_spawn_time + 0.01, 1113 self._set_can_end_wave, 1114 ) 1115 1116 # Reset our time bonus. 1117 # In this game we use a constant time bonus so it erodes away in 1118 # roughly the same time (since the time limit a wave can take is 1119 # relatively constant) ..we then post-multiply a modifier to adjust 1120 # points. 1121 self._time_bonus = 150 1122 self._flawless_bonus = this_flawless_bonus 1123 assert self._time_bonus_mult is not None 1124 txtval = bs.Lstr( 1125 value='${A}: ${B}', 1126 subs=[ 1127 ('${A}', bs.Lstr(resource='timeBonusText')), 1128 ('${B}', str(int(self._time_bonus * self._time_bonus_mult))), 1129 ], 1130 ) 1131 self._time_bonus_text = bs.NodeActor( 1132 bs.newnode( 1133 'text', 1134 attrs={ 1135 'v_attach': 'top', 1136 'h_attach': 'center', 1137 'h_align': 'center', 1138 'color': (1, 1, 0.0, 1), 1139 'shadow': 1.0, 1140 'vr_depth': -30, 1141 'flatness': 1.0, 1142 'position': (0, -60), 1143 'scale': 0.8, 1144 'text': txtval, 1145 }, 1146 ) 1147 ) 1148 1149 bs.timer(t_sec, self._start_time_bonus_timer) 1150 1151 # Keep track of when this wave finishes emerging. We wanna stop 1152 # dropping land-mines powerups at some point (otherwise a crafty 1153 # player could fill the whole map with them) 1154 self._last_wave_end_time = bs.Time(bs.time() + t_sec) 1155 totalwaves = str(len(self._waves)) if self._waves is not None else '??' 1156 txtval = bs.Lstr( 1157 value='${A} ${B}', 1158 subs=[ 1159 ('${A}', bs.Lstr(resource='waveText')), 1160 ( 1161 '${B}', 1162 str(self._wavenum) 1163 + ( 1164 '' 1165 if self._preset 1166 in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT} 1167 else f'/{totalwaves}' 1168 ), 1169 ), 1170 ], 1171 ) 1172 self._wave_text = bs.NodeActor( 1173 bs.newnode( 1174 'text', 1175 attrs={ 1176 'v_attach': 'top', 1177 'h_attach': 'center', 1178 'h_align': 'center', 1179 'vr_depth': -10, 1180 'color': (1, 1, 1, 1), 1181 'shadow': 1.0, 1182 'flatness': 1.0, 1183 'position': (0, -40), 1184 'scale': 1.3, 1185 'text': txtval, 1186 }, 1187 ) 1188 ) 1189 1190 def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None: 1191 # Add our custom update callback and set some info for this bot. 1192 spaz_type = type(spaz) 1193 assert spaz is not None 1194 spaz.update_callback = self._update_bot 1195 1196 # Tack some custom attrs onto the spaz. 1197 setattr(spaz, 'r_walk_row', path) 1198 setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type)) 1199 1200 def add_bot_at_point( 1201 self, 1202 point: Point, 1203 spaztype: type[SpazBot], 1204 path: int, 1205 spawn_time: float = 0.1, 1206 ) -> None: 1207 """Add the given type bot with the given delay (in seconds).""" 1208 1209 # Don't add if the game has ended. 1210 if self._game_over: 1211 return 1212 pos = self.map.defs.points[point.value][:3] 1213 self._bots.spawn_bot( 1214 spaztype, 1215 pos=pos, 1216 spawn_time=spawn_time, 1217 on_spawn_call=bs.Call(self._on_bot_spawn, path), 1218 ) 1219 1220 def _update_time_bonus(self) -> None: 1221 self._time_bonus = int(self._time_bonus * 0.91) 1222 if self._time_bonus > 0 and self._time_bonus_text is not None: 1223 assert self._time_bonus_text.node 1224 assert self._time_bonus_mult 1225 self._time_bonus_text.node.text = bs.Lstr( 1226 value='${A}: ${B}', 1227 subs=[ 1228 ('${A}', bs.Lstr(resource='timeBonusText')), 1229 ( 1230 '${B}', 1231 str(int(self._time_bonus * self._time_bonus_mult)), 1232 ), 1233 ], 1234 ) 1235 else: 1236 self._time_bonus_text = None 1237 1238 def _start_updating_waves(self) -> None: 1239 self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True) 1240 1241 def _update_scores(self) -> None: 1242 score = self._score 1243 if self._preset is Preset.ENDLESS: 1244 if score >= 500: 1245 self._award_achievement('Runaround Master') 1246 if score >= 1000: 1247 self._award_achievement('Runaround Wizard') 1248 if score >= 2000: 1249 self._award_achievement('Runaround God') 1250 1251 assert self._scoreboard is not None 1252 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1253 1254 def _update_bot(self, bot: SpazBot) -> bool: 1255 # Yup; that's a lot of return statements right there. 1256 # pylint: disable=too-many-return-statements 1257 1258 if not bool(bot): 1259 return True 1260 1261 assert bot.node 1262 1263 # FIXME: Do this in a type safe way. 1264 r_walk_speed: float = getattr(bot, 'r_walk_speed') 1265 r_walk_row: int = getattr(bot, 'r_walk_row') 1266 1267 speed = r_walk_speed 1268 pos = bot.node.position 1269 boxes = self.map.defs.boxes 1270 1271 # Bots in row 1 attempt the high road.. 1272 if r_walk_row == 1: 1273 if bs.is_point_in_box(pos, boxes['b4']): 1274 bot.node.move_up_down = speed 1275 bot.node.move_left_right = 0 1276 bot.node.run = 0.0 1277 return True 1278 1279 # Row 1 and 2 bots attempt the middle road.. 1280 if r_walk_row in [1, 2]: 1281 if bs.is_point_in_box(pos, boxes['b1']): 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 # All bots settle for the third row. 1288 if bs.is_point_in_box(pos, boxes['b7']): 1289 bot.node.move_up_down = speed 1290 bot.node.move_left_right = 0 1291 bot.node.run = 0.0 1292 return True 1293 if bs.is_point_in_box(pos, boxes['b2']): 1294 bot.node.move_up_down = -speed 1295 bot.node.move_left_right = 0 1296 bot.node.run = 0.0 1297 return True 1298 if bs.is_point_in_box(pos, boxes['b3']): 1299 bot.node.move_up_down = -speed 1300 bot.node.move_left_right = 0 1301 bot.node.run = 0.0 1302 return True 1303 if bs.is_point_in_box(pos, boxes['b5']): 1304 bot.node.move_up_down = -speed 1305 bot.node.move_left_right = 0 1306 bot.node.run = 0.0 1307 return True 1308 if bs.is_point_in_box(pos, boxes['b6']): 1309 bot.node.move_up_down = speed 1310 bot.node.move_left_right = 0 1311 bot.node.run = 0.0 1312 return True 1313 if ( 1314 bs.is_point_in_box(pos, boxes['b8']) 1315 and not bs.is_point_in_box(pos, boxes['b9']) 1316 ) or pos == (0.0, 0.0, 0.0): 1317 # Default to walking right if we're still in the walking area. 1318 bot.node.move_left_right = speed 1319 bot.node.move_up_down = 0 1320 bot.node.run = 0.0 1321 return True 1322 1323 # Revert to normal bot behavior otherwise.. 1324 return False 1325 1326 @override 1327 def handlemessage(self, msg: Any) -> Any: 1328 if isinstance(msg, bs.PlayerScoredMessage): 1329 self._score += msg.score 1330 self._update_scores() 1331 1332 elif isinstance(msg, bs.PlayerDiedMessage): 1333 # Augment standard behavior. 1334 super().handlemessage(msg) 1335 1336 self._a_player_has_been_killed = True 1337 1338 # Respawn them shortly. 1339 player = msg.getplayer(Player) 1340 assert self.initialplayerinfos is not None 1341 respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 1342 player.respawn_timer = bs.Timer( 1343 respawn_time, bs.Call(self.spawn_player_if_exists, player) 1344 ) 1345 player.respawn_icon = RespawnIcon(player, respawn_time) 1346 1347 elif isinstance(msg, SpazBotDiedMessage): 1348 if msg.how is bs.DeathType.REACHED_GOAL: 1349 return None 1350 pts, importance = msg.spazbot.get_death_points(msg.how) 1351 if msg.killerplayer is not None: 1352 target: Sequence[float] | None 1353 try: 1354 assert msg.spazbot is not None 1355 assert msg.spazbot.node 1356 target = msg.spazbot.node.position 1357 except Exception: 1358 logging.exception('Error getting SpazBotDied target.') 1359 target = None 1360 try: 1361 if msg.killerplayer: 1362 self.stats.player_scored( 1363 msg.killerplayer, 1364 pts, 1365 target=target, 1366 kill=True, 1367 screenmessage=False, 1368 importance=importance, 1369 ) 1370 dingsound = ( 1371 self._dingsound 1372 if importance == 1 1373 else self._dingsoundhigh 1374 ) 1375 dingsound.play(volume=0.6) 1376 except Exception: 1377 logging.exception('Error on SpazBotDiedMessage.') 1378 1379 # Normally we pull scores from the score-set, but if there's no 1380 # player lets be explicit. 1381 else: 1382 self._score += pts 1383 self._update_scores() 1384 1385 else: 1386 return super().handlemessage(msg) 1387 return None 1388 1389 def _get_bot_speed(self, bot_type: type[SpazBot]) -> float: 1390 speed = self._bot_speed_map.get(bot_type) 1391 if speed is None: 1392 raise TypeError( 1393 'Invalid bot type to _get_bot_speed(): ' + str(bot_type) 1394 ) 1395 return speed 1396 1397 def _set_can_end_wave(self) -> None: 1398 self._can_end_wave = True 1399 1400 def heart_dyin(self, status: bool, time: float = 1.22) -> None: 1401 """Makes the UI heart beat at low health.""" 1402 assert self._lives_bg is not None 1403 if self._lives_bg.node.exists(): 1404 return 1405 heart = self._lives_bg.node 1406 1407 # Make the heart beat intensely! 1408 if status: 1409 bs.animate_array( 1410 heart, 1411 'scale', 1412 2, 1413 { 1414 0: (90, 90), 1415 time * 0.1: (105, 105), 1416 time * 0.21: (88, 88), 1417 time * 0.42: (90, 90), 1418 time * 0.52: (105, 105), 1419 time * 0.63: (88, 88), 1420 time: (90, 90), 1421 }, 1422 ) 1423 1424 # Neutralize heartbeat (Done did when dead.) 1425 else: 1426 # Ew; janky old scenev1 has a single 'Node' Python type so 1427 # it thinks heart.scale could be a few different things 1428 # (float, Sequence[float], etc.). So we have to force the 1429 # issue with a cast(). This should go away with scenev2/etc. 1430 bs.animate_array( 1431 heart, 1432 'scale', 1433 2, 1434 { 1435 0.0: cast(Sequence[float], heart.scale), 1436 time: (90, 90), 1437 }, 1438 )
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.
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.
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.
Our team type for this game.
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.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 spawn_player(self, player: Player) -> bs.Actor: 619 pos = ( 620 self._spawn_center[0] + random.uniform(-1.5, 1.5), 621 self._spawn_center[1], 622 self._spawn_center[2] + random.uniform(-1.5, 1.5), 623 ) 624 spaz = self.spawn_player_spaz(player, position=pos) 625 if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}: 626 spaz.impact_scale = 0.25 627 628 # Add the material that causes us to hit the player-wall. 629 spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup 630 return spaz 631 632 def _on_player_picked_up_powerup(self, player: bs.Actor) -> None: 633 del player # Unused. 634 self._player_has_picked_up_powerup = True 635 636 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 637 if poweruptype is None: 638 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 639 excludetypes=self._exclude_powerups 640 ) 641 PowerupBox( 642 position=self.map.powerup_spawn_points[index], 643 poweruptype=poweruptype, 644 ).autoretain() 645 646 def _start_powerup_drops(self) -> None: 647 bs.timer(3.0, self._drop_powerups, repeat=True) 648 649 def _drop_powerups( 650 self, standard_points: bool = False, force_first: str | None = None 651 ) -> None: 652 """Generic powerup drop.""" 653 654 # If its been a minute since our last wave finished emerging, stop 655 # giving out land-mine powerups. (prevents players from waiting 656 # around for them on purpose and filling the map up) 657 if bs.time() - self._last_wave_end_time > 60.0: 658 extra_excludes = ['land_mines'] 659 else: 660 extra_excludes = [] 661 662 if standard_points: 663 points = self.map.powerup_spawn_points 664 for i in range(len(points)): 665 bs.timer( 666 1.0 + i * 0.5, 667 bs.Call( 668 self._drop_powerup, i, force_first if i == 0 else None 669 ), 670 ) 671 else: 672 pos = ( 673 self._powerup_center[0] 674 + random.uniform( 675 -1.0 * self._powerup_spread[0], 676 1.0 * self._powerup_spread[0], 677 ), 678 self._powerup_center[1], 679 self._powerup_center[2] 680 + random.uniform( 681 -self._powerup_spread[1], self._powerup_spread[1] 682 ), 683 ) 684 685 # drop one random one somewhere.. 686 assert self._exclude_powerups is not None 687 PowerupBox( 688 position=pos, 689 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 690 excludetypes=self._exclude_powerups + extra_excludes 691 ), 692 ).autoretain() 693 694 @override 695 def end_game(self) -> None: 696 bs.pushcall(bs.Call(self.do_end, 'defeat')) 697 bs.setmusic(None) 698 self._player_death_sound.play() 699 700 def do_end(self, outcome: str) -> None: 701 """End the game now with the provided outcome.""" 702 703 if outcome == 'defeat': 704 delay = 2.0 705 self.fade_to_red() 706 else: 707 delay = 0 708 709 score: int | None 710 if self._wavenum >= 2: 711 score = self._score 712 fail_message = None 713 else: 714 score = None 715 fail_message = bs.Lstr(resource='reachWave2Text') 716 717 self.end( 718 delay=delay, 719 results={ 720 'outcome': outcome, 721 'score': score, 722 'fail_message': fail_message, 723 'playerinfos': self.initialplayerinfos, 724 }, 725 ) 726 727 def _update_waves(self) -> None: 728 # pylint: disable=too-many-branches 729 730 # If we have no living bots, go to the next wave. 731 if ( 732 self._can_end_wave 733 and not self._bots.have_living_bots() 734 and not self._game_over 735 and self._lives > 0 736 ): 737 self._can_end_wave = False 738 self._time_bonus_timer = None 739 self._time_bonus_text = None 740 741 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 742 won = False 743 else: 744 assert self._waves is not None 745 won = self._wavenum == len(self._waves) 746 747 # Reward time bonus. 748 base_delay = 4.0 if won else 0 749 if self._time_bonus > 0: 750 bs.timer(0, self._cashregistersound.play) 751 bs.timer( 752 base_delay, 753 bs.Call(self._award_time_bonus, self._time_bonus), 754 ) 755 base_delay += 1.0 756 757 # Reward flawless bonus. 758 if self._wavenum > 0 and self._flawless: 759 bs.timer(base_delay, self._award_flawless_bonus) 760 base_delay += 1.0 761 762 self._flawless = True # reset 763 764 if won: 765 # Completion achievements: 766 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 767 self._award_achievement( 768 'Pro Runaround Victory', sound=False 769 ) 770 if self._lives == self._start_lives: 771 self._award_achievement('The Wall', sound=False) 772 if not self._player_has_picked_up_powerup: 773 self._award_achievement( 774 'Precision Bombing', sound=False 775 ) 776 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 777 self._award_achievement( 778 'Uber Runaround Victory', sound=False 779 ) 780 if self._lives == self._start_lives: 781 self._award_achievement('The Great Wall', sound=False) 782 if not self._a_player_has_been_killed: 783 self._award_achievement('Stayin\' Alive', sound=False) 784 785 # Give remaining players some points and have them celebrate. 786 self.show_zoom_message( 787 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 788 ) 789 790 self.celebrate(10.0) 791 bs.timer(base_delay, self._award_lives_bonus) 792 base_delay += 1.0 793 bs.timer(base_delay, self._award_completion_bonus) 794 base_delay += 0.85 795 self._winsound.play() 796 bs.cameraflash() 797 bs.setmusic(bs.MusicType.VICTORY) 798 self._game_over = True 799 bs.timer(base_delay, bs.Call(self.do_end, 'victory')) 800 return 801 802 self._wavenum += 1 803 804 # Short celebration after waves. 805 if self._wavenum > 1: 806 self.celebrate(0.5) 807 808 bs.timer(base_delay, self._start_next_wave) 809 810 def _award_completion_bonus(self) -> None: 811 bonus = 200 812 self._cashregistersound.play() 813 PopupText( 814 bs.Lstr( 815 value='+${A} ${B}', 816 subs=[ 817 ('${A}', str(bonus)), 818 ('${B}', bs.Lstr(resource='completionBonusText')), 819 ], 820 ), 821 color=(0.7, 0.7, 1.0, 1), 822 scale=1.6, 823 position=(0, 1.5, -1), 824 ).autoretain() 825 self._score += bonus 826 self._update_scores() 827 828 def _award_lives_bonus(self) -> None: 829 bonus = self._lives * 30 830 self._cashregistersound.play() 831 PopupText( 832 bs.Lstr( 833 value='+${A} ${B}', 834 subs=[ 835 ('${A}', str(bonus)), 836 ('${B}', bs.Lstr(resource='livesBonusText')), 837 ], 838 ), 839 color=(0.7, 1.0, 0.3, 1), 840 scale=1.3, 841 position=(0, 1, -1), 842 ).autoretain() 843 self._score += bonus 844 self._update_scores() 845 846 def _award_time_bonus(self, bonus: int) -> None: 847 self._cashregistersound.play() 848 PopupText( 849 bs.Lstr( 850 value='+${A} ${B}', 851 subs=[ 852 ('${A}', str(bonus)), 853 ('${B}', bs.Lstr(resource='timeBonusText')), 854 ], 855 ), 856 color=(1, 1, 0.5, 1), 857 scale=1.0, 858 position=(0, 3, -1), 859 ).autoretain() 860 861 self._score += self._time_bonus 862 self._update_scores() 863 864 def _award_flawless_bonus(self) -> None: 865 self._cashregistersound.play() 866 PopupText( 867 bs.Lstr( 868 value='+${A} ${B}', 869 subs=[ 870 ('${A}', str(self._flawless_bonus)), 871 ('${B}', bs.Lstr(resource='perfectWaveText')), 872 ], 873 ), 874 color=(1, 1, 0.2, 1), 875 scale=1.2, 876 position=(0, 2, -1), 877 ).autoretain() 878 879 assert self._flawless_bonus is not None 880 self._score += self._flawless_bonus 881 self._update_scores() 882 883 def _start_time_bonus_timer(self) -> None: 884 self._time_bonus_timer = bs.Timer( 885 1.0, self._update_time_bonus, repeat=True 886 ) 887 888 def _start_next_wave(self) -> None: 889 # FIXME: Need to split this up. 890 # pylint: disable=too-many-locals 891 # pylint: disable=too-many-branches 892 # pylint: disable=too-many-statements 893 self.show_zoom_message( 894 bs.Lstr( 895 value='${A} ${B}', 896 subs=[ 897 ('${A}', bs.Lstr(resource='waveText')), 898 ('${B}', str(self._wavenum)), 899 ], 900 ), 901 scale=1.0, 902 duration=1.0, 903 trail=True, 904 ) 905 bs.timer(0.4, self._new_wave_sound.play) 906 t_sec = 0.0 907 base_delay = 0.5 908 delay = 0.0 909 bot_types: list[Spawn | Spacing | None] = [] 910 911 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 912 level = self._wavenum 913 target_points = (level + 1) * 8.0 914 group_count = random.randint(1, 3) 915 entries: list[Spawn | Spacing | None] = [] 916 spaz_types: list[tuple[type[SpazBot], float]] = [] 917 if level < 6: 918 spaz_types += [(BomberBot, 5.0)] 919 if level < 10: 920 spaz_types += [(BrawlerBot, 5.0)] 921 if level < 15: 922 spaz_types += [(TriggerBot, 6.0)] 923 if level > 5: 924 spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7) 925 if level > 2: 926 spaz_types += [(BomberBotProShielded, 8.0)] * ( 927 1 + (level - 2) // 6 928 ) 929 if level > 6: 930 spaz_types += [(TriggerBotProShielded, 12.0)] * ( 931 1 + (level - 6) // 5 932 ) 933 if level > 1: 934 spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4) 935 if level > 7: 936 spaz_types += [(ChargerBotProShielded, 15.0)] * ( 937 1 + (level - 7) // 3 938 ) 939 940 # Bot type, their effect on target points. 941 defender_types: list[tuple[type[SpazBot], float]] = [ 942 (BomberBot, 0.9), 943 (BrawlerBot, 0.9), 944 (TriggerBot, 0.85), 945 ] 946 if level > 2: 947 defender_types += [(ChargerBot, 0.75)] 948 if level > 4: 949 defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6) 950 if level > 6: 951 defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5) 952 if level > 8: 953 defender_types += [(BrawlerBotProShielded, 0.65)] * ( 954 1 + (level - 5) // 4 955 ) 956 if level > 10: 957 defender_types += [(TriggerBotProShielded, 0.6)] * ( 958 1 + (level - 6) // 3 959 ) 960 961 for group in range(group_count): 962 this_target_point_s = target_points / group_count 963 964 # Adding spacing makes things slightly harder. 965 rval = random.random() 966 if rval < 0.07: 967 spacing = 1.5 968 this_target_point_s *= 0.85 969 elif rval < 0.15: 970 spacing = 1.0 971 this_target_point_s *= 0.9 972 else: 973 spacing = 0.0 974 975 path = random.randint(1, 3) 976 977 # Don't allow hard paths on early levels. 978 if level < 3: 979 if path == 1: 980 path = 3 981 982 # Easy path. 983 if path == 3: 984 pass 985 986 # Harder path. 987 elif path == 2: 988 this_target_point_s *= 0.8 989 990 # Even harder path. 991 elif path == 1: 992 this_target_point_s *= 0.7 993 994 # Looping forward. 995 elif path == 4: 996 this_target_point_s *= 0.7 997 998 # Looping backward. 999 elif path == 5: 1000 this_target_point_s *= 0.7 1001 1002 # Random. 1003 elif path == 6: 1004 this_target_point_s *= 0.7 1005 1006 def _add_defender( 1007 defender_type: tuple[type[SpazBot], float], pnt: Point 1008 ) -> tuple[float, Spawn]: 1009 # This is ok because we call it immediately. 1010 # pylint: disable=cell-var-from-loop 1011 return this_target_point_s * defender_type[1], Spawn( 1012 defender_type[0], point=pnt 1013 ) 1014 1015 # Add defenders. 1016 defender_type1 = defender_types[ 1017 random.randrange(len(defender_types)) 1018 ] 1019 defender_type2 = defender_types[ 1020 random.randrange(len(defender_types)) 1021 ] 1022 defender1 = defender2 = None 1023 if ( 1024 (group == 0) 1025 or (group == 1 and level > 3) 1026 or (group == 2 and level > 5) 1027 ): 1028 if random.random() < min(0.75, (level - 1) * 0.11): 1029 this_target_point_s, defender1 = _add_defender( 1030 defender_type1, Point.BOTTOM_LEFT 1031 ) 1032 if random.random() < min(0.75, (level - 1) * 0.04): 1033 this_target_point_s, defender2 = _add_defender( 1034 defender_type2, Point.BOTTOM_RIGHT 1035 ) 1036 1037 spaz_type = spaz_types[random.randrange(len(spaz_types))] 1038 member_count = max( 1039 1, int(round(this_target_point_s / spaz_type[1])) 1040 ) 1041 for i, _member in enumerate(range(member_count)): 1042 if path == 4: 1043 this_path = i % 3 # Looping forward. 1044 elif path == 5: 1045 this_path = 3 - (i % 3) # Looping backward. 1046 elif path == 6: 1047 this_path = random.randint(1, 3) # Random. 1048 else: 1049 this_path = path 1050 entries.append(Spawn(spaz_type[0], path=this_path)) 1051 if spacing != 0.0: 1052 entries.append(Spacing(duration=spacing)) 1053 1054 if defender1 is not None: 1055 entries.append(defender1) 1056 if defender2 is not None: 1057 entries.append(defender2) 1058 1059 # Some spacing between groups. 1060 rval = random.random() 1061 if rval < 0.1: 1062 spacing = 5.0 1063 elif rval < 0.5: 1064 spacing = 1.0 1065 else: 1066 spacing = 1.0 1067 entries.append(Spacing(duration=spacing)) 1068 1069 wave = Wave(entries=entries) 1070 1071 else: 1072 assert self._waves is not None 1073 wave = self._waves[self._wavenum - 1] 1074 1075 bot_types += wave.entries 1076 self._time_bonus_mult = 1.0 1077 this_flawless_bonus = 0 1078 non_runner_spawn_time = 1.0 1079 1080 for info in bot_types: 1081 if info is None: 1082 continue 1083 if isinstance(info, Spacing): 1084 t_sec += info.duration 1085 continue 1086 bot_type = info.type 1087 path = info.path 1088 self._time_bonus_mult += bot_type.points_mult * 0.02 1089 this_flawless_bonus += bot_type.points_mult * 5 1090 1091 # If its got a position, use that. 1092 if info.point is not None: 1093 point = info.point 1094 else: 1095 point = Point.START 1096 1097 # Space our our slower bots. 1098 delay = base_delay 1099 delay /= self._get_bot_speed(bot_type) 1100 t_sec += delay * 0.5 1101 tcall = bs.Call( 1102 self.add_bot_at_point, 1103 point, 1104 bot_type, 1105 path, 1106 0.1 if point is Point.START else non_runner_spawn_time, 1107 ) 1108 bs.timer(t_sec, tcall) 1109 t_sec += delay * 0.5 1110 1111 # We can end the wave after all the spawning happens. 1112 bs.timer( 1113 t_sec - delay * 0.5 + non_runner_spawn_time + 0.01, 1114 self._set_can_end_wave, 1115 ) 1116 1117 # Reset our time bonus. 1118 # In this game we use a constant time bonus so it erodes away in 1119 # roughly the same time (since the time limit a wave can take is 1120 # relatively constant) ..we then post-multiply a modifier to adjust 1121 # points. 1122 self._time_bonus = 150 1123 self._flawless_bonus = this_flawless_bonus 1124 assert self._time_bonus_mult is not None 1125 txtval = bs.Lstr( 1126 value='${A}: ${B}', 1127 subs=[ 1128 ('${A}', bs.Lstr(resource='timeBonusText')), 1129 ('${B}', str(int(self._time_bonus * self._time_bonus_mult))), 1130 ], 1131 ) 1132 self._time_bonus_text = bs.NodeActor( 1133 bs.newnode( 1134 'text', 1135 attrs={ 1136 'v_attach': 'top', 1137 'h_attach': 'center', 1138 'h_align': 'center', 1139 'color': (1, 1, 0.0, 1), 1140 'shadow': 1.0, 1141 'vr_depth': -30, 1142 'flatness': 1.0, 1143 'position': (0, -60), 1144 'scale': 0.8, 1145 'text': txtval, 1146 }, 1147 ) 1148 ) 1149 1150 bs.timer(t_sec, self._start_time_bonus_timer) 1151 1152 # Keep track of when this wave finishes emerging. We wanna stop 1153 # dropping land-mines powerups at some point (otherwise a crafty 1154 # player could fill the whole map with them) 1155 self._last_wave_end_time = bs.Time(bs.time() + t_sec) 1156 totalwaves = str(len(self._waves)) if self._waves is not None else '??' 1157 txtval = bs.Lstr( 1158 value='${A} ${B}', 1159 subs=[ 1160 ('${A}', bs.Lstr(resource='waveText')), 1161 ( 1162 '${B}', 1163 str(self._wavenum) 1164 + ( 1165 '' 1166 if self._preset 1167 in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT} 1168 else f'/{totalwaves}' 1169 ), 1170 ), 1171 ], 1172 ) 1173 self._wave_text = bs.NodeActor( 1174 bs.newnode( 1175 'text', 1176 attrs={ 1177 'v_attach': 'top', 1178 'h_attach': 'center', 1179 'h_align': 'center', 1180 'vr_depth': -10, 1181 'color': (1, 1, 1, 1), 1182 'shadow': 1.0, 1183 'flatness': 1.0, 1184 'position': (0, -40), 1185 'scale': 1.3, 1186 'text': txtval, 1187 }, 1188 ) 1189 ) 1190 1191 def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None: 1192 # Add our custom update callback and set some info for this bot. 1193 spaz_type = type(spaz) 1194 assert spaz is not None 1195 spaz.update_callback = self._update_bot 1196 1197 # Tack some custom attrs onto the spaz. 1198 setattr(spaz, 'r_walk_row', path) 1199 setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type)) 1200 1201 def add_bot_at_point( 1202 self, 1203 point: Point, 1204 spaztype: type[SpazBot], 1205 path: int, 1206 spawn_time: float = 0.1, 1207 ) -> None: 1208 """Add the given type bot with the given delay (in seconds).""" 1209 1210 # Don't add if the game has ended. 1211 if self._game_over: 1212 return 1213 pos = self.map.defs.points[point.value][:3] 1214 self._bots.spawn_bot( 1215 spaztype, 1216 pos=pos, 1217 spawn_time=spawn_time, 1218 on_spawn_call=bs.Call(self._on_bot_spawn, path), 1219 ) 1220 1221 def _update_time_bonus(self) -> None: 1222 self._time_bonus = int(self._time_bonus * 0.91) 1223 if self._time_bonus > 0 and self._time_bonus_text is not None: 1224 assert self._time_bonus_text.node 1225 assert self._time_bonus_mult 1226 self._time_bonus_text.node.text = bs.Lstr( 1227 value='${A}: ${B}', 1228 subs=[ 1229 ('${A}', bs.Lstr(resource='timeBonusText')), 1230 ( 1231 '${B}', 1232 str(int(self._time_bonus * self._time_bonus_mult)), 1233 ), 1234 ], 1235 ) 1236 else: 1237 self._time_bonus_text = None 1238 1239 def _start_updating_waves(self) -> None: 1240 self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True) 1241 1242 def _update_scores(self) -> None: 1243 score = self._score 1244 if self._preset is Preset.ENDLESS: 1245 if score >= 500: 1246 self._award_achievement('Runaround Master') 1247 if score >= 1000: 1248 self._award_achievement('Runaround Wizard') 1249 if score >= 2000: 1250 self._award_achievement('Runaround God') 1251 1252 assert self._scoreboard is not None 1253 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1254 1255 def _update_bot(self, bot: SpazBot) -> bool: 1256 # Yup; that's a lot of return statements right there. 1257 # pylint: disable=too-many-return-statements 1258 1259 if not bool(bot): 1260 return True 1261 1262 assert bot.node 1263 1264 # FIXME: Do this in a type safe way. 1265 r_walk_speed: float = getattr(bot, 'r_walk_speed') 1266 r_walk_row: int = getattr(bot, 'r_walk_row') 1267 1268 speed = r_walk_speed 1269 pos = bot.node.position 1270 boxes = self.map.defs.boxes 1271 1272 # Bots in row 1 attempt the high road.. 1273 if r_walk_row == 1: 1274 if bs.is_point_in_box(pos, boxes['b4']): 1275 bot.node.move_up_down = speed 1276 bot.node.move_left_right = 0 1277 bot.node.run = 0.0 1278 return True 1279 1280 # Row 1 and 2 bots attempt the middle road.. 1281 if r_walk_row in [1, 2]: 1282 if bs.is_point_in_box(pos, boxes['b1']): 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 # All bots settle for the third row. 1289 if bs.is_point_in_box(pos, boxes['b7']): 1290 bot.node.move_up_down = speed 1291 bot.node.move_left_right = 0 1292 bot.node.run = 0.0 1293 return True 1294 if bs.is_point_in_box(pos, boxes['b2']): 1295 bot.node.move_up_down = -speed 1296 bot.node.move_left_right = 0 1297 bot.node.run = 0.0 1298 return True 1299 if bs.is_point_in_box(pos, boxes['b3']): 1300 bot.node.move_up_down = -speed 1301 bot.node.move_left_right = 0 1302 bot.node.run = 0.0 1303 return True 1304 if bs.is_point_in_box(pos, boxes['b5']): 1305 bot.node.move_up_down = -speed 1306 bot.node.move_left_right = 0 1307 bot.node.run = 0.0 1308 return True 1309 if bs.is_point_in_box(pos, boxes['b6']): 1310 bot.node.move_up_down = speed 1311 bot.node.move_left_right = 0 1312 bot.node.run = 0.0 1313 return True 1314 if ( 1315 bs.is_point_in_box(pos, boxes['b8']) 1316 and not bs.is_point_in_box(pos, boxes['b9']) 1317 ) or pos == (0.0, 0.0, 0.0): 1318 # Default to walking right if we're still in the walking area. 1319 bot.node.move_left_right = speed 1320 bot.node.move_up_down = 0 1321 bot.node.run = 0.0 1322 return True 1323 1324 # Revert to normal bot behavior otherwise.. 1325 return False 1326 1327 @override 1328 def handlemessage(self, msg: Any) -> Any: 1329 if isinstance(msg, bs.PlayerScoredMessage): 1330 self._score += msg.score 1331 self._update_scores() 1332 1333 elif isinstance(msg, bs.PlayerDiedMessage): 1334 # Augment standard behavior. 1335 super().handlemessage(msg) 1336 1337 self._a_player_has_been_killed = True 1338 1339 # Respawn them shortly. 1340 player = msg.getplayer(Player) 1341 assert self.initialplayerinfos is not None 1342 respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 1343 player.respawn_timer = bs.Timer( 1344 respawn_time, bs.Call(self.spawn_player_if_exists, player) 1345 ) 1346 player.respawn_icon = RespawnIcon(player, respawn_time) 1347 1348 elif isinstance(msg, SpazBotDiedMessage): 1349 if msg.how is bs.DeathType.REACHED_GOAL: 1350 return None 1351 pts, importance = msg.spazbot.get_death_points(msg.how) 1352 if msg.killerplayer is not None: 1353 target: Sequence[float] | None 1354 try: 1355 assert msg.spazbot is not None 1356 assert msg.spazbot.node 1357 target = msg.spazbot.node.position 1358 except Exception: 1359 logging.exception('Error getting SpazBotDied target.') 1360 target = None 1361 try: 1362 if msg.killerplayer: 1363 self.stats.player_scored( 1364 msg.killerplayer, 1365 pts, 1366 target=target, 1367 kill=True, 1368 screenmessage=False, 1369 importance=importance, 1370 ) 1371 dingsound = ( 1372 self._dingsound 1373 if importance == 1 1374 else self._dingsoundhigh 1375 ) 1376 dingsound.play(volume=0.6) 1377 except Exception: 1378 logging.exception('Error on SpazBotDiedMessage.') 1379 1380 # Normally we pull scores from the score-set, but if there's no 1381 # player lets be explicit. 1382 else: 1383 self._score += pts 1384 self._update_scores() 1385 1386 else: 1387 return super().handlemessage(msg) 1388 return None 1389 1390 def _get_bot_speed(self, bot_type: type[SpazBot]) -> float: 1391 speed = self._bot_speed_map.get(bot_type) 1392 if speed is None: 1393 raise TypeError( 1394 'Invalid bot type to _get_bot_speed(): ' + str(bot_type) 1395 ) 1396 return speed 1397 1398 def _set_can_end_wave(self) -> None: 1399 self._can_end_wave = True 1400 1401 def heart_dyin(self, status: bool, time: float = 1.22) -> None: 1402 """Makes the UI heart beat at low health.""" 1403 assert self._lives_bg is not None 1404 if self._lives_bg.node.exists(): 1405 return 1406 heart = self._lives_bg.node 1407 1408 # Make the heart beat intensely! 1409 if status: 1410 bs.animate_array( 1411 heart, 1412 'scale', 1413 2, 1414 { 1415 0: (90, 90), 1416 time * 0.1: (105, 105), 1417 time * 0.21: (88, 88), 1418 time * 0.42: (90, 90), 1419 time * 0.52: (105, 105), 1420 time * 0.63: (88, 88), 1421 time: (90, 90), 1422 }, 1423 ) 1424 1425 # Neutralize heartbeat (Done did when dead.) 1426 else: 1427 # Ew; janky old scenev1 has a single 'Node' Python type so 1428 # it thinks heart.scale could be a few different things 1429 # (float, Sequence[float], etc.). So we have to force the 1430 # issue with a cast(). This should go away with scenev2/etc. 1431 bs.animate_array( 1432 heart, 1433 'scale', 1434 2, 1435 { 1436 0.0: cast(Sequence[float], heart.scale), 1437 time: (90, 90), 1438 }, 1439 )
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 spawn_player(self, player: Player) -> bs.Actor: 619 pos = ( 620 self._spawn_center[0] + random.uniform(-1.5, 1.5), 621 self._spawn_center[1], 622 self._spawn_center[2] + random.uniform(-1.5, 1.5), 623 ) 624 spaz = self.spawn_player_spaz(player, position=pos) 625 if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}: 626 spaz.impact_scale = 0.25 627 628 # Add the material that causes us to hit the player-wall. 629 spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup 630 return spaz
Spawn something for the provided bascenev1.Player.
The default implementation simply calls spawn_player_spaz().
694 @override 695 def end_game(self) -> None: 696 bs.pushcall(bs.Call(self.do_end, 'defeat')) 697 bs.setmusic(None) 698 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.
700 def do_end(self, outcome: str) -> None: 701 """End the game now with the provided outcome.""" 702 703 if outcome == 'defeat': 704 delay = 2.0 705 self.fade_to_red() 706 else: 707 delay = 0 708 709 score: int | None 710 if self._wavenum >= 2: 711 score = self._score 712 fail_message = None 713 else: 714 score = None 715 fail_message = bs.Lstr(resource='reachWave2Text') 716 717 self.end( 718 delay=delay, 719 results={ 720 'outcome': outcome, 721 'score': score, 722 'fail_message': fail_message, 723 'playerinfos': self.initialplayerinfos, 724 }, 725 )
End the game now with the provided outcome.
1201 def add_bot_at_point( 1202 self, 1203 point: Point, 1204 spaztype: type[SpazBot], 1205 path: int, 1206 spawn_time: float = 0.1, 1207 ) -> None: 1208 """Add the given type bot with the given delay (in seconds).""" 1209 1210 # Don't add if the game has ended. 1211 if self._game_over: 1212 return 1213 pos = self.map.defs.points[point.value][:3] 1214 self._bots.spawn_bot( 1215 spaztype, 1216 pos=pos, 1217 spawn_time=spawn_time, 1218 on_spawn_call=bs.Call(self._on_bot_spawn, path), 1219 )
Add the given type bot with the given delay (in seconds).
1327 @override 1328 def handlemessage(self, msg: Any) -> Any: 1329 if isinstance(msg, bs.PlayerScoredMessage): 1330 self._score += msg.score 1331 self._update_scores() 1332 1333 elif isinstance(msg, bs.PlayerDiedMessage): 1334 # Augment standard behavior. 1335 super().handlemessage(msg) 1336 1337 self._a_player_has_been_killed = True 1338 1339 # Respawn them shortly. 1340 player = msg.getplayer(Player) 1341 assert self.initialplayerinfos is not None 1342 respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0 1343 player.respawn_timer = bs.Timer( 1344 respawn_time, bs.Call(self.spawn_player_if_exists, player) 1345 ) 1346 player.respawn_icon = RespawnIcon(player, respawn_time) 1347 1348 elif isinstance(msg, SpazBotDiedMessage): 1349 if msg.how is bs.DeathType.REACHED_GOAL: 1350 return None 1351 pts, importance = msg.spazbot.get_death_points(msg.how) 1352 if msg.killerplayer is not None: 1353 target: Sequence[float] | None 1354 try: 1355 assert msg.spazbot is not None 1356 assert msg.spazbot.node 1357 target = msg.spazbot.node.position 1358 except Exception: 1359 logging.exception('Error getting SpazBotDied target.') 1360 target = None 1361 try: 1362 if msg.killerplayer: 1363 self.stats.player_scored( 1364 msg.killerplayer, 1365 pts, 1366 target=target, 1367 kill=True, 1368 screenmessage=False, 1369 importance=importance, 1370 ) 1371 dingsound = ( 1372 self._dingsound 1373 if importance == 1 1374 else self._dingsoundhigh 1375 ) 1376 dingsound.play(volume=0.6) 1377 except Exception: 1378 logging.exception('Error on SpazBotDiedMessage.') 1379 1380 # Normally we pull scores from the score-set, but if there's no 1381 # player lets be explicit. 1382 else: 1383 self._score += pts 1384 self._update_scores() 1385 1386 else: 1387 return super().handlemessage(msg) 1388 return None
General message handling; can be passed any message object.
1401 def heart_dyin(self, status: bool, time: float = 1.22) -> None: 1402 """Makes the UI heart beat at low health.""" 1403 assert self._lives_bg is not None 1404 if self._lives_bg.node.exists(): 1405 return 1406 heart = self._lives_bg.node 1407 1408 # Make the heart beat intensely! 1409 if status: 1410 bs.animate_array( 1411 heart, 1412 'scale', 1413 2, 1414 { 1415 0: (90, 90), 1416 time * 0.1: (105, 105), 1417 time * 0.21: (88, 88), 1418 time * 0.42: (90, 90), 1419 time * 0.52: (105, 105), 1420 time * 0.63: (88, 88), 1421 time: (90, 90), 1422 }, 1423 ) 1424 1425 # Neutralize heartbeat (Done did when dead.) 1426 else: 1427 # Ew; janky old scenev1 has a single 'Node' Python type so 1428 # it thinks heart.scale could be a few different things 1429 # (float, Sequence[float], etc.). So we have to force the 1430 # issue with a cast(). This should go away with scenev2/etc. 1431 bs.animate_array( 1432 heart, 1433 'scale', 1434 2, 1435 { 1436 0.0: cast(Sequence[float], heart.scale), 1437 time: (90, 90), 1438 }, 1439 )
Makes the UI heart beat at low health.