bastd.game.onslaught
Provides Onslaught Co-op game.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Provides Onslaught Co-op game.""" 4 5# Yes this is a long one.. 6# pylint: disable=too-many-lines 7 8# ba_meta require api 7 9# (see https://ballistica.net/wiki/meta-tag-system) 10 11from __future__ import annotations 12 13import math 14import random 15from enum import Enum, unique 16from dataclasses import dataclass 17from typing import TYPE_CHECKING 18 19import ba 20from bastd.actor.popuptext import PopupText 21from bastd.actor.bomb import TNTSpawner 22from bastd.actor.playerspaz import PlayerSpazHurtMessage 23from bastd.actor.scoreboard import Scoreboard 24from bastd.actor.controlsguide import ControlsGuide 25from bastd.actor.powerupbox import PowerupBox, PowerupBoxFactory 26from bastd.actor.spazbot import ( 27 SpazBotDiedMessage, 28 SpazBotSet, 29 ChargerBot, 30 StickyBot, 31 BomberBot, 32 BomberBotLite, 33 BrawlerBot, 34 BrawlerBotLite, 35 TriggerBot, 36 BomberBotStaticLite, 37 TriggerBotStatic, 38 BomberBotProStatic, 39 TriggerBotPro, 40 ExplodeyBot, 41 BrawlerBotProShielded, 42 ChargerBotProShielded, 43 BomberBotPro, 44 TriggerBotProShielded, 45 BrawlerBotPro, 46 BomberBotProShielded, 47) 48 49if TYPE_CHECKING: 50 from typing import Any, Sequence 51 from bastd.actor.spazbot import SpazBot 52 53 54@dataclass 55class Wave: 56 """A wave of enemies.""" 57 58 entries: list[Spawn | Spacing | Delay | None] 59 base_angle: float = 0.0 60 61 62@dataclass 63class Spawn: 64 """A bot spawn event in a wave.""" 65 66 bottype: type[SpazBot] | str 67 point: Point | None = None 68 spacing: float = 5.0 69 70 71@dataclass 72class Spacing: 73 """Empty space in a wave.""" 74 75 spacing: float = 5.0 76 77 78@dataclass 79class Delay: 80 """A delay between events in a wave.""" 81 82 duration: float 83 84 85class Preset(Enum): 86 """Game presets we support.""" 87 88 TRAINING = 'training' 89 TRAINING_EASY = 'training_easy' 90 ROOKIE = 'rookie' 91 ROOKIE_EASY = 'rookie_easy' 92 PRO = 'pro' 93 PRO_EASY = 'pro_easy' 94 UBER = 'uber' 95 UBER_EASY = 'uber_easy' 96 ENDLESS = 'endless' 97 ENDLESS_TOURNAMENT = 'endless_tournament' 98 99 100@unique 101class Point(Enum): 102 """Points on the map we can spawn at.""" 103 104 LEFT_UPPER_MORE = 'bot_spawn_left_upper_more' 105 LEFT_UPPER = 'bot_spawn_left_upper' 106 TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right' 107 RIGHT_UPPER = 'bot_spawn_right_upper' 108 TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left' 109 TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right' 110 TURRET_TOP_LEFT = 'bot_spawn_turret_top_left' 111 TOP_RIGHT = 'bot_spawn_top_right' 112 TOP_LEFT = 'bot_spawn_top_left' 113 TOP = 'bot_spawn_top' 114 BOTTOM = 'bot_spawn_bottom' 115 LEFT = 'bot_spawn_left' 116 RIGHT = 'bot_spawn_right' 117 RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more' 118 RIGHT_LOWER = 'bot_spawn_right_lower' 119 RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more' 120 BOTTOM_RIGHT = 'bot_spawn_bottom_right' 121 BOTTOM_LEFT = 'bot_spawn_bottom_left' 122 TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right' 123 TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left' 124 LEFT_LOWER = 'bot_spawn_left_lower' 125 LEFT_LOWER_MORE = 'bot_spawn_left_lower_more' 126 TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle' 127 BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right' 128 BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left' 129 TOP_HALF_RIGHT = 'bot_spawn_top_half_right' 130 TOP_HALF_LEFT = 'bot_spawn_top_half_left' 131 132 133class Player(ba.Player['Team']): 134 """Our player type for this game.""" 135 136 def __init__(self) -> None: 137 self.has_been_hurt = False 138 self.respawn_wave = 0 139 140 141class Team(ba.Team[Player]): 142 """Our team type for this game.""" 143 144 145class OnslaughtGame(ba.CoopGameActivity[Player, Team]): 146 """Co-op game where players try to survive attacking waves of enemies.""" 147 148 name = 'Onslaught' 149 description = 'Defeat all enemies.' 150 151 tips: list[str | ba.GameTip] = [ 152 'Hold any button to run.' 153 ' (Trigger buttons work well if you have them)', 154 'Try tricking enemies into killing eachother or running off cliffs.', 155 'Try \'Cooking off\' bombs for a second or two before throwing them.', 156 'It\'s easier to win with a friend or two helping.', 157 'If you stay in one place, you\'re toast. Run and dodge to survive..', 158 'Practice using your momentum to throw bombs more accurately.', 159 'Your punches do much more damage if you are running or spinning.', 160 ] 161 162 # Show messages when players die since it matters here. 163 announce_player_deaths = True 164 165 def __init__(self, settings: dict): 166 167 self._preset = Preset(settings.get('preset', 'training')) 168 if self._preset in { 169 Preset.TRAINING, 170 Preset.TRAINING_EASY, 171 Preset.PRO, 172 Preset.PRO_EASY, 173 Preset.ENDLESS, 174 Preset.ENDLESS_TOURNAMENT, 175 }: 176 settings['map'] = 'Doom Shroom' 177 else: 178 settings['map'] = 'Courtyard' 179 180 super().__init__(settings) 181 182 self._new_wave_sound = ba.getsound('scoreHit01') 183 self._winsound = ba.getsound('score') 184 self._cashregistersound = ba.getsound('cashRegister') 185 self._a_player_has_been_hurt = False 186 self._player_has_dropped_bomb = False 187 188 # FIXME: should use standard map defs. 189 if settings['map'] == 'Doom Shroom': 190 self._spawn_center = (0, 3, -5) 191 self._tntspawnpos = (0.0, 3.0, -5.0) 192 self._powerup_center = (0, 5, -3.6) 193 self._powerup_spread = (6.0, 4.0) 194 elif settings['map'] == 'Courtyard': 195 self._spawn_center = (0, 3, -2) 196 self._tntspawnpos = (0.0, 3.0, 2.1) 197 self._powerup_center = (0, 5, -1.6) 198 self._powerup_spread = (4.6, 2.7) 199 else: 200 raise Exception('Unsupported map: ' + str(settings['map'])) 201 self._scoreboard: Scoreboard | None = None 202 self._game_over = False 203 self._wavenum = 0 204 self._can_end_wave = True 205 self._score = 0 206 self._time_bonus = 0 207 self._spawn_info_text: ba.NodeActor | None = None 208 self._dingsound = ba.getsound('dingSmall') 209 self._dingsoundhigh = ba.getsound('dingSmallHigh') 210 self._have_tnt = False 211 self._excluded_powerups: list[str] | None = None 212 self._waves: list[Wave] = [] 213 self._tntspawner: TNTSpawner | None = None 214 self._bots: SpazBotSet | None = None 215 self._powerup_drop_timer: ba.Timer | None = None 216 self._time_bonus_timer: ba.Timer | None = None 217 self._time_bonus_text: ba.NodeActor | None = None 218 self._flawless_bonus: int | None = None 219 self._wave_text: ba.NodeActor | None = None 220 self._wave_update_timer: ba.Timer | None = None 221 self._throw_off_kills = 0 222 self._land_mine_kills = 0 223 self._tnt_kills = 0 224 225 def on_transition_in(self) -> None: 226 super().on_transition_in() 227 customdata = ba.getsession().customdata 228 229 # Show special landmine tip on rookie preset. 230 if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 231 # Show once per session only (then we revert to regular tips). 232 if not customdata.get('_showed_onslaught_landmine_tip', False): 233 customdata['_showed_onslaught_landmine_tip'] = True 234 self.tips = [ 235 ba.GameTip( 236 'Land-mines are a good way to stop speedy enemies.', 237 icon=ba.gettexture('powerupLandMines'), 238 sound=ba.getsound('ding'), 239 ) 240 ] 241 242 # Show special tnt tip on pro preset. 243 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 244 # Show once per session only (then we revert to regular tips). 245 if not customdata.get('_showed_onslaught_tnt_tip', False): 246 customdata['_showed_onslaught_tnt_tip'] = True 247 self.tips = [ 248 ba.GameTip( 249 'Take out a group of enemies by\n' 250 'setting off a bomb near a TNT box.', 251 icon=ba.gettexture('tnt'), 252 sound=ba.getsound('ding'), 253 ) 254 ] 255 256 # Show special curse tip on uber preset. 257 if self._preset in {Preset.UBER, Preset.UBER_EASY}: 258 # Show once per session only (then we revert to regular tips). 259 if not customdata.get('_showed_onslaught_curse_tip', False): 260 customdata['_showed_onslaught_curse_tip'] = True 261 self.tips = [ 262 ba.GameTip( 263 'Curse boxes turn you into a ticking time bomb.\n' 264 'The only cure is to quickly grab a health-pack.', 265 icon=ba.gettexture('powerupCurse'), 266 sound=ba.getsound('ding'), 267 ) 268 ] 269 270 self._spawn_info_text = ba.NodeActor( 271 ba.newnode( 272 'text', 273 attrs={ 274 'position': (15, -130), 275 'h_attach': 'left', 276 'v_attach': 'top', 277 'scale': 0.55, 278 'color': (0.3, 0.8, 0.3, 1.0), 279 'text': '', 280 }, 281 ) 282 ) 283 ba.setmusic(ba.MusicType.ONSLAUGHT) 284 285 self._scoreboard = Scoreboard( 286 label=ba.Lstr(resource='scoreText'), score_split=0.5 287 ) 288 289 def on_begin(self) -> None: 290 super().on_begin() 291 player_count = len(self.players) 292 hard = self._preset not in { 293 Preset.TRAINING_EASY, 294 Preset.ROOKIE_EASY, 295 Preset.PRO_EASY, 296 Preset.UBER_EASY, 297 } 298 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 299 ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain() 300 301 self._have_tnt = False 302 self._excluded_powerups = ['curse', 'land_mines'] 303 self._waves = [ 304 Wave( 305 base_angle=195, 306 entries=[ 307 Spawn(BomberBotLite, spacing=5), 308 ] 309 * player_count, 310 ), 311 Wave( 312 base_angle=130, 313 entries=[ 314 Spawn(BrawlerBotLite, spacing=5), 315 ] 316 * player_count, 317 ), 318 Wave( 319 base_angle=195, 320 entries=[Spawn(BomberBotLite, spacing=10)] 321 * (player_count + 1), 322 ), 323 Wave( 324 base_angle=130, 325 entries=[ 326 Spawn(BrawlerBotLite, spacing=10), 327 ] 328 * (player_count + 1), 329 ), 330 Wave( 331 base_angle=130, 332 entries=[ 333 Spawn(BrawlerBotLite, spacing=5) 334 if player_count > 1 335 else None, 336 Spawn(BrawlerBotLite, spacing=5), 337 Spacing(30), 338 Spawn(BomberBotLite, spacing=5) 339 if player_count > 3 340 else None, 341 Spawn(BomberBotLite, spacing=5), 342 Spacing(30), 343 Spawn(BrawlerBotLite, spacing=5), 344 Spawn(BrawlerBotLite, spacing=5) 345 if player_count > 2 346 else None, 347 ], 348 ), 349 Wave( 350 base_angle=195, 351 entries=[ 352 Spawn(TriggerBot, spacing=90), 353 Spawn(TriggerBot, spacing=90) 354 if player_count > 1 355 else None, 356 ], 357 ), 358 ] 359 360 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 361 self._have_tnt = False 362 self._excluded_powerups = ['curse'] 363 self._waves = [ 364 Wave( 365 entries=[ 366 Spawn(ChargerBot, Point.LEFT_UPPER_MORE) 367 if player_count > 2 368 else None, 369 Spawn(ChargerBot, Point.LEFT_UPPER), 370 ] 371 ), 372 Wave( 373 entries=[ 374 Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT), 375 Spawn(BrawlerBotLite, Point.RIGHT_UPPER), 376 Spawn(BrawlerBotLite, Point.RIGHT_LOWER) 377 if player_count > 1 378 else None, 379 Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT) 380 if player_count > 2 381 else None, 382 ] 383 ), 384 Wave( 385 entries=[ 386 Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT), 387 Spawn(TriggerBot, Point.LEFT), 388 Spawn(TriggerBot, Point.LEFT_LOWER) 389 if player_count > 1 390 else None, 391 Spawn(TriggerBot, Point.LEFT_UPPER) 392 if player_count > 2 393 else None, 394 ] 395 ), 396 Wave( 397 entries=[ 398 Spawn(BrawlerBotLite, Point.TOP_RIGHT), 399 Spawn(BrawlerBot, Point.TOP_HALF_RIGHT) 400 if player_count > 1 401 else None, 402 Spawn(BrawlerBotLite, Point.TOP_LEFT), 403 Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT) 404 if player_count > 2 405 else None, 406 Spawn(BrawlerBot, Point.TOP), 407 Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE), 408 ] 409 ), 410 Wave( 411 entries=[ 412 Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT), 413 Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT), 414 Spawn(TriggerBot, Point.BOTTOM), 415 Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT) 416 if player_count > 1 417 else None, 418 Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT) 419 if player_count > 2 420 else None, 421 ] 422 ), 423 Wave( 424 entries=[ 425 Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT), 426 Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT), 427 Spawn(ChargerBot, Point.BOTTOM), 428 Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT) 429 if player_count > 1 430 else None, 431 Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT) 432 if player_count > 2 433 else None, 434 ] 435 ), 436 ] 437 438 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 439 self._excluded_powerups = ['curse'] 440 self._have_tnt = True 441 self._waves = [ 442 Wave( 443 base_angle=-50, 444 entries=[ 445 Spawn(BrawlerBot, spacing=12) 446 if player_count > 3 447 else None, 448 Spawn(BrawlerBot, spacing=12), 449 Spawn(BomberBot, spacing=6), 450 Spawn(BomberBot, spacing=6) 451 if self._preset is Preset.PRO 452 else None, 453 Spawn(BomberBot, spacing=6) 454 if player_count > 1 455 else None, 456 Spawn(BrawlerBot, spacing=12), 457 Spawn(BrawlerBot, spacing=12) 458 if player_count > 2 459 else None, 460 ], 461 ), 462 Wave( 463 base_angle=180, 464 entries=[ 465 Spawn(BrawlerBot, spacing=6) 466 if player_count > 3 467 else None, 468 Spawn(BrawlerBot, spacing=6) 469 if self._preset is Preset.PRO 470 else None, 471 Spawn(BrawlerBot, spacing=6), 472 Spawn(ChargerBot, spacing=45), 473 Spawn(ChargerBot, spacing=45) 474 if player_count > 1 475 else None, 476 Spawn(BrawlerBot, spacing=6), 477 Spawn(BrawlerBot, spacing=6) 478 if self._preset is Preset.PRO 479 else None, 480 Spawn(BrawlerBot, spacing=6) 481 if player_count > 2 482 else None, 483 ], 484 ), 485 Wave( 486 base_angle=0, 487 entries=[ 488 Spawn(ChargerBot, spacing=30), 489 Spawn(TriggerBot, spacing=30), 490 Spawn(TriggerBot, spacing=30), 491 Spawn(TriggerBot, spacing=30) 492 if self._preset is Preset.PRO 493 else None, 494 Spawn(TriggerBot, spacing=30) 495 if player_count > 1 496 else None, 497 Spawn(TriggerBot, spacing=30) 498 if player_count > 3 499 else None, 500 Spawn(ChargerBot, spacing=30), 501 ], 502 ), 503 Wave( 504 base_angle=90, 505 entries=[ 506 Spawn(StickyBot, spacing=50), 507 Spawn(StickyBot, spacing=50) 508 if self._preset is Preset.PRO 509 else None, 510 Spawn(StickyBot, spacing=50), 511 Spawn(StickyBot, spacing=50) 512 if player_count > 1 513 else None, 514 Spawn(StickyBot, spacing=50) 515 if player_count > 3 516 else None, 517 ], 518 ), 519 Wave( 520 base_angle=0, 521 entries=[ 522 Spawn(TriggerBot, spacing=72), 523 Spawn(TriggerBot, spacing=72), 524 Spawn(TriggerBot, spacing=72) 525 if self._preset is Preset.PRO 526 else None, 527 Spawn(TriggerBot, spacing=72), 528 Spawn(TriggerBot, spacing=72), 529 Spawn(TriggerBot, spacing=36) 530 if player_count > 2 531 else None, 532 ], 533 ), 534 Wave( 535 base_angle=30, 536 entries=[ 537 Spawn(ChargerBotProShielded, spacing=50), 538 Spawn(ChargerBotProShielded, spacing=50), 539 Spawn(ChargerBotProShielded, spacing=50) 540 if self._preset is Preset.PRO 541 else None, 542 Spawn(ChargerBotProShielded, spacing=50) 543 if player_count > 1 544 else None, 545 Spawn(ChargerBotProShielded, spacing=50) 546 if player_count > 2 547 else None, 548 ], 549 ), 550 ] 551 552 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 553 554 # Show controls help in demo/arcade modes. 555 if ba.app.demo_mode or ba.app.arcade_mode: 556 ControlsGuide( 557 delay=3.0, lifespan=10.0, bright=True 558 ).autoretain() 559 560 self._have_tnt = True 561 self._excluded_powerups = [] 562 self._waves = [ 563 Wave( 564 entries=[ 565 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT) 566 if hard 567 else None, 568 Spawn( 569 BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT 570 ), 571 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT) 572 if player_count > 2 573 else None, 574 Spawn(ExplodeyBot, Point.TOP_RIGHT), 575 Delay(4.0), 576 Spawn(ExplodeyBot, Point.TOP_LEFT), 577 ] 578 ), 579 Wave( 580 entries=[ 581 Spawn(ChargerBot, Point.LEFT), 582 Spawn(ChargerBot, Point.RIGHT), 583 Spawn(ChargerBot, Point.RIGHT_UPPER_MORE) 584 if player_count > 2 585 else None, 586 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT), 587 Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT), 588 ] 589 ), 590 Wave( 591 entries=[ 592 Spawn(TriggerBotPro, Point.TOP_RIGHT), 593 Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE) 594 if player_count > 1 595 else None, 596 Spawn(TriggerBotPro, Point.RIGHT_UPPER), 597 Spawn(TriggerBotPro, Point.RIGHT_LOWER) 598 if hard 599 else None, 600 Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE) 601 if player_count > 2 602 else None, 603 Spawn(TriggerBotPro, Point.BOTTOM_RIGHT), 604 ] 605 ), 606 Wave( 607 entries=[ 608 Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT), 609 Spawn(ChargerBotProShielded, Point.BOTTOM) 610 if player_count > 2 611 else None, 612 Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT), 613 Spawn(ChargerBotProShielded, Point.TOP) 614 if hard 615 else None, 616 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE), 617 ] 618 ), 619 Wave( 620 entries=[ 621 Spawn(ExplodeyBot, Point.LEFT_UPPER), 622 Delay(1.0), 623 Spawn(BrawlerBotProShielded, Point.LEFT_LOWER), 624 Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE), 625 Delay(4.0), 626 Spawn(ExplodeyBot, Point.RIGHT_UPPER), 627 Delay(1.0), 628 Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER), 629 Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE), 630 Delay(4.0), 631 Spawn(ExplodeyBot, Point.LEFT), 632 Delay(5.0), 633 Spawn(ExplodeyBot, Point.RIGHT), 634 ] 635 ), 636 Wave( 637 entries=[ 638 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT), 639 Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT), 640 Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT), 641 Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT), 642 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT) 643 if hard 644 else None, 645 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT) 646 if hard 647 else None, 648 ] 649 ), 650 ] 651 652 # We generate these on the fly in endless. 653 elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 654 self._have_tnt = True 655 self._excluded_powerups = [] 656 self._waves = [] 657 658 else: 659 raise RuntimeError(f'Invalid preset: {self._preset}') 660 661 # FIXME: Should migrate to use setup_standard_powerup_drops(). 662 663 # Spit out a few powerups and start dropping more shortly. 664 self._drop_powerups( 665 standard_points=True, 666 poweruptype='curse' 667 if self._preset in [Preset.UBER, Preset.UBER_EASY] 668 else ( 669 'land_mines' 670 if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY] 671 else None 672 ), 673 ) 674 ba.timer(4.0, self._start_powerup_drops) 675 676 # Our TNT spawner (if applicable). 677 if self._have_tnt: 678 self._tntspawner = TNTSpawner(position=self._tntspawnpos) 679 680 self.setup_low_life_warning_sound() 681 self._update_scores() 682 self._bots = SpazBotSet() 683 ba.timer(4.0, self._start_updating_waves) 684 685 def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]: 686 totalpts = 0 687 totaldudes = 0 688 for grp in grps: 689 for grpentry in grp: 690 dudes = grpentry[1] 691 totalpts += grpentry[0] * dudes 692 totaldudes += dudes 693 return totalpts, totaldudes 694 695 def _get_distribution( 696 self, 697 target_points: int, 698 min_dudes: int, 699 max_dudes: int, 700 group_count: int, 701 max_level: int, 702 ) -> list[list[tuple[int, int]]]: 703 """Calculate a distribution of bad guys given some params.""" 704 max_iterations = 10 + max_dudes * 2 705 706 groups: list[list[tuple[int, int]]] = [] 707 for _g in range(group_count): 708 groups.append([]) 709 types = [1] 710 if max_level > 1: 711 types.append(2) 712 if max_level > 2: 713 types.append(3) 714 if max_level > 3: 715 types.append(4) 716 for iteration in range(max_iterations): 717 diff = self._add_dist_entry_if_possible( 718 groups, max_dudes, target_points, types 719 ) 720 721 total_points, total_dudes = self._get_dist_grp_totals(groups) 722 full = total_points >= target_points 723 724 if full: 725 # Every so often, delete a random entry just to 726 # shake up our distribution. 727 if random.random() < 0.2 and iteration != max_iterations - 1: 728 self._delete_random_dist_entry(groups) 729 730 # If we don't have enough dudes, kill the group with 731 # the biggest point value. 732 elif ( 733 total_dudes < min_dudes and iteration != max_iterations - 1 734 ): 735 self._delete_biggest_dist_entry(groups) 736 737 # If we've got too many dudes, kill the group with the 738 # smallest point value. 739 elif ( 740 total_dudes > max_dudes and iteration != max_iterations - 1 741 ): 742 self._delete_smallest_dist_entry(groups) 743 744 # Close enough.. we're done. 745 else: 746 if diff == 0: 747 break 748 749 return groups 750 751 def _add_dist_entry_if_possible( 752 self, 753 groups: list[list[tuple[int, int]]], 754 max_dudes: int, 755 target_points: int, 756 types: list[int], 757 ) -> int: 758 # See how much we're off our target by. 759 total_points, total_dudes = self._get_dist_grp_totals(groups) 760 diff = target_points - total_points 761 dudes_diff = max_dudes - total_dudes 762 763 # Add an entry if one will fit. 764 value = types[random.randrange(len(types))] 765 group = groups[random.randrange(len(groups))] 766 if not group: 767 max_count = random.randint(1, 6) 768 else: 769 max_count = 2 * random.randint(1, 3) 770 max_count = min(max_count, dudes_diff) 771 count = min(max_count, diff // value) 772 if count > 0: 773 group.append((value, count)) 774 total_points += value * count 775 total_dudes += count 776 diff = target_points - total_points 777 return diff 778 779 def _delete_smallest_dist_entry( 780 self, groups: list[list[tuple[int, int]]] 781 ) -> None: 782 smallest_value = 9999 783 smallest_entry = None 784 smallest_entry_group = None 785 for group in groups: 786 for entry in group: 787 if entry[0] < smallest_value or smallest_entry is None: 788 smallest_value = entry[0] 789 smallest_entry = entry 790 smallest_entry_group = group 791 assert smallest_entry is not None 792 assert smallest_entry_group is not None 793 smallest_entry_group.remove(smallest_entry) 794 795 def _delete_biggest_dist_entry( 796 self, groups: list[list[tuple[int, int]]] 797 ) -> None: 798 biggest_value = 9999 799 biggest_entry = None 800 biggest_entry_group = None 801 for group in groups: 802 for entry in group: 803 if entry[0] > biggest_value or biggest_entry is None: 804 biggest_value = entry[0] 805 biggest_entry = entry 806 biggest_entry_group = group 807 if biggest_entry is not None: 808 assert biggest_entry_group is not None 809 biggest_entry_group.remove(biggest_entry) 810 811 def _delete_random_dist_entry( 812 self, groups: list[list[tuple[int, int]]] 813 ) -> None: 814 entry_count = 0 815 for group in groups: 816 for _ in group: 817 entry_count += 1 818 if entry_count > 1: 819 del_entry = random.randrange(entry_count) 820 entry_count = 0 821 for group in groups: 822 for entry in group: 823 if entry_count == del_entry: 824 group.remove(entry) 825 break 826 entry_count += 1 827 828 def spawn_player(self, player: Player) -> ba.Actor: 829 830 # We keep track of who got hurt each wave for score purposes. 831 player.has_been_hurt = False 832 pos = ( 833 self._spawn_center[0] + random.uniform(-1.5, 1.5), 834 self._spawn_center[1], 835 self._spawn_center[2] + random.uniform(-1.5, 1.5), 836 ) 837 spaz = self.spawn_player_spaz(player, position=pos) 838 if self._preset in { 839 Preset.TRAINING_EASY, 840 Preset.ROOKIE_EASY, 841 Preset.PRO_EASY, 842 Preset.UBER_EASY, 843 }: 844 spaz.impact_scale = 0.25 845 spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) 846 return spaz 847 848 def _handle_player_dropped_bomb( 849 self, player: ba.Actor, bomb: ba.Actor 850 ) -> None: 851 del player, bomb # Unused. 852 self._player_has_dropped_bomb = True 853 854 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 855 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 856 forcetype=poweruptype, excludetypes=self._excluded_powerups 857 ) 858 PowerupBox( 859 position=self.map.powerup_spawn_points[index], 860 poweruptype=poweruptype, 861 ).autoretain() 862 863 def _start_powerup_drops(self) -> None: 864 self._powerup_drop_timer = ba.Timer( 865 3.0, ba.WeakCall(self._drop_powerups), repeat=True 866 ) 867 868 def _drop_powerups( 869 self, standard_points: bool = False, poweruptype: str | None = None 870 ) -> None: 871 """Generic powerup drop.""" 872 if standard_points: 873 points = self.map.powerup_spawn_points 874 for i in range(len(points)): 875 ba.timer( 876 1.0 + i * 0.5, 877 ba.WeakCall( 878 self._drop_powerup, i, poweruptype if i == 0 else None 879 ), 880 ) 881 else: 882 point = ( 883 self._powerup_center[0] 884 + random.uniform( 885 -1.0 * self._powerup_spread[0], 886 1.0 * self._powerup_spread[0], 887 ), 888 self._powerup_center[1], 889 self._powerup_center[2] 890 + random.uniform( 891 -self._powerup_spread[1], self._powerup_spread[1] 892 ), 893 ) 894 895 # Drop one random one somewhere. 896 PowerupBox( 897 position=point, 898 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 899 excludetypes=self._excluded_powerups 900 ), 901 ).autoretain() 902 903 def do_end(self, outcome: str, delay: float = 0.0) -> None: 904 """End the game with the specified outcome.""" 905 if outcome == 'defeat': 906 self.fade_to_red() 907 score: int | None 908 if self._wavenum >= 2: 909 score = self._score 910 fail_message = None 911 else: 912 score = None 913 fail_message = ba.Lstr(resource='reachWave2Text') 914 self.end( 915 { 916 'outcome': outcome, 917 'score': score, 918 'fail_message': fail_message, 919 'playerinfos': self.initialplayerinfos, 920 }, 921 delay=delay, 922 ) 923 924 def _award_completion_achievements(self) -> None: 925 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 926 self._award_achievement('Onslaught Training Victory', sound=False) 927 if not self._player_has_dropped_bomb: 928 self._award_achievement('Boxer', sound=False) 929 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 930 self._award_achievement('Rookie Onslaught Victory', sound=False) 931 if not self._a_player_has_been_hurt: 932 self._award_achievement('Flawless Victory', sound=False) 933 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 934 self._award_achievement('Pro Onslaught Victory', sound=False) 935 if not self._player_has_dropped_bomb: 936 self._award_achievement('Pro Boxer', sound=False) 937 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 938 self._award_achievement('Uber Onslaught Victory', sound=False) 939 940 def _update_waves(self) -> None: 941 942 # If we have no living bots, go to the next wave. 943 assert self._bots is not None 944 if ( 945 self._can_end_wave 946 and not self._bots.have_living_bots() 947 and not self._game_over 948 ): 949 self._can_end_wave = False 950 self._time_bonus_timer = None 951 self._time_bonus_text = None 952 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 953 won = False 954 else: 955 won = self._wavenum == len(self._waves) 956 957 base_delay = 4.0 if won else 0.0 958 959 # Reward time bonus. 960 if self._time_bonus > 0: 961 ba.timer(0, lambda: ba.playsound(self._cashregistersound)) 962 ba.timer( 963 base_delay, 964 ba.WeakCall(self._award_time_bonus, self._time_bonus), 965 ) 966 base_delay += 1.0 967 968 # Reward flawless bonus. 969 if self._wavenum > 0: 970 have_flawless = False 971 for player in self.players: 972 if player.is_alive() and not player.has_been_hurt: 973 have_flawless = True 974 ba.timer( 975 base_delay, 976 ba.WeakCall(self._award_flawless_bonus, player), 977 ) 978 player.has_been_hurt = False # reset 979 if have_flawless: 980 base_delay += 1.0 981 982 if won: 983 self.show_zoom_message( 984 ba.Lstr(resource='victoryText'), scale=1.0, duration=4.0 985 ) 986 self.celebrate(20.0) 987 self._award_completion_achievements() 988 ba.timer(base_delay, ba.WeakCall(self._award_completion_bonus)) 989 base_delay += 0.85 990 ba.playsound(self._winsound) 991 ba.cameraflash() 992 ba.setmusic(ba.MusicType.VICTORY) 993 self._game_over = True 994 995 # Can't just pass delay to do_end because our extra bonuses 996 # haven't been added yet (once we call do_end the score 997 # gets locked in). 998 ba.timer(base_delay, ba.WeakCall(self.do_end, 'victory')) 999 return 1000 1001 self._wavenum += 1 1002 1003 # Short celebration after waves. 1004 if self._wavenum > 1: 1005 self.celebrate(0.5) 1006 ba.timer(base_delay, ba.WeakCall(self._start_next_wave)) 1007 1008 def _award_completion_bonus(self) -> None: 1009 ba.playsound(self._cashregistersound) 1010 for player in self.players: 1011 try: 1012 if player.is_alive(): 1013 assert self.initialplayerinfos is not None 1014 self.stats.player_scored( 1015 player, 1016 int(100 / len(self.initialplayerinfos)), 1017 scale=1.4, 1018 color=(0.6, 0.6, 1.0, 1.0), 1019 title=ba.Lstr(resource='completionBonusText'), 1020 screenmessage=False, 1021 ) 1022 except Exception: 1023 ba.print_exception() 1024 1025 def _award_time_bonus(self, bonus: int) -> None: 1026 ba.playsound(self._cashregistersound) 1027 PopupText( 1028 ba.Lstr( 1029 value='+${A} ${B}', 1030 subs=[ 1031 ('${A}', str(bonus)), 1032 ('${B}', ba.Lstr(resource='timeBonusText')), 1033 ], 1034 ), 1035 color=(1, 1, 0.5, 1), 1036 scale=1.0, 1037 position=(0, 3, -1), 1038 ).autoretain() 1039 self._score += self._time_bonus 1040 self._update_scores() 1041 1042 def _award_flawless_bonus(self, player: Player) -> None: 1043 ba.playsound(self._cashregistersound) 1044 try: 1045 if player.is_alive(): 1046 assert self._flawless_bonus is not None 1047 self.stats.player_scored( 1048 player, 1049 self._flawless_bonus, 1050 scale=1.2, 1051 color=(0.6, 1.0, 0.6, 1.0), 1052 title=ba.Lstr(resource='flawlessWaveText'), 1053 screenmessage=False, 1054 ) 1055 except Exception: 1056 ba.print_exception() 1057 1058 def _start_time_bonus_timer(self) -> None: 1059 self._time_bonus_timer = ba.Timer( 1060 1.0, ba.WeakCall(self._update_time_bonus), repeat=True 1061 ) 1062 1063 def _update_player_spawn_info(self) -> None: 1064 1065 # If we have no living players lets just blank this. 1066 assert self._spawn_info_text is not None 1067 assert self._spawn_info_text.node 1068 if not any(player.is_alive() for player in self.teams[0].players): 1069 self._spawn_info_text.node.text = '' 1070 else: 1071 text: str | ba.Lstr = '' 1072 for player in self.players: 1073 if not player.is_alive() and ( 1074 self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1075 or (player.respawn_wave <= len(self._waves)) 1076 ): 1077 rtxt = ba.Lstr( 1078 resource='onslaughtRespawnText', 1079 subs=[ 1080 ('${PLAYER}', player.getname()), 1081 ('${WAVE}', str(player.respawn_wave)), 1082 ], 1083 ) 1084 text = ba.Lstr( 1085 value='${A}${B}\n', 1086 subs=[ 1087 ('${A}', text), 1088 ('${B}', rtxt), 1089 ], 1090 ) 1091 self._spawn_info_text.node.text = text 1092 1093 def _respawn_players_for_wave(self) -> None: 1094 # Respawn applicable players. 1095 if self._wavenum > 1 and not self.is_waiting_for_continue(): 1096 for player in self.players: 1097 if ( 1098 not player.is_alive() 1099 and player.respawn_wave == self._wavenum 1100 ): 1101 self.spawn_player(player) 1102 self._update_player_spawn_info() 1103 1104 def _setup_wave_spawns(self, wave: Wave) -> None: 1105 tval = 0.0 1106 dtime = 0.2 1107 if self._wavenum == 1: 1108 spawn_time = 3.973 1109 tval += 0.5 1110 else: 1111 spawn_time = 2.648 1112 1113 bot_angle = wave.base_angle 1114 self._time_bonus = 0 1115 self._flawless_bonus = 0 1116 for info in wave.entries: 1117 if info is None: 1118 continue 1119 if isinstance(info, Delay): 1120 spawn_time += info.duration 1121 continue 1122 if isinstance(info, Spacing): 1123 bot_angle += info.spacing 1124 continue 1125 bot_type_2 = info.bottype 1126 if bot_type_2 is not None: 1127 assert not isinstance(bot_type_2, str) 1128 self._time_bonus += bot_type_2.points_mult * 20 1129 self._flawless_bonus += bot_type_2.points_mult * 5 1130 1131 # If its got a position, use that. 1132 point = info.point 1133 if point is not None: 1134 assert bot_type_2 is not None 1135 spcall = ba.WeakCall( 1136 self.add_bot_at_point, point, bot_type_2, spawn_time 1137 ) 1138 ba.timer(tval, spcall) 1139 tval += dtime 1140 else: 1141 spacing = info.spacing 1142 bot_angle += spacing * 0.5 1143 if bot_type_2 is not None: 1144 tcall = ba.WeakCall( 1145 self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time 1146 ) 1147 ba.timer(tval, tcall) 1148 tval += dtime 1149 bot_angle += spacing * 0.5 1150 1151 # We can end the wave after all the spawning happens. 1152 ba.timer( 1153 tval + spawn_time - dtime + 0.01, 1154 ba.WeakCall(self._set_can_end_wave), 1155 ) 1156 1157 def _start_next_wave(self) -> None: 1158 1159 # This can happen if we beat a wave as we die. 1160 # We don't wanna respawn players and whatnot if this happens. 1161 if self._game_over: 1162 return 1163 1164 self._respawn_players_for_wave() 1165 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 1166 wave = self._generate_random_wave() 1167 else: 1168 wave = self._waves[self._wavenum - 1] 1169 self._setup_wave_spawns(wave) 1170 self._update_wave_ui_and_bonuses() 1171 ba.timer(0.4, ba.Call(ba.playsound, self._new_wave_sound)) 1172 1173 def _update_wave_ui_and_bonuses(self) -> None: 1174 1175 self.show_zoom_message( 1176 ba.Lstr( 1177 value='${A} ${B}', 1178 subs=[ 1179 ('${A}', ba.Lstr(resource='waveText')), 1180 ('${B}', str(self._wavenum)), 1181 ], 1182 ), 1183 scale=1.0, 1184 duration=1.0, 1185 trail=True, 1186 ) 1187 1188 # Reset our time bonus. 1189 tbtcolor = (1, 1, 0, 1) 1190 tbttxt = ba.Lstr( 1191 value='${A}: ${B}', 1192 subs=[ 1193 ('${A}', ba.Lstr(resource='timeBonusText')), 1194 ('${B}', str(self._time_bonus)), 1195 ], 1196 ) 1197 self._time_bonus_text = ba.NodeActor( 1198 ba.newnode( 1199 'text', 1200 attrs={ 1201 'v_attach': 'top', 1202 'h_attach': 'center', 1203 'h_align': 'center', 1204 'vr_depth': -30, 1205 'color': tbtcolor, 1206 'shadow': 1.0, 1207 'flatness': 1.0, 1208 'position': (0, -60), 1209 'scale': 0.8, 1210 'text': tbttxt, 1211 }, 1212 ) 1213 ) 1214 1215 ba.timer(5.0, ba.WeakCall(self._start_time_bonus_timer)) 1216 wtcolor = (1, 1, 1, 1) 1217 wttxt = ba.Lstr( 1218 value='${A} ${B}', 1219 subs=[ 1220 ('${A}', ba.Lstr(resource='waveText')), 1221 ( 1222 '${B}', 1223 str(self._wavenum) 1224 + ( 1225 '' 1226 if self._preset 1227 in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1228 else ('/' + str(len(self._waves))) 1229 ), 1230 ), 1231 ], 1232 ) 1233 self._wave_text = ba.NodeActor( 1234 ba.newnode( 1235 'text', 1236 attrs={ 1237 'v_attach': 'top', 1238 'h_attach': 'center', 1239 'h_align': 'center', 1240 'vr_depth': -10, 1241 'color': wtcolor, 1242 'shadow': 1.0, 1243 'flatness': 1.0, 1244 'position': (0, -40), 1245 'scale': 1.3, 1246 'text': wttxt, 1247 }, 1248 ) 1249 ) 1250 1251 def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]: 1252 level = self._wavenum 1253 bot_types = [ 1254 BomberBot, 1255 BrawlerBot, 1256 TriggerBot, 1257 ChargerBot, 1258 BomberBotPro, 1259 BrawlerBotPro, 1260 TriggerBotPro, 1261 BomberBotProShielded, 1262 ExplodeyBot, 1263 ChargerBotProShielded, 1264 StickyBot, 1265 BrawlerBotProShielded, 1266 TriggerBotProShielded, 1267 ] 1268 if level > 5: 1269 bot_types += [ 1270 ExplodeyBot, 1271 TriggerBotProShielded, 1272 BrawlerBotProShielded, 1273 ChargerBotProShielded, 1274 ] 1275 if level > 7: 1276 bot_types += [ 1277 ExplodeyBot, 1278 TriggerBotProShielded, 1279 BrawlerBotProShielded, 1280 ChargerBotProShielded, 1281 ] 1282 if level > 10: 1283 bot_types += [ 1284 TriggerBotProShielded, 1285 TriggerBotProShielded, 1286 TriggerBotProShielded, 1287 TriggerBotProShielded, 1288 ] 1289 if level > 13: 1290 bot_types += [ 1291 TriggerBotProShielded, 1292 TriggerBotProShielded, 1293 TriggerBotProShielded, 1294 TriggerBotProShielded, 1295 ] 1296 bot_levels = [ 1297 [b for b in bot_types if b.points_mult == 1], 1298 [b for b in bot_types if b.points_mult == 2], 1299 [b for b in bot_types if b.points_mult == 3], 1300 [b for b in bot_types if b.points_mult == 4], 1301 ] 1302 1303 # Make sure all lists have something in them 1304 if not all(bot_levels): 1305 raise RuntimeError('Got empty bot level') 1306 return bot_levels 1307 1308 def _add_entries_for_distribution_group( 1309 self, 1310 group: list[tuple[int, int]], 1311 bot_levels: list[list[type[SpazBot]]], 1312 all_entries: list[Spawn | Spacing | Delay | None], 1313 ) -> None: 1314 entries: list[Spawn | Spacing | Delay | None] = [] 1315 for entry in group: 1316 bot_level = bot_levels[entry[0] - 1] 1317 bot_type = bot_level[random.randrange(len(bot_level))] 1318 rval = random.random() 1319 if rval < 0.5: 1320 spacing = 10.0 1321 elif rval < 0.9: 1322 spacing = 20.0 1323 else: 1324 spacing = 40.0 1325 split = random.random() > 0.3 1326 for i in range(entry[1]): 1327 if split and i % 2 == 0: 1328 entries.insert(0, Spawn(bot_type, spacing=spacing)) 1329 else: 1330 entries.append(Spawn(bot_type, spacing=spacing)) 1331 if entries: 1332 all_entries += entries 1333 all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0)) 1334 1335 def _generate_random_wave(self) -> Wave: 1336 level = self._wavenum 1337 bot_levels = self._bot_levels_for_wave() 1338 1339 target_points = level * 3 - 2 1340 min_dudes = min(1 + level // 3, 10) 1341 max_dudes = min(10, level + 1) 1342 max_level = ( 1343 4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1)) 1344 ) 1345 group_count = 3 1346 distribution = self._get_distribution( 1347 target_points, min_dudes, max_dudes, group_count, max_level 1348 ) 1349 all_entries: list[Spawn | Spacing | Delay | None] = [] 1350 for group in distribution: 1351 self._add_entries_for_distribution_group( 1352 group, bot_levels, all_entries 1353 ) 1354 angle_rand = random.random() 1355 if angle_rand > 0.75: 1356 base_angle = 130.0 1357 elif angle_rand > 0.5: 1358 base_angle = 210.0 1359 elif angle_rand > 0.25: 1360 base_angle = 20.0 1361 else: 1362 base_angle = -30.0 1363 base_angle += (0.5 - random.random()) * 20.0 1364 wave = Wave(base_angle=base_angle, entries=all_entries) 1365 return wave 1366 1367 def add_bot_at_point( 1368 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1369 ) -> None: 1370 """Add a new bot at a specified named point.""" 1371 if self._game_over: 1372 return 1373 assert isinstance(point.value, str) 1374 pointpos = self.map.defs.points[point.value] 1375 assert self._bots is not None 1376 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time) 1377 1378 def add_bot_at_angle( 1379 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1380 ) -> None: 1381 """Add a new bot at a specified angle (for circular maps).""" 1382 if self._game_over: 1383 return 1384 angle_radians = angle / 57.2957795 1385 xval = math.sin(angle_radians) * 1.06 1386 zval = math.cos(angle_radians) * 1.06 1387 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1388 assert self._bots is not None 1389 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time) 1390 1391 def _update_time_bonus(self) -> None: 1392 self._time_bonus = int(self._time_bonus * 0.93) 1393 if self._time_bonus > 0 and self._time_bonus_text is not None: 1394 assert self._time_bonus_text.node 1395 self._time_bonus_text.node.text = ba.Lstr( 1396 value='${A}: ${B}', 1397 subs=[ 1398 ('${A}', ba.Lstr(resource='timeBonusText')), 1399 ('${B}', str(self._time_bonus)), 1400 ], 1401 ) 1402 else: 1403 self._time_bonus_text = None 1404 1405 def _start_updating_waves(self) -> None: 1406 self._wave_update_timer = ba.Timer( 1407 2.0, ba.WeakCall(self._update_waves), repeat=True 1408 ) 1409 1410 def _update_scores(self) -> None: 1411 score = self._score 1412 if self._preset is Preset.ENDLESS: 1413 if score >= 500: 1414 self._award_achievement('Onslaught Master') 1415 if score >= 1000: 1416 self._award_achievement('Onslaught Wizard') 1417 if score >= 5000: 1418 self._award_achievement('Onslaught God') 1419 assert self._scoreboard is not None 1420 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1421 1422 def handlemessage(self, msg: Any) -> Any: 1423 1424 if isinstance(msg, PlayerSpazHurtMessage): 1425 msg.spaz.getplayer(Player, True).has_been_hurt = True 1426 self._a_player_has_been_hurt = True 1427 1428 elif isinstance(msg, ba.PlayerScoredMessage): 1429 self._score += msg.score 1430 self._update_scores() 1431 1432 elif isinstance(msg, ba.PlayerDiedMessage): 1433 super().handlemessage(msg) # Augment standard behavior. 1434 player = msg.getplayer(Player) 1435 self._a_player_has_been_hurt = True 1436 1437 # Make note with the player when they can respawn: 1438 if self._wavenum < 10: 1439 player.respawn_wave = max(2, self._wavenum + 1) 1440 elif self._wavenum < 15: 1441 player.respawn_wave = max(2, self._wavenum + 2) 1442 else: 1443 player.respawn_wave = max(2, self._wavenum + 3) 1444 ba.timer(0.1, self._update_player_spawn_info) 1445 ba.timer(0.1, self._checkroundover) 1446 1447 elif isinstance(msg, SpazBotDiedMessage): 1448 pts, importance = msg.spazbot.get_death_points(msg.how) 1449 if msg.killerplayer is not None: 1450 self._handle_kill_achievements(msg) 1451 target: Sequence[float] | None 1452 if msg.spazbot.node: 1453 target = msg.spazbot.node.position 1454 else: 1455 target = None 1456 1457 killerplayer = msg.killerplayer 1458 self.stats.player_scored( 1459 killerplayer, 1460 pts, 1461 target=target, 1462 kill=True, 1463 screenmessage=False, 1464 importance=importance, 1465 ) 1466 ba.playsound( 1467 self._dingsound if importance == 1 else self._dingsoundhigh, 1468 volume=0.6, 1469 ) 1470 1471 # Normally we pull scores from the score-set, but if there's 1472 # no player lets be explicit. 1473 else: 1474 self._score += pts 1475 self._update_scores() 1476 else: 1477 super().handlemessage(msg) 1478 1479 def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1480 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 1481 self._handle_training_kill_achievements(msg) 1482 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 1483 self._handle_rookie_kill_achievements(msg) 1484 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 1485 self._handle_pro_kill_achievements(msg) 1486 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 1487 self._handle_uber_kill_achievements(msg) 1488 1489 def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1490 1491 # Uber mine achievement: 1492 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1493 self._land_mine_kills += 1 1494 if self._land_mine_kills >= 6: 1495 self._award_achievement('Gold Miner') 1496 1497 # Uber tnt achievement: 1498 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1499 self._tnt_kills += 1 1500 if self._tnt_kills >= 6: 1501 ba.timer( 1502 0.5, ba.WeakCall(self._award_achievement, 'TNT Terror') 1503 ) 1504 1505 def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1506 1507 # TNT achievement: 1508 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1509 self._tnt_kills += 1 1510 if self._tnt_kills >= 3: 1511 ba.timer( 1512 0.5, 1513 ba.WeakCall( 1514 self._award_achievement, 'Boom Goes the Dynamite' 1515 ), 1516 ) 1517 1518 def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1519 # Land-mine achievement: 1520 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1521 self._land_mine_kills += 1 1522 if self._land_mine_kills >= 3: 1523 self._award_achievement('Mine Games') 1524 1525 def _handle_training_kill_achievements( 1526 self, msg: SpazBotDiedMessage 1527 ) -> None: 1528 # Toss-off-map achievement: 1529 if msg.spazbot.last_attacked_type == ('picked_up', 'default'): 1530 self._throw_off_kills += 1 1531 if self._throw_off_kills >= 3: 1532 self._award_achievement('Off You Go Then') 1533 1534 def _set_can_end_wave(self) -> None: 1535 self._can_end_wave = True 1536 1537 def end_game(self) -> None: 1538 # Tell our bots to celebrate just to rub it in. 1539 assert self._bots is not None 1540 self._bots.final_celebrate() 1541 self._game_over = True 1542 self.do_end('defeat', delay=2.0) 1543 ba.setmusic(None) 1544 1545 def on_continue(self) -> None: 1546 for player in self.players: 1547 if not player.is_alive(): 1548 self.spawn_player(player) 1549 1550 def _checkroundover(self) -> None: 1551 """Potentially end the round based on the state of the game.""" 1552 if self.has_ended(): 1553 return 1554 if not any(player.is_alive() for player in self.teams[0].players): 1555 # Allow continuing after wave 1. 1556 if self._wavenum > 1: 1557 self.continue_or_end_game() 1558 else: 1559 self.end_game()
55@dataclass 56class Wave: 57 """A wave of enemies.""" 58 59 entries: list[Spawn | Spacing | Delay | None] 60 base_angle: float = 0.0
A wave of enemies.
63@dataclass 64class Spawn: 65 """A bot spawn event in a wave.""" 66 67 bottype: type[SpazBot] | str 68 point: Point | None = None 69 spacing: float = 5.0
A bot spawn event in a wave.
Empty space in a wave.
A delay between events in a wave.
86class Preset(Enum): 87 """Game presets we support.""" 88 89 TRAINING = 'training' 90 TRAINING_EASY = 'training_easy' 91 ROOKIE = 'rookie' 92 ROOKIE_EASY = 'rookie_easy' 93 PRO = 'pro' 94 PRO_EASY = 'pro_easy' 95 UBER = 'uber' 96 UBER_EASY = 'uber_easy' 97 ENDLESS = 'endless' 98 ENDLESS_TOURNAMENT = 'endless_tournament'
Game presets we support.
Inherited Members
- enum.Enum
- name
- value
101@unique 102class Point(Enum): 103 """Points on the map we can spawn at.""" 104 105 LEFT_UPPER_MORE = 'bot_spawn_left_upper_more' 106 LEFT_UPPER = 'bot_spawn_left_upper' 107 TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right' 108 RIGHT_UPPER = 'bot_spawn_right_upper' 109 TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left' 110 TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right' 111 TURRET_TOP_LEFT = 'bot_spawn_turret_top_left' 112 TOP_RIGHT = 'bot_spawn_top_right' 113 TOP_LEFT = 'bot_spawn_top_left' 114 TOP = 'bot_spawn_top' 115 BOTTOM = 'bot_spawn_bottom' 116 LEFT = 'bot_spawn_left' 117 RIGHT = 'bot_spawn_right' 118 RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more' 119 RIGHT_LOWER = 'bot_spawn_right_lower' 120 RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more' 121 BOTTOM_RIGHT = 'bot_spawn_bottom_right' 122 BOTTOM_LEFT = 'bot_spawn_bottom_left' 123 TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right' 124 TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left' 125 LEFT_LOWER = 'bot_spawn_left_lower' 126 LEFT_LOWER_MORE = 'bot_spawn_left_lower_more' 127 TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle' 128 BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right' 129 BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left' 130 TOP_HALF_RIGHT = 'bot_spawn_top_half_right' 131 TOP_HALF_LEFT = 'bot_spawn_top_half_left'
Points on the map we can spawn at.
Inherited Members
- enum.Enum
- name
- value
134class Player(ba.Player['Team']): 135 """Our player type for this game.""" 136 137 def __init__(self) -> None: 138 self.has_been_hurt = False 139 self.respawn_wave = 0
Our player type for this game.
Inherited Members
- ba._player.Player
- actor
- on_expire
- team
- customdata
- sessionplayer
- node
- position
- exists
- getname
- is_alive
- get_icon
- assigninput
- resetinput
Our team type for this game.
Inherited Members
- ba._team.Team
- manual_init
- customdata
- on_expire
- sessionteam
146class OnslaughtGame(ba.CoopGameActivity[Player, Team]): 147 """Co-op game where players try to survive attacking waves of enemies.""" 148 149 name = 'Onslaught' 150 description = 'Defeat all enemies.' 151 152 tips: list[str | ba.GameTip] = [ 153 'Hold any button to run.' 154 ' (Trigger buttons work well if you have them)', 155 'Try tricking enemies into killing eachother or running off cliffs.', 156 'Try \'Cooking off\' bombs for a second or two before throwing them.', 157 'It\'s easier to win with a friend or two helping.', 158 'If you stay in one place, you\'re toast. Run and dodge to survive..', 159 'Practice using your momentum to throw bombs more accurately.', 160 'Your punches do much more damage if you are running or spinning.', 161 ] 162 163 # Show messages when players die since it matters here. 164 announce_player_deaths = True 165 166 def __init__(self, settings: dict): 167 168 self._preset = Preset(settings.get('preset', 'training')) 169 if self._preset in { 170 Preset.TRAINING, 171 Preset.TRAINING_EASY, 172 Preset.PRO, 173 Preset.PRO_EASY, 174 Preset.ENDLESS, 175 Preset.ENDLESS_TOURNAMENT, 176 }: 177 settings['map'] = 'Doom Shroom' 178 else: 179 settings['map'] = 'Courtyard' 180 181 super().__init__(settings) 182 183 self._new_wave_sound = ba.getsound('scoreHit01') 184 self._winsound = ba.getsound('score') 185 self._cashregistersound = ba.getsound('cashRegister') 186 self._a_player_has_been_hurt = False 187 self._player_has_dropped_bomb = False 188 189 # FIXME: should use standard map defs. 190 if settings['map'] == 'Doom Shroom': 191 self._spawn_center = (0, 3, -5) 192 self._tntspawnpos = (0.0, 3.0, -5.0) 193 self._powerup_center = (0, 5, -3.6) 194 self._powerup_spread = (6.0, 4.0) 195 elif settings['map'] == 'Courtyard': 196 self._spawn_center = (0, 3, -2) 197 self._tntspawnpos = (0.0, 3.0, 2.1) 198 self._powerup_center = (0, 5, -1.6) 199 self._powerup_spread = (4.6, 2.7) 200 else: 201 raise Exception('Unsupported map: ' + str(settings['map'])) 202 self._scoreboard: Scoreboard | None = None 203 self._game_over = False 204 self._wavenum = 0 205 self._can_end_wave = True 206 self._score = 0 207 self._time_bonus = 0 208 self._spawn_info_text: ba.NodeActor | None = None 209 self._dingsound = ba.getsound('dingSmall') 210 self._dingsoundhigh = ba.getsound('dingSmallHigh') 211 self._have_tnt = False 212 self._excluded_powerups: list[str] | None = None 213 self._waves: list[Wave] = [] 214 self._tntspawner: TNTSpawner | None = None 215 self._bots: SpazBotSet | None = None 216 self._powerup_drop_timer: ba.Timer | None = None 217 self._time_bonus_timer: ba.Timer | None = None 218 self._time_bonus_text: ba.NodeActor | None = None 219 self._flawless_bonus: int | None = None 220 self._wave_text: ba.NodeActor | None = None 221 self._wave_update_timer: ba.Timer | None = None 222 self._throw_off_kills = 0 223 self._land_mine_kills = 0 224 self._tnt_kills = 0 225 226 def on_transition_in(self) -> None: 227 super().on_transition_in() 228 customdata = ba.getsession().customdata 229 230 # Show special landmine tip on rookie preset. 231 if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 232 # Show once per session only (then we revert to regular tips). 233 if not customdata.get('_showed_onslaught_landmine_tip', False): 234 customdata['_showed_onslaught_landmine_tip'] = True 235 self.tips = [ 236 ba.GameTip( 237 'Land-mines are a good way to stop speedy enemies.', 238 icon=ba.gettexture('powerupLandMines'), 239 sound=ba.getsound('ding'), 240 ) 241 ] 242 243 # Show special tnt tip on pro preset. 244 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 245 # Show once per session only (then we revert to regular tips). 246 if not customdata.get('_showed_onslaught_tnt_tip', False): 247 customdata['_showed_onslaught_tnt_tip'] = True 248 self.tips = [ 249 ba.GameTip( 250 'Take out a group of enemies by\n' 251 'setting off a bomb near a TNT box.', 252 icon=ba.gettexture('tnt'), 253 sound=ba.getsound('ding'), 254 ) 255 ] 256 257 # Show special curse tip on uber preset. 258 if self._preset in {Preset.UBER, Preset.UBER_EASY}: 259 # Show once per session only (then we revert to regular tips). 260 if not customdata.get('_showed_onslaught_curse_tip', False): 261 customdata['_showed_onslaught_curse_tip'] = True 262 self.tips = [ 263 ba.GameTip( 264 'Curse boxes turn you into a ticking time bomb.\n' 265 'The only cure is to quickly grab a health-pack.', 266 icon=ba.gettexture('powerupCurse'), 267 sound=ba.getsound('ding'), 268 ) 269 ] 270 271 self._spawn_info_text = ba.NodeActor( 272 ba.newnode( 273 'text', 274 attrs={ 275 'position': (15, -130), 276 'h_attach': 'left', 277 'v_attach': 'top', 278 'scale': 0.55, 279 'color': (0.3, 0.8, 0.3, 1.0), 280 'text': '', 281 }, 282 ) 283 ) 284 ba.setmusic(ba.MusicType.ONSLAUGHT) 285 286 self._scoreboard = Scoreboard( 287 label=ba.Lstr(resource='scoreText'), score_split=0.5 288 ) 289 290 def on_begin(self) -> None: 291 super().on_begin() 292 player_count = len(self.players) 293 hard = self._preset not in { 294 Preset.TRAINING_EASY, 295 Preset.ROOKIE_EASY, 296 Preset.PRO_EASY, 297 Preset.UBER_EASY, 298 } 299 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 300 ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain() 301 302 self._have_tnt = False 303 self._excluded_powerups = ['curse', 'land_mines'] 304 self._waves = [ 305 Wave( 306 base_angle=195, 307 entries=[ 308 Spawn(BomberBotLite, spacing=5), 309 ] 310 * player_count, 311 ), 312 Wave( 313 base_angle=130, 314 entries=[ 315 Spawn(BrawlerBotLite, spacing=5), 316 ] 317 * player_count, 318 ), 319 Wave( 320 base_angle=195, 321 entries=[Spawn(BomberBotLite, spacing=10)] 322 * (player_count + 1), 323 ), 324 Wave( 325 base_angle=130, 326 entries=[ 327 Spawn(BrawlerBotLite, spacing=10), 328 ] 329 * (player_count + 1), 330 ), 331 Wave( 332 base_angle=130, 333 entries=[ 334 Spawn(BrawlerBotLite, spacing=5) 335 if player_count > 1 336 else None, 337 Spawn(BrawlerBotLite, spacing=5), 338 Spacing(30), 339 Spawn(BomberBotLite, spacing=5) 340 if player_count > 3 341 else None, 342 Spawn(BomberBotLite, spacing=5), 343 Spacing(30), 344 Spawn(BrawlerBotLite, spacing=5), 345 Spawn(BrawlerBotLite, spacing=5) 346 if player_count > 2 347 else None, 348 ], 349 ), 350 Wave( 351 base_angle=195, 352 entries=[ 353 Spawn(TriggerBot, spacing=90), 354 Spawn(TriggerBot, spacing=90) 355 if player_count > 1 356 else None, 357 ], 358 ), 359 ] 360 361 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 362 self._have_tnt = False 363 self._excluded_powerups = ['curse'] 364 self._waves = [ 365 Wave( 366 entries=[ 367 Spawn(ChargerBot, Point.LEFT_UPPER_MORE) 368 if player_count > 2 369 else None, 370 Spawn(ChargerBot, Point.LEFT_UPPER), 371 ] 372 ), 373 Wave( 374 entries=[ 375 Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT), 376 Spawn(BrawlerBotLite, Point.RIGHT_UPPER), 377 Spawn(BrawlerBotLite, Point.RIGHT_LOWER) 378 if player_count > 1 379 else None, 380 Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT) 381 if player_count > 2 382 else None, 383 ] 384 ), 385 Wave( 386 entries=[ 387 Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT), 388 Spawn(TriggerBot, Point.LEFT), 389 Spawn(TriggerBot, Point.LEFT_LOWER) 390 if player_count > 1 391 else None, 392 Spawn(TriggerBot, Point.LEFT_UPPER) 393 if player_count > 2 394 else None, 395 ] 396 ), 397 Wave( 398 entries=[ 399 Spawn(BrawlerBotLite, Point.TOP_RIGHT), 400 Spawn(BrawlerBot, Point.TOP_HALF_RIGHT) 401 if player_count > 1 402 else None, 403 Spawn(BrawlerBotLite, Point.TOP_LEFT), 404 Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT) 405 if player_count > 2 406 else None, 407 Spawn(BrawlerBot, Point.TOP), 408 Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE), 409 ] 410 ), 411 Wave( 412 entries=[ 413 Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT), 414 Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT), 415 Spawn(TriggerBot, Point.BOTTOM), 416 Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT) 417 if player_count > 1 418 else None, 419 Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT) 420 if player_count > 2 421 else None, 422 ] 423 ), 424 Wave( 425 entries=[ 426 Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT), 427 Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT), 428 Spawn(ChargerBot, Point.BOTTOM), 429 Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT) 430 if player_count > 1 431 else None, 432 Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT) 433 if player_count > 2 434 else None, 435 ] 436 ), 437 ] 438 439 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 440 self._excluded_powerups = ['curse'] 441 self._have_tnt = True 442 self._waves = [ 443 Wave( 444 base_angle=-50, 445 entries=[ 446 Spawn(BrawlerBot, spacing=12) 447 if player_count > 3 448 else None, 449 Spawn(BrawlerBot, spacing=12), 450 Spawn(BomberBot, spacing=6), 451 Spawn(BomberBot, spacing=6) 452 if self._preset is Preset.PRO 453 else None, 454 Spawn(BomberBot, spacing=6) 455 if player_count > 1 456 else None, 457 Spawn(BrawlerBot, spacing=12), 458 Spawn(BrawlerBot, spacing=12) 459 if player_count > 2 460 else None, 461 ], 462 ), 463 Wave( 464 base_angle=180, 465 entries=[ 466 Spawn(BrawlerBot, spacing=6) 467 if player_count > 3 468 else None, 469 Spawn(BrawlerBot, spacing=6) 470 if self._preset is Preset.PRO 471 else None, 472 Spawn(BrawlerBot, spacing=6), 473 Spawn(ChargerBot, spacing=45), 474 Spawn(ChargerBot, spacing=45) 475 if player_count > 1 476 else None, 477 Spawn(BrawlerBot, spacing=6), 478 Spawn(BrawlerBot, spacing=6) 479 if self._preset is Preset.PRO 480 else None, 481 Spawn(BrawlerBot, spacing=6) 482 if player_count > 2 483 else None, 484 ], 485 ), 486 Wave( 487 base_angle=0, 488 entries=[ 489 Spawn(ChargerBot, spacing=30), 490 Spawn(TriggerBot, spacing=30), 491 Spawn(TriggerBot, spacing=30), 492 Spawn(TriggerBot, spacing=30) 493 if self._preset is Preset.PRO 494 else None, 495 Spawn(TriggerBot, spacing=30) 496 if player_count > 1 497 else None, 498 Spawn(TriggerBot, spacing=30) 499 if player_count > 3 500 else None, 501 Spawn(ChargerBot, spacing=30), 502 ], 503 ), 504 Wave( 505 base_angle=90, 506 entries=[ 507 Spawn(StickyBot, spacing=50), 508 Spawn(StickyBot, spacing=50) 509 if self._preset is Preset.PRO 510 else None, 511 Spawn(StickyBot, spacing=50), 512 Spawn(StickyBot, spacing=50) 513 if player_count > 1 514 else None, 515 Spawn(StickyBot, spacing=50) 516 if player_count > 3 517 else None, 518 ], 519 ), 520 Wave( 521 base_angle=0, 522 entries=[ 523 Spawn(TriggerBot, spacing=72), 524 Spawn(TriggerBot, spacing=72), 525 Spawn(TriggerBot, spacing=72) 526 if self._preset is Preset.PRO 527 else None, 528 Spawn(TriggerBot, spacing=72), 529 Spawn(TriggerBot, spacing=72), 530 Spawn(TriggerBot, spacing=36) 531 if player_count > 2 532 else None, 533 ], 534 ), 535 Wave( 536 base_angle=30, 537 entries=[ 538 Spawn(ChargerBotProShielded, spacing=50), 539 Spawn(ChargerBotProShielded, spacing=50), 540 Spawn(ChargerBotProShielded, spacing=50) 541 if self._preset is Preset.PRO 542 else None, 543 Spawn(ChargerBotProShielded, spacing=50) 544 if player_count > 1 545 else None, 546 Spawn(ChargerBotProShielded, spacing=50) 547 if player_count > 2 548 else None, 549 ], 550 ), 551 ] 552 553 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 554 555 # Show controls help in demo/arcade modes. 556 if ba.app.demo_mode or ba.app.arcade_mode: 557 ControlsGuide( 558 delay=3.0, lifespan=10.0, bright=True 559 ).autoretain() 560 561 self._have_tnt = True 562 self._excluded_powerups = [] 563 self._waves = [ 564 Wave( 565 entries=[ 566 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT) 567 if hard 568 else None, 569 Spawn( 570 BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT 571 ), 572 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT) 573 if player_count > 2 574 else None, 575 Spawn(ExplodeyBot, Point.TOP_RIGHT), 576 Delay(4.0), 577 Spawn(ExplodeyBot, Point.TOP_LEFT), 578 ] 579 ), 580 Wave( 581 entries=[ 582 Spawn(ChargerBot, Point.LEFT), 583 Spawn(ChargerBot, Point.RIGHT), 584 Spawn(ChargerBot, Point.RIGHT_UPPER_MORE) 585 if player_count > 2 586 else None, 587 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT), 588 Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT), 589 ] 590 ), 591 Wave( 592 entries=[ 593 Spawn(TriggerBotPro, Point.TOP_RIGHT), 594 Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE) 595 if player_count > 1 596 else None, 597 Spawn(TriggerBotPro, Point.RIGHT_UPPER), 598 Spawn(TriggerBotPro, Point.RIGHT_LOWER) 599 if hard 600 else None, 601 Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE) 602 if player_count > 2 603 else None, 604 Spawn(TriggerBotPro, Point.BOTTOM_RIGHT), 605 ] 606 ), 607 Wave( 608 entries=[ 609 Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT), 610 Spawn(ChargerBotProShielded, Point.BOTTOM) 611 if player_count > 2 612 else None, 613 Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT), 614 Spawn(ChargerBotProShielded, Point.TOP) 615 if hard 616 else None, 617 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE), 618 ] 619 ), 620 Wave( 621 entries=[ 622 Spawn(ExplodeyBot, Point.LEFT_UPPER), 623 Delay(1.0), 624 Spawn(BrawlerBotProShielded, Point.LEFT_LOWER), 625 Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE), 626 Delay(4.0), 627 Spawn(ExplodeyBot, Point.RIGHT_UPPER), 628 Delay(1.0), 629 Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER), 630 Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE), 631 Delay(4.0), 632 Spawn(ExplodeyBot, Point.LEFT), 633 Delay(5.0), 634 Spawn(ExplodeyBot, Point.RIGHT), 635 ] 636 ), 637 Wave( 638 entries=[ 639 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT), 640 Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT), 641 Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT), 642 Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT), 643 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT) 644 if hard 645 else None, 646 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT) 647 if hard 648 else None, 649 ] 650 ), 651 ] 652 653 # We generate these on the fly in endless. 654 elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 655 self._have_tnt = True 656 self._excluded_powerups = [] 657 self._waves = [] 658 659 else: 660 raise RuntimeError(f'Invalid preset: {self._preset}') 661 662 # FIXME: Should migrate to use setup_standard_powerup_drops(). 663 664 # Spit out a few powerups and start dropping more shortly. 665 self._drop_powerups( 666 standard_points=True, 667 poweruptype='curse' 668 if self._preset in [Preset.UBER, Preset.UBER_EASY] 669 else ( 670 'land_mines' 671 if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY] 672 else None 673 ), 674 ) 675 ba.timer(4.0, self._start_powerup_drops) 676 677 # Our TNT spawner (if applicable). 678 if self._have_tnt: 679 self._tntspawner = TNTSpawner(position=self._tntspawnpos) 680 681 self.setup_low_life_warning_sound() 682 self._update_scores() 683 self._bots = SpazBotSet() 684 ba.timer(4.0, self._start_updating_waves) 685 686 def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]: 687 totalpts = 0 688 totaldudes = 0 689 for grp in grps: 690 for grpentry in grp: 691 dudes = grpentry[1] 692 totalpts += grpentry[0] * dudes 693 totaldudes += dudes 694 return totalpts, totaldudes 695 696 def _get_distribution( 697 self, 698 target_points: int, 699 min_dudes: int, 700 max_dudes: int, 701 group_count: int, 702 max_level: int, 703 ) -> list[list[tuple[int, int]]]: 704 """Calculate a distribution of bad guys given some params.""" 705 max_iterations = 10 + max_dudes * 2 706 707 groups: list[list[tuple[int, int]]] = [] 708 for _g in range(group_count): 709 groups.append([]) 710 types = [1] 711 if max_level > 1: 712 types.append(2) 713 if max_level > 2: 714 types.append(3) 715 if max_level > 3: 716 types.append(4) 717 for iteration in range(max_iterations): 718 diff = self._add_dist_entry_if_possible( 719 groups, max_dudes, target_points, types 720 ) 721 722 total_points, total_dudes = self._get_dist_grp_totals(groups) 723 full = total_points >= target_points 724 725 if full: 726 # Every so often, delete a random entry just to 727 # shake up our distribution. 728 if random.random() < 0.2 and iteration != max_iterations - 1: 729 self._delete_random_dist_entry(groups) 730 731 # If we don't have enough dudes, kill the group with 732 # the biggest point value. 733 elif ( 734 total_dudes < min_dudes and iteration != max_iterations - 1 735 ): 736 self._delete_biggest_dist_entry(groups) 737 738 # If we've got too many dudes, kill the group with the 739 # smallest point value. 740 elif ( 741 total_dudes > max_dudes and iteration != max_iterations - 1 742 ): 743 self._delete_smallest_dist_entry(groups) 744 745 # Close enough.. we're done. 746 else: 747 if diff == 0: 748 break 749 750 return groups 751 752 def _add_dist_entry_if_possible( 753 self, 754 groups: list[list[tuple[int, int]]], 755 max_dudes: int, 756 target_points: int, 757 types: list[int], 758 ) -> int: 759 # See how much we're off our target by. 760 total_points, total_dudes = self._get_dist_grp_totals(groups) 761 diff = target_points - total_points 762 dudes_diff = max_dudes - total_dudes 763 764 # Add an entry if one will fit. 765 value = types[random.randrange(len(types))] 766 group = groups[random.randrange(len(groups))] 767 if not group: 768 max_count = random.randint(1, 6) 769 else: 770 max_count = 2 * random.randint(1, 3) 771 max_count = min(max_count, dudes_diff) 772 count = min(max_count, diff // value) 773 if count > 0: 774 group.append((value, count)) 775 total_points += value * count 776 total_dudes += count 777 diff = target_points - total_points 778 return diff 779 780 def _delete_smallest_dist_entry( 781 self, groups: list[list[tuple[int, int]]] 782 ) -> None: 783 smallest_value = 9999 784 smallest_entry = None 785 smallest_entry_group = None 786 for group in groups: 787 for entry in group: 788 if entry[0] < smallest_value or smallest_entry is None: 789 smallest_value = entry[0] 790 smallest_entry = entry 791 smallest_entry_group = group 792 assert smallest_entry is not None 793 assert smallest_entry_group is not None 794 smallest_entry_group.remove(smallest_entry) 795 796 def _delete_biggest_dist_entry( 797 self, groups: list[list[tuple[int, int]]] 798 ) -> None: 799 biggest_value = 9999 800 biggest_entry = None 801 biggest_entry_group = None 802 for group in groups: 803 for entry in group: 804 if entry[0] > biggest_value or biggest_entry is None: 805 biggest_value = entry[0] 806 biggest_entry = entry 807 biggest_entry_group = group 808 if biggest_entry is not None: 809 assert biggest_entry_group is not None 810 biggest_entry_group.remove(biggest_entry) 811 812 def _delete_random_dist_entry( 813 self, groups: list[list[tuple[int, int]]] 814 ) -> None: 815 entry_count = 0 816 for group in groups: 817 for _ in group: 818 entry_count += 1 819 if entry_count > 1: 820 del_entry = random.randrange(entry_count) 821 entry_count = 0 822 for group in groups: 823 for entry in group: 824 if entry_count == del_entry: 825 group.remove(entry) 826 break 827 entry_count += 1 828 829 def spawn_player(self, player: Player) -> ba.Actor: 830 831 # We keep track of who got hurt each wave for score purposes. 832 player.has_been_hurt = False 833 pos = ( 834 self._spawn_center[0] + random.uniform(-1.5, 1.5), 835 self._spawn_center[1], 836 self._spawn_center[2] + random.uniform(-1.5, 1.5), 837 ) 838 spaz = self.spawn_player_spaz(player, position=pos) 839 if self._preset in { 840 Preset.TRAINING_EASY, 841 Preset.ROOKIE_EASY, 842 Preset.PRO_EASY, 843 Preset.UBER_EASY, 844 }: 845 spaz.impact_scale = 0.25 846 spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) 847 return spaz 848 849 def _handle_player_dropped_bomb( 850 self, player: ba.Actor, bomb: ba.Actor 851 ) -> None: 852 del player, bomb # Unused. 853 self._player_has_dropped_bomb = True 854 855 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 856 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 857 forcetype=poweruptype, excludetypes=self._excluded_powerups 858 ) 859 PowerupBox( 860 position=self.map.powerup_spawn_points[index], 861 poweruptype=poweruptype, 862 ).autoretain() 863 864 def _start_powerup_drops(self) -> None: 865 self._powerup_drop_timer = ba.Timer( 866 3.0, ba.WeakCall(self._drop_powerups), repeat=True 867 ) 868 869 def _drop_powerups( 870 self, standard_points: bool = False, poweruptype: str | None = None 871 ) -> None: 872 """Generic powerup drop.""" 873 if standard_points: 874 points = self.map.powerup_spawn_points 875 for i in range(len(points)): 876 ba.timer( 877 1.0 + i * 0.5, 878 ba.WeakCall( 879 self._drop_powerup, i, poweruptype if i == 0 else None 880 ), 881 ) 882 else: 883 point = ( 884 self._powerup_center[0] 885 + random.uniform( 886 -1.0 * self._powerup_spread[0], 887 1.0 * self._powerup_spread[0], 888 ), 889 self._powerup_center[1], 890 self._powerup_center[2] 891 + random.uniform( 892 -self._powerup_spread[1], self._powerup_spread[1] 893 ), 894 ) 895 896 # Drop one random one somewhere. 897 PowerupBox( 898 position=point, 899 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 900 excludetypes=self._excluded_powerups 901 ), 902 ).autoretain() 903 904 def do_end(self, outcome: str, delay: float = 0.0) -> None: 905 """End the game with the specified outcome.""" 906 if outcome == 'defeat': 907 self.fade_to_red() 908 score: int | None 909 if self._wavenum >= 2: 910 score = self._score 911 fail_message = None 912 else: 913 score = None 914 fail_message = ba.Lstr(resource='reachWave2Text') 915 self.end( 916 { 917 'outcome': outcome, 918 'score': score, 919 'fail_message': fail_message, 920 'playerinfos': self.initialplayerinfos, 921 }, 922 delay=delay, 923 ) 924 925 def _award_completion_achievements(self) -> None: 926 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 927 self._award_achievement('Onslaught Training Victory', sound=False) 928 if not self._player_has_dropped_bomb: 929 self._award_achievement('Boxer', sound=False) 930 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 931 self._award_achievement('Rookie Onslaught Victory', sound=False) 932 if not self._a_player_has_been_hurt: 933 self._award_achievement('Flawless Victory', sound=False) 934 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 935 self._award_achievement('Pro Onslaught Victory', sound=False) 936 if not self._player_has_dropped_bomb: 937 self._award_achievement('Pro Boxer', sound=False) 938 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 939 self._award_achievement('Uber Onslaught Victory', sound=False) 940 941 def _update_waves(self) -> None: 942 943 # If we have no living bots, go to the next wave. 944 assert self._bots is not None 945 if ( 946 self._can_end_wave 947 and not self._bots.have_living_bots() 948 and not self._game_over 949 ): 950 self._can_end_wave = False 951 self._time_bonus_timer = None 952 self._time_bonus_text = None 953 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 954 won = False 955 else: 956 won = self._wavenum == len(self._waves) 957 958 base_delay = 4.0 if won else 0.0 959 960 # Reward time bonus. 961 if self._time_bonus > 0: 962 ba.timer(0, lambda: ba.playsound(self._cashregistersound)) 963 ba.timer( 964 base_delay, 965 ba.WeakCall(self._award_time_bonus, self._time_bonus), 966 ) 967 base_delay += 1.0 968 969 # Reward flawless bonus. 970 if self._wavenum > 0: 971 have_flawless = False 972 for player in self.players: 973 if player.is_alive() and not player.has_been_hurt: 974 have_flawless = True 975 ba.timer( 976 base_delay, 977 ba.WeakCall(self._award_flawless_bonus, player), 978 ) 979 player.has_been_hurt = False # reset 980 if have_flawless: 981 base_delay += 1.0 982 983 if won: 984 self.show_zoom_message( 985 ba.Lstr(resource='victoryText'), scale=1.0, duration=4.0 986 ) 987 self.celebrate(20.0) 988 self._award_completion_achievements() 989 ba.timer(base_delay, ba.WeakCall(self._award_completion_bonus)) 990 base_delay += 0.85 991 ba.playsound(self._winsound) 992 ba.cameraflash() 993 ba.setmusic(ba.MusicType.VICTORY) 994 self._game_over = True 995 996 # Can't just pass delay to do_end because our extra bonuses 997 # haven't been added yet (once we call do_end the score 998 # gets locked in). 999 ba.timer(base_delay, ba.WeakCall(self.do_end, 'victory')) 1000 return 1001 1002 self._wavenum += 1 1003 1004 # Short celebration after waves. 1005 if self._wavenum > 1: 1006 self.celebrate(0.5) 1007 ba.timer(base_delay, ba.WeakCall(self._start_next_wave)) 1008 1009 def _award_completion_bonus(self) -> None: 1010 ba.playsound(self._cashregistersound) 1011 for player in self.players: 1012 try: 1013 if player.is_alive(): 1014 assert self.initialplayerinfos is not None 1015 self.stats.player_scored( 1016 player, 1017 int(100 / len(self.initialplayerinfos)), 1018 scale=1.4, 1019 color=(0.6, 0.6, 1.0, 1.0), 1020 title=ba.Lstr(resource='completionBonusText'), 1021 screenmessage=False, 1022 ) 1023 except Exception: 1024 ba.print_exception() 1025 1026 def _award_time_bonus(self, bonus: int) -> None: 1027 ba.playsound(self._cashregistersound) 1028 PopupText( 1029 ba.Lstr( 1030 value='+${A} ${B}', 1031 subs=[ 1032 ('${A}', str(bonus)), 1033 ('${B}', ba.Lstr(resource='timeBonusText')), 1034 ], 1035 ), 1036 color=(1, 1, 0.5, 1), 1037 scale=1.0, 1038 position=(0, 3, -1), 1039 ).autoretain() 1040 self._score += self._time_bonus 1041 self._update_scores() 1042 1043 def _award_flawless_bonus(self, player: Player) -> None: 1044 ba.playsound(self._cashregistersound) 1045 try: 1046 if player.is_alive(): 1047 assert self._flawless_bonus is not None 1048 self.stats.player_scored( 1049 player, 1050 self._flawless_bonus, 1051 scale=1.2, 1052 color=(0.6, 1.0, 0.6, 1.0), 1053 title=ba.Lstr(resource='flawlessWaveText'), 1054 screenmessage=False, 1055 ) 1056 except Exception: 1057 ba.print_exception() 1058 1059 def _start_time_bonus_timer(self) -> None: 1060 self._time_bonus_timer = ba.Timer( 1061 1.0, ba.WeakCall(self._update_time_bonus), repeat=True 1062 ) 1063 1064 def _update_player_spawn_info(self) -> None: 1065 1066 # If we have no living players lets just blank this. 1067 assert self._spawn_info_text is not None 1068 assert self._spawn_info_text.node 1069 if not any(player.is_alive() for player in self.teams[0].players): 1070 self._spawn_info_text.node.text = '' 1071 else: 1072 text: str | ba.Lstr = '' 1073 for player in self.players: 1074 if not player.is_alive() and ( 1075 self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1076 or (player.respawn_wave <= len(self._waves)) 1077 ): 1078 rtxt = ba.Lstr( 1079 resource='onslaughtRespawnText', 1080 subs=[ 1081 ('${PLAYER}', player.getname()), 1082 ('${WAVE}', str(player.respawn_wave)), 1083 ], 1084 ) 1085 text = ba.Lstr( 1086 value='${A}${B}\n', 1087 subs=[ 1088 ('${A}', text), 1089 ('${B}', rtxt), 1090 ], 1091 ) 1092 self._spawn_info_text.node.text = text 1093 1094 def _respawn_players_for_wave(self) -> None: 1095 # Respawn applicable players. 1096 if self._wavenum > 1 and not self.is_waiting_for_continue(): 1097 for player in self.players: 1098 if ( 1099 not player.is_alive() 1100 and player.respawn_wave == self._wavenum 1101 ): 1102 self.spawn_player(player) 1103 self._update_player_spawn_info() 1104 1105 def _setup_wave_spawns(self, wave: Wave) -> None: 1106 tval = 0.0 1107 dtime = 0.2 1108 if self._wavenum == 1: 1109 spawn_time = 3.973 1110 tval += 0.5 1111 else: 1112 spawn_time = 2.648 1113 1114 bot_angle = wave.base_angle 1115 self._time_bonus = 0 1116 self._flawless_bonus = 0 1117 for info in wave.entries: 1118 if info is None: 1119 continue 1120 if isinstance(info, Delay): 1121 spawn_time += info.duration 1122 continue 1123 if isinstance(info, Spacing): 1124 bot_angle += info.spacing 1125 continue 1126 bot_type_2 = info.bottype 1127 if bot_type_2 is not None: 1128 assert not isinstance(bot_type_2, str) 1129 self._time_bonus += bot_type_2.points_mult * 20 1130 self._flawless_bonus += bot_type_2.points_mult * 5 1131 1132 # If its got a position, use that. 1133 point = info.point 1134 if point is not None: 1135 assert bot_type_2 is not None 1136 spcall = ba.WeakCall( 1137 self.add_bot_at_point, point, bot_type_2, spawn_time 1138 ) 1139 ba.timer(tval, spcall) 1140 tval += dtime 1141 else: 1142 spacing = info.spacing 1143 bot_angle += spacing * 0.5 1144 if bot_type_2 is not None: 1145 tcall = ba.WeakCall( 1146 self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time 1147 ) 1148 ba.timer(tval, tcall) 1149 tval += dtime 1150 bot_angle += spacing * 0.5 1151 1152 # We can end the wave after all the spawning happens. 1153 ba.timer( 1154 tval + spawn_time - dtime + 0.01, 1155 ba.WeakCall(self._set_can_end_wave), 1156 ) 1157 1158 def _start_next_wave(self) -> None: 1159 1160 # This can happen if we beat a wave as we die. 1161 # We don't wanna respawn players and whatnot if this happens. 1162 if self._game_over: 1163 return 1164 1165 self._respawn_players_for_wave() 1166 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 1167 wave = self._generate_random_wave() 1168 else: 1169 wave = self._waves[self._wavenum - 1] 1170 self._setup_wave_spawns(wave) 1171 self._update_wave_ui_and_bonuses() 1172 ba.timer(0.4, ba.Call(ba.playsound, self._new_wave_sound)) 1173 1174 def _update_wave_ui_and_bonuses(self) -> None: 1175 1176 self.show_zoom_message( 1177 ba.Lstr( 1178 value='${A} ${B}', 1179 subs=[ 1180 ('${A}', ba.Lstr(resource='waveText')), 1181 ('${B}', str(self._wavenum)), 1182 ], 1183 ), 1184 scale=1.0, 1185 duration=1.0, 1186 trail=True, 1187 ) 1188 1189 # Reset our time bonus. 1190 tbtcolor = (1, 1, 0, 1) 1191 tbttxt = ba.Lstr( 1192 value='${A}: ${B}', 1193 subs=[ 1194 ('${A}', ba.Lstr(resource='timeBonusText')), 1195 ('${B}', str(self._time_bonus)), 1196 ], 1197 ) 1198 self._time_bonus_text = ba.NodeActor( 1199 ba.newnode( 1200 'text', 1201 attrs={ 1202 'v_attach': 'top', 1203 'h_attach': 'center', 1204 'h_align': 'center', 1205 'vr_depth': -30, 1206 'color': tbtcolor, 1207 'shadow': 1.0, 1208 'flatness': 1.0, 1209 'position': (0, -60), 1210 'scale': 0.8, 1211 'text': tbttxt, 1212 }, 1213 ) 1214 ) 1215 1216 ba.timer(5.0, ba.WeakCall(self._start_time_bonus_timer)) 1217 wtcolor = (1, 1, 1, 1) 1218 wttxt = ba.Lstr( 1219 value='${A} ${B}', 1220 subs=[ 1221 ('${A}', ba.Lstr(resource='waveText')), 1222 ( 1223 '${B}', 1224 str(self._wavenum) 1225 + ( 1226 '' 1227 if self._preset 1228 in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1229 else ('/' + str(len(self._waves))) 1230 ), 1231 ), 1232 ], 1233 ) 1234 self._wave_text = ba.NodeActor( 1235 ba.newnode( 1236 'text', 1237 attrs={ 1238 'v_attach': 'top', 1239 'h_attach': 'center', 1240 'h_align': 'center', 1241 'vr_depth': -10, 1242 'color': wtcolor, 1243 'shadow': 1.0, 1244 'flatness': 1.0, 1245 'position': (0, -40), 1246 'scale': 1.3, 1247 'text': wttxt, 1248 }, 1249 ) 1250 ) 1251 1252 def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]: 1253 level = self._wavenum 1254 bot_types = [ 1255 BomberBot, 1256 BrawlerBot, 1257 TriggerBot, 1258 ChargerBot, 1259 BomberBotPro, 1260 BrawlerBotPro, 1261 TriggerBotPro, 1262 BomberBotProShielded, 1263 ExplodeyBot, 1264 ChargerBotProShielded, 1265 StickyBot, 1266 BrawlerBotProShielded, 1267 TriggerBotProShielded, 1268 ] 1269 if level > 5: 1270 bot_types += [ 1271 ExplodeyBot, 1272 TriggerBotProShielded, 1273 BrawlerBotProShielded, 1274 ChargerBotProShielded, 1275 ] 1276 if level > 7: 1277 bot_types += [ 1278 ExplodeyBot, 1279 TriggerBotProShielded, 1280 BrawlerBotProShielded, 1281 ChargerBotProShielded, 1282 ] 1283 if level > 10: 1284 bot_types += [ 1285 TriggerBotProShielded, 1286 TriggerBotProShielded, 1287 TriggerBotProShielded, 1288 TriggerBotProShielded, 1289 ] 1290 if level > 13: 1291 bot_types += [ 1292 TriggerBotProShielded, 1293 TriggerBotProShielded, 1294 TriggerBotProShielded, 1295 TriggerBotProShielded, 1296 ] 1297 bot_levels = [ 1298 [b for b in bot_types if b.points_mult == 1], 1299 [b for b in bot_types if b.points_mult == 2], 1300 [b for b in bot_types if b.points_mult == 3], 1301 [b for b in bot_types if b.points_mult == 4], 1302 ] 1303 1304 # Make sure all lists have something in them 1305 if not all(bot_levels): 1306 raise RuntimeError('Got empty bot level') 1307 return bot_levels 1308 1309 def _add_entries_for_distribution_group( 1310 self, 1311 group: list[tuple[int, int]], 1312 bot_levels: list[list[type[SpazBot]]], 1313 all_entries: list[Spawn | Spacing | Delay | None], 1314 ) -> None: 1315 entries: list[Spawn | Spacing | Delay | None] = [] 1316 for entry in group: 1317 bot_level = bot_levels[entry[0] - 1] 1318 bot_type = bot_level[random.randrange(len(bot_level))] 1319 rval = random.random() 1320 if rval < 0.5: 1321 spacing = 10.0 1322 elif rval < 0.9: 1323 spacing = 20.0 1324 else: 1325 spacing = 40.0 1326 split = random.random() > 0.3 1327 for i in range(entry[1]): 1328 if split and i % 2 == 0: 1329 entries.insert(0, Spawn(bot_type, spacing=spacing)) 1330 else: 1331 entries.append(Spawn(bot_type, spacing=spacing)) 1332 if entries: 1333 all_entries += entries 1334 all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0)) 1335 1336 def _generate_random_wave(self) -> Wave: 1337 level = self._wavenum 1338 bot_levels = self._bot_levels_for_wave() 1339 1340 target_points = level * 3 - 2 1341 min_dudes = min(1 + level // 3, 10) 1342 max_dudes = min(10, level + 1) 1343 max_level = ( 1344 4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1)) 1345 ) 1346 group_count = 3 1347 distribution = self._get_distribution( 1348 target_points, min_dudes, max_dudes, group_count, max_level 1349 ) 1350 all_entries: list[Spawn | Spacing | Delay | None] = [] 1351 for group in distribution: 1352 self._add_entries_for_distribution_group( 1353 group, bot_levels, all_entries 1354 ) 1355 angle_rand = random.random() 1356 if angle_rand > 0.75: 1357 base_angle = 130.0 1358 elif angle_rand > 0.5: 1359 base_angle = 210.0 1360 elif angle_rand > 0.25: 1361 base_angle = 20.0 1362 else: 1363 base_angle = -30.0 1364 base_angle += (0.5 - random.random()) * 20.0 1365 wave = Wave(base_angle=base_angle, entries=all_entries) 1366 return wave 1367 1368 def add_bot_at_point( 1369 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1370 ) -> None: 1371 """Add a new bot at a specified named point.""" 1372 if self._game_over: 1373 return 1374 assert isinstance(point.value, str) 1375 pointpos = self.map.defs.points[point.value] 1376 assert self._bots is not None 1377 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time) 1378 1379 def add_bot_at_angle( 1380 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1381 ) -> None: 1382 """Add a new bot at a specified angle (for circular maps).""" 1383 if self._game_over: 1384 return 1385 angle_radians = angle / 57.2957795 1386 xval = math.sin(angle_radians) * 1.06 1387 zval = math.cos(angle_radians) * 1.06 1388 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1389 assert self._bots is not None 1390 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time) 1391 1392 def _update_time_bonus(self) -> None: 1393 self._time_bonus = int(self._time_bonus * 0.93) 1394 if self._time_bonus > 0 and self._time_bonus_text is not None: 1395 assert self._time_bonus_text.node 1396 self._time_bonus_text.node.text = ba.Lstr( 1397 value='${A}: ${B}', 1398 subs=[ 1399 ('${A}', ba.Lstr(resource='timeBonusText')), 1400 ('${B}', str(self._time_bonus)), 1401 ], 1402 ) 1403 else: 1404 self._time_bonus_text = None 1405 1406 def _start_updating_waves(self) -> None: 1407 self._wave_update_timer = ba.Timer( 1408 2.0, ba.WeakCall(self._update_waves), repeat=True 1409 ) 1410 1411 def _update_scores(self) -> None: 1412 score = self._score 1413 if self._preset is Preset.ENDLESS: 1414 if score >= 500: 1415 self._award_achievement('Onslaught Master') 1416 if score >= 1000: 1417 self._award_achievement('Onslaught Wizard') 1418 if score >= 5000: 1419 self._award_achievement('Onslaught God') 1420 assert self._scoreboard is not None 1421 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1422 1423 def handlemessage(self, msg: Any) -> Any: 1424 1425 if isinstance(msg, PlayerSpazHurtMessage): 1426 msg.spaz.getplayer(Player, True).has_been_hurt = True 1427 self._a_player_has_been_hurt = True 1428 1429 elif isinstance(msg, ba.PlayerScoredMessage): 1430 self._score += msg.score 1431 self._update_scores() 1432 1433 elif isinstance(msg, ba.PlayerDiedMessage): 1434 super().handlemessage(msg) # Augment standard behavior. 1435 player = msg.getplayer(Player) 1436 self._a_player_has_been_hurt = True 1437 1438 # Make note with the player when they can respawn: 1439 if self._wavenum < 10: 1440 player.respawn_wave = max(2, self._wavenum + 1) 1441 elif self._wavenum < 15: 1442 player.respawn_wave = max(2, self._wavenum + 2) 1443 else: 1444 player.respawn_wave = max(2, self._wavenum + 3) 1445 ba.timer(0.1, self._update_player_spawn_info) 1446 ba.timer(0.1, self._checkroundover) 1447 1448 elif isinstance(msg, SpazBotDiedMessage): 1449 pts, importance = msg.spazbot.get_death_points(msg.how) 1450 if msg.killerplayer is not None: 1451 self._handle_kill_achievements(msg) 1452 target: Sequence[float] | None 1453 if msg.spazbot.node: 1454 target = msg.spazbot.node.position 1455 else: 1456 target = None 1457 1458 killerplayer = msg.killerplayer 1459 self.stats.player_scored( 1460 killerplayer, 1461 pts, 1462 target=target, 1463 kill=True, 1464 screenmessage=False, 1465 importance=importance, 1466 ) 1467 ba.playsound( 1468 self._dingsound if importance == 1 else self._dingsoundhigh, 1469 volume=0.6, 1470 ) 1471 1472 # Normally we pull scores from the score-set, but if there's 1473 # no player lets be explicit. 1474 else: 1475 self._score += pts 1476 self._update_scores() 1477 else: 1478 super().handlemessage(msg) 1479 1480 def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1481 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 1482 self._handle_training_kill_achievements(msg) 1483 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 1484 self._handle_rookie_kill_achievements(msg) 1485 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 1486 self._handle_pro_kill_achievements(msg) 1487 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 1488 self._handle_uber_kill_achievements(msg) 1489 1490 def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1491 1492 # Uber mine achievement: 1493 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1494 self._land_mine_kills += 1 1495 if self._land_mine_kills >= 6: 1496 self._award_achievement('Gold Miner') 1497 1498 # Uber tnt achievement: 1499 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1500 self._tnt_kills += 1 1501 if self._tnt_kills >= 6: 1502 ba.timer( 1503 0.5, ba.WeakCall(self._award_achievement, 'TNT Terror') 1504 ) 1505 1506 def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1507 1508 # TNT achievement: 1509 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1510 self._tnt_kills += 1 1511 if self._tnt_kills >= 3: 1512 ba.timer( 1513 0.5, 1514 ba.WeakCall( 1515 self._award_achievement, 'Boom Goes the Dynamite' 1516 ), 1517 ) 1518 1519 def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1520 # Land-mine achievement: 1521 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1522 self._land_mine_kills += 1 1523 if self._land_mine_kills >= 3: 1524 self._award_achievement('Mine Games') 1525 1526 def _handle_training_kill_achievements( 1527 self, msg: SpazBotDiedMessage 1528 ) -> None: 1529 # Toss-off-map achievement: 1530 if msg.spazbot.last_attacked_type == ('picked_up', 'default'): 1531 self._throw_off_kills += 1 1532 if self._throw_off_kills >= 3: 1533 self._award_achievement('Off You Go Then') 1534 1535 def _set_can_end_wave(self) -> None: 1536 self._can_end_wave = True 1537 1538 def end_game(self) -> None: 1539 # Tell our bots to celebrate just to rub it in. 1540 assert self._bots is not None 1541 self._bots.final_celebrate() 1542 self._game_over = True 1543 self.do_end('defeat', delay=2.0) 1544 ba.setmusic(None) 1545 1546 def on_continue(self) -> None: 1547 for player in self.players: 1548 if not player.is_alive(): 1549 self.spawn_player(player) 1550 1551 def _checkroundover(self) -> None: 1552 """Potentially end the round based on the state of the game.""" 1553 if self.has_ended(): 1554 return 1555 if not any(player.is_alive() for player in self.teams[0].players): 1556 # Allow continuing after wave 1. 1557 if self._wavenum > 1: 1558 self.continue_or_end_game() 1559 else: 1560 self.end_game()
Co-op game where players try to survive attacking waves of enemies.
166 def __init__(self, settings: dict): 167 168 self._preset = Preset(settings.get('preset', 'training')) 169 if self._preset in { 170 Preset.TRAINING, 171 Preset.TRAINING_EASY, 172 Preset.PRO, 173 Preset.PRO_EASY, 174 Preset.ENDLESS, 175 Preset.ENDLESS_TOURNAMENT, 176 }: 177 settings['map'] = 'Doom Shroom' 178 else: 179 settings['map'] = 'Courtyard' 180 181 super().__init__(settings) 182 183 self._new_wave_sound = ba.getsound('scoreHit01') 184 self._winsound = ba.getsound('score') 185 self._cashregistersound = ba.getsound('cashRegister') 186 self._a_player_has_been_hurt = False 187 self._player_has_dropped_bomb = False 188 189 # FIXME: should use standard map defs. 190 if settings['map'] == 'Doom Shroom': 191 self._spawn_center = (0, 3, -5) 192 self._tntspawnpos = (0.0, 3.0, -5.0) 193 self._powerup_center = (0, 5, -3.6) 194 self._powerup_spread = (6.0, 4.0) 195 elif settings['map'] == 'Courtyard': 196 self._spawn_center = (0, 3, -2) 197 self._tntspawnpos = (0.0, 3.0, 2.1) 198 self._powerup_center = (0, 5, -1.6) 199 self._powerup_spread = (4.6, 2.7) 200 else: 201 raise Exception('Unsupported map: ' + str(settings['map'])) 202 self._scoreboard: Scoreboard | None = None 203 self._game_over = False 204 self._wavenum = 0 205 self._can_end_wave = True 206 self._score = 0 207 self._time_bonus = 0 208 self._spawn_info_text: ba.NodeActor | None = None 209 self._dingsound = ba.getsound('dingSmall') 210 self._dingsoundhigh = ba.getsound('dingSmallHigh') 211 self._have_tnt = False 212 self._excluded_powerups: list[str] | None = None 213 self._waves: list[Wave] = [] 214 self._tntspawner: TNTSpawner | None = None 215 self._bots: SpazBotSet | None = None 216 self._powerup_drop_timer: ba.Timer | None = None 217 self._time_bonus_timer: ba.Timer | None = None 218 self._time_bonus_text: ba.NodeActor | None = None 219 self._flawless_bonus: int | None = None 220 self._wave_text: ba.NodeActor | None = None 221 self._wave_update_timer: ba.Timer | None = None 222 self._throw_off_kills = 0 223 self._land_mine_kills = 0 224 self._tnt_kills = 0
Instantiate the Activity.
Whether to print every time a player dies. This can be pertinent in games such as Death-Match but can be annoying in games where it doesn't matter.
226 def on_transition_in(self) -> None: 227 super().on_transition_in() 228 customdata = ba.getsession().customdata 229 230 # Show special landmine tip on rookie preset. 231 if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 232 # Show once per session only (then we revert to regular tips). 233 if not customdata.get('_showed_onslaught_landmine_tip', False): 234 customdata['_showed_onslaught_landmine_tip'] = True 235 self.tips = [ 236 ba.GameTip( 237 'Land-mines are a good way to stop speedy enemies.', 238 icon=ba.gettexture('powerupLandMines'), 239 sound=ba.getsound('ding'), 240 ) 241 ] 242 243 # Show special tnt tip on pro preset. 244 if self._preset in {Preset.PRO, Preset.PRO_EASY}: 245 # Show once per session only (then we revert to regular tips). 246 if not customdata.get('_showed_onslaught_tnt_tip', False): 247 customdata['_showed_onslaught_tnt_tip'] = True 248 self.tips = [ 249 ba.GameTip( 250 'Take out a group of enemies by\n' 251 'setting off a bomb near a TNT box.', 252 icon=ba.gettexture('tnt'), 253 sound=ba.getsound('ding'), 254 ) 255 ] 256 257 # Show special curse tip on uber preset. 258 if self._preset in {Preset.UBER, Preset.UBER_EASY}: 259 # Show once per session only (then we revert to regular tips). 260 if not customdata.get('_showed_onslaught_curse_tip', False): 261 customdata['_showed_onslaught_curse_tip'] = True 262 self.tips = [ 263 ba.GameTip( 264 'Curse boxes turn you into a ticking time bomb.\n' 265 'The only cure is to quickly grab a health-pack.', 266 icon=ba.gettexture('powerupCurse'), 267 sound=ba.getsound('ding'), 268 ) 269 ] 270 271 self._spawn_info_text = ba.NodeActor( 272 ba.newnode( 273 'text', 274 attrs={ 275 'position': (15, -130), 276 'h_attach': 'left', 277 'v_attach': 'top', 278 'scale': 0.55, 279 'color': (0.3, 0.8, 0.3, 1.0), 280 'text': '', 281 }, 282 ) 283 ) 284 ba.setmusic(ba.MusicType.ONSLAUGHT) 285 286 self._scoreboard = Scoreboard( 287 label=ba.Lstr(resource='scoreText'), score_split=0.5 288 )
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 ba.Activity.on_begin() is called.
290 def on_begin(self) -> None: 291 super().on_begin() 292 player_count = len(self.players) 293 hard = self._preset not in { 294 Preset.TRAINING_EASY, 295 Preset.ROOKIE_EASY, 296 Preset.PRO_EASY, 297 Preset.UBER_EASY, 298 } 299 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 300 ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain() 301 302 self._have_tnt = False 303 self._excluded_powerups = ['curse', 'land_mines'] 304 self._waves = [ 305 Wave( 306 base_angle=195, 307 entries=[ 308 Spawn(BomberBotLite, spacing=5), 309 ] 310 * player_count, 311 ), 312 Wave( 313 base_angle=130, 314 entries=[ 315 Spawn(BrawlerBotLite, spacing=5), 316 ] 317 * player_count, 318 ), 319 Wave( 320 base_angle=195, 321 entries=[Spawn(BomberBotLite, spacing=10)] 322 * (player_count + 1), 323 ), 324 Wave( 325 base_angle=130, 326 entries=[ 327 Spawn(BrawlerBotLite, spacing=10), 328 ] 329 * (player_count + 1), 330 ), 331 Wave( 332 base_angle=130, 333 entries=[ 334 Spawn(BrawlerBotLite, spacing=5) 335 if player_count > 1 336 else None, 337 Spawn(BrawlerBotLite, spacing=5), 338 Spacing(30), 339 Spawn(BomberBotLite, spacing=5) 340 if player_count > 3 341 else None, 342 Spawn(BomberBotLite, spacing=5), 343 Spacing(30), 344 Spawn(BrawlerBotLite, spacing=5), 345 Spawn(BrawlerBotLite, spacing=5) 346 if player_count > 2 347 else None, 348 ], 349 ), 350 Wave( 351 base_angle=195, 352 entries=[ 353 Spawn(TriggerBot, spacing=90), 354 Spawn(TriggerBot, spacing=90) 355 if player_count > 1 356 else None, 357 ], 358 ), 359 ] 360 361 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 362 self._have_tnt = False 363 self._excluded_powerups = ['curse'] 364 self._waves = [ 365 Wave( 366 entries=[ 367 Spawn(ChargerBot, Point.LEFT_UPPER_MORE) 368 if player_count > 2 369 else None, 370 Spawn(ChargerBot, Point.LEFT_UPPER), 371 ] 372 ), 373 Wave( 374 entries=[ 375 Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT), 376 Spawn(BrawlerBotLite, Point.RIGHT_UPPER), 377 Spawn(BrawlerBotLite, Point.RIGHT_LOWER) 378 if player_count > 1 379 else None, 380 Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT) 381 if player_count > 2 382 else None, 383 ] 384 ), 385 Wave( 386 entries=[ 387 Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT), 388 Spawn(TriggerBot, Point.LEFT), 389 Spawn(TriggerBot, Point.LEFT_LOWER) 390 if player_count > 1 391 else None, 392 Spawn(TriggerBot, Point.LEFT_UPPER) 393 if player_count > 2 394 else None, 395 ] 396 ), 397 Wave( 398 entries=[ 399 Spawn(BrawlerBotLite, Point.TOP_RIGHT), 400 Spawn(BrawlerBot, Point.TOP_HALF_RIGHT) 401 if player_count > 1 402 else None, 403 Spawn(BrawlerBotLite, Point.TOP_LEFT), 404 Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT) 405 if player_count > 2 406 else None, 407 Spawn(BrawlerBot, Point.TOP), 408 Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE), 409 ] 410 ), 411 Wave( 412 entries=[ 413 Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT), 414 Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT), 415 Spawn(TriggerBot, Point.BOTTOM), 416 Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT) 417 if player_count > 1 418 else None, 419 Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT) 420 if player_count > 2 421 else None, 422 ] 423 ), 424 Wave( 425 entries=[ 426 Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT), 427 Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT), 428 Spawn(ChargerBot, Point.BOTTOM), 429 Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT) 430 if player_count > 1 431 else None, 432 Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT) 433 if player_count > 2 434 else None, 435 ] 436 ), 437 ] 438 439 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 440 self._excluded_powerups = ['curse'] 441 self._have_tnt = True 442 self._waves = [ 443 Wave( 444 base_angle=-50, 445 entries=[ 446 Spawn(BrawlerBot, spacing=12) 447 if player_count > 3 448 else None, 449 Spawn(BrawlerBot, spacing=12), 450 Spawn(BomberBot, spacing=6), 451 Spawn(BomberBot, spacing=6) 452 if self._preset is Preset.PRO 453 else None, 454 Spawn(BomberBot, spacing=6) 455 if player_count > 1 456 else None, 457 Spawn(BrawlerBot, spacing=12), 458 Spawn(BrawlerBot, spacing=12) 459 if player_count > 2 460 else None, 461 ], 462 ), 463 Wave( 464 base_angle=180, 465 entries=[ 466 Spawn(BrawlerBot, spacing=6) 467 if player_count > 3 468 else None, 469 Spawn(BrawlerBot, spacing=6) 470 if self._preset is Preset.PRO 471 else None, 472 Spawn(BrawlerBot, spacing=6), 473 Spawn(ChargerBot, spacing=45), 474 Spawn(ChargerBot, spacing=45) 475 if player_count > 1 476 else None, 477 Spawn(BrawlerBot, spacing=6), 478 Spawn(BrawlerBot, spacing=6) 479 if self._preset is Preset.PRO 480 else None, 481 Spawn(BrawlerBot, spacing=6) 482 if player_count > 2 483 else None, 484 ], 485 ), 486 Wave( 487 base_angle=0, 488 entries=[ 489 Spawn(ChargerBot, spacing=30), 490 Spawn(TriggerBot, spacing=30), 491 Spawn(TriggerBot, spacing=30), 492 Spawn(TriggerBot, spacing=30) 493 if self._preset is Preset.PRO 494 else None, 495 Spawn(TriggerBot, spacing=30) 496 if player_count > 1 497 else None, 498 Spawn(TriggerBot, spacing=30) 499 if player_count > 3 500 else None, 501 Spawn(ChargerBot, spacing=30), 502 ], 503 ), 504 Wave( 505 base_angle=90, 506 entries=[ 507 Spawn(StickyBot, spacing=50), 508 Spawn(StickyBot, spacing=50) 509 if self._preset is Preset.PRO 510 else None, 511 Spawn(StickyBot, spacing=50), 512 Spawn(StickyBot, spacing=50) 513 if player_count > 1 514 else None, 515 Spawn(StickyBot, spacing=50) 516 if player_count > 3 517 else None, 518 ], 519 ), 520 Wave( 521 base_angle=0, 522 entries=[ 523 Spawn(TriggerBot, spacing=72), 524 Spawn(TriggerBot, spacing=72), 525 Spawn(TriggerBot, spacing=72) 526 if self._preset is Preset.PRO 527 else None, 528 Spawn(TriggerBot, spacing=72), 529 Spawn(TriggerBot, spacing=72), 530 Spawn(TriggerBot, spacing=36) 531 if player_count > 2 532 else None, 533 ], 534 ), 535 Wave( 536 base_angle=30, 537 entries=[ 538 Spawn(ChargerBotProShielded, spacing=50), 539 Spawn(ChargerBotProShielded, spacing=50), 540 Spawn(ChargerBotProShielded, spacing=50) 541 if self._preset is Preset.PRO 542 else None, 543 Spawn(ChargerBotProShielded, spacing=50) 544 if player_count > 1 545 else None, 546 Spawn(ChargerBotProShielded, spacing=50) 547 if player_count > 2 548 else None, 549 ], 550 ), 551 ] 552 553 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 554 555 # Show controls help in demo/arcade modes. 556 if ba.app.demo_mode or ba.app.arcade_mode: 557 ControlsGuide( 558 delay=3.0, lifespan=10.0, bright=True 559 ).autoretain() 560 561 self._have_tnt = True 562 self._excluded_powerups = [] 563 self._waves = [ 564 Wave( 565 entries=[ 566 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT) 567 if hard 568 else None, 569 Spawn( 570 BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT 571 ), 572 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT) 573 if player_count > 2 574 else None, 575 Spawn(ExplodeyBot, Point.TOP_RIGHT), 576 Delay(4.0), 577 Spawn(ExplodeyBot, Point.TOP_LEFT), 578 ] 579 ), 580 Wave( 581 entries=[ 582 Spawn(ChargerBot, Point.LEFT), 583 Spawn(ChargerBot, Point.RIGHT), 584 Spawn(ChargerBot, Point.RIGHT_UPPER_MORE) 585 if player_count > 2 586 else None, 587 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT), 588 Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT), 589 ] 590 ), 591 Wave( 592 entries=[ 593 Spawn(TriggerBotPro, Point.TOP_RIGHT), 594 Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE) 595 if player_count > 1 596 else None, 597 Spawn(TriggerBotPro, Point.RIGHT_UPPER), 598 Spawn(TriggerBotPro, Point.RIGHT_LOWER) 599 if hard 600 else None, 601 Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE) 602 if player_count > 2 603 else None, 604 Spawn(TriggerBotPro, Point.BOTTOM_RIGHT), 605 ] 606 ), 607 Wave( 608 entries=[ 609 Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT), 610 Spawn(ChargerBotProShielded, Point.BOTTOM) 611 if player_count > 2 612 else None, 613 Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT), 614 Spawn(ChargerBotProShielded, Point.TOP) 615 if hard 616 else None, 617 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE), 618 ] 619 ), 620 Wave( 621 entries=[ 622 Spawn(ExplodeyBot, Point.LEFT_UPPER), 623 Delay(1.0), 624 Spawn(BrawlerBotProShielded, Point.LEFT_LOWER), 625 Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE), 626 Delay(4.0), 627 Spawn(ExplodeyBot, Point.RIGHT_UPPER), 628 Delay(1.0), 629 Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER), 630 Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE), 631 Delay(4.0), 632 Spawn(ExplodeyBot, Point.LEFT), 633 Delay(5.0), 634 Spawn(ExplodeyBot, Point.RIGHT), 635 ] 636 ), 637 Wave( 638 entries=[ 639 Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT), 640 Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT), 641 Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT), 642 Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT), 643 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT) 644 if hard 645 else None, 646 Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT) 647 if hard 648 else None, 649 ] 650 ), 651 ] 652 653 # We generate these on the fly in endless. 654 elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 655 self._have_tnt = True 656 self._excluded_powerups = [] 657 self._waves = [] 658 659 else: 660 raise RuntimeError(f'Invalid preset: {self._preset}') 661 662 # FIXME: Should migrate to use setup_standard_powerup_drops(). 663 664 # Spit out a few powerups and start dropping more shortly. 665 self._drop_powerups( 666 standard_points=True, 667 poweruptype='curse' 668 if self._preset in [Preset.UBER, Preset.UBER_EASY] 669 else ( 670 'land_mines' 671 if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY] 672 else None 673 ), 674 ) 675 ba.timer(4.0, self._start_powerup_drops) 676 677 # Our TNT spawner (if applicable). 678 if self._have_tnt: 679 self._tntspawner = TNTSpawner(position=self._tntspawnpos) 680 681 self.setup_low_life_warning_sound() 682 self._update_scores() 683 self._bots = SpazBotSet() 684 ba.timer(4.0, self._start_updating_waves)
Called once the previous ba.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.
829 def spawn_player(self, player: Player) -> ba.Actor: 830 831 # We keep track of who got hurt each wave for score purposes. 832 player.has_been_hurt = False 833 pos = ( 834 self._spawn_center[0] + random.uniform(-1.5, 1.5), 835 self._spawn_center[1], 836 self._spawn_center[2] + random.uniform(-1.5, 1.5), 837 ) 838 spaz = self.spawn_player_spaz(player, position=pos) 839 if self._preset in { 840 Preset.TRAINING_EASY, 841 Preset.ROOKIE_EASY, 842 Preset.PRO_EASY, 843 Preset.UBER_EASY, 844 }: 845 spaz.impact_scale = 0.25 846 spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) 847 return spaz
Spawn something for the provided ba.Player.
The default implementation simply calls spawn_player_spaz().
904 def do_end(self, outcome: str, delay: float = 0.0) -> None: 905 """End the game with the specified outcome.""" 906 if outcome == 'defeat': 907 self.fade_to_red() 908 score: int | None 909 if self._wavenum >= 2: 910 score = self._score 911 fail_message = None 912 else: 913 score = None 914 fail_message = ba.Lstr(resource='reachWave2Text') 915 self.end( 916 { 917 'outcome': outcome, 918 'score': score, 919 'fail_message': fail_message, 920 'playerinfos': self.initialplayerinfos, 921 }, 922 delay=delay, 923 )
End the game with the specified outcome.
1368 def add_bot_at_point( 1369 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1370 ) -> None: 1371 """Add a new bot at a specified named point.""" 1372 if self._game_over: 1373 return 1374 assert isinstance(point.value, str) 1375 pointpos = self.map.defs.points[point.value] 1376 assert self._bots is not None 1377 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)
Add a new bot at a specified named point.
1379 def add_bot_at_angle( 1380 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1381 ) -> None: 1382 """Add a new bot at a specified angle (for circular maps).""" 1383 if self._game_over: 1384 return 1385 angle_radians = angle / 57.2957795 1386 xval = math.sin(angle_radians) * 1.06 1387 zval = math.cos(angle_radians) * 1.06 1388 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1389 assert self._bots is not None 1390 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)
Add a new bot at a specified angle (for circular maps).
1423 def handlemessage(self, msg: Any) -> Any: 1424 1425 if isinstance(msg, PlayerSpazHurtMessage): 1426 msg.spaz.getplayer(Player, True).has_been_hurt = True 1427 self._a_player_has_been_hurt = True 1428 1429 elif isinstance(msg, ba.PlayerScoredMessage): 1430 self._score += msg.score 1431 self._update_scores() 1432 1433 elif isinstance(msg, ba.PlayerDiedMessage): 1434 super().handlemessage(msg) # Augment standard behavior. 1435 player = msg.getplayer(Player) 1436 self._a_player_has_been_hurt = True 1437 1438 # Make note with the player when they can respawn: 1439 if self._wavenum < 10: 1440 player.respawn_wave = max(2, self._wavenum + 1) 1441 elif self._wavenum < 15: 1442 player.respawn_wave = max(2, self._wavenum + 2) 1443 else: 1444 player.respawn_wave = max(2, self._wavenum + 3) 1445 ba.timer(0.1, self._update_player_spawn_info) 1446 ba.timer(0.1, self._checkroundover) 1447 1448 elif isinstance(msg, SpazBotDiedMessage): 1449 pts, importance = msg.spazbot.get_death_points(msg.how) 1450 if msg.killerplayer is not None: 1451 self._handle_kill_achievements(msg) 1452 target: Sequence[float] | None 1453 if msg.spazbot.node: 1454 target = msg.spazbot.node.position 1455 else: 1456 target = None 1457 1458 killerplayer = msg.killerplayer 1459 self.stats.player_scored( 1460 killerplayer, 1461 pts, 1462 target=target, 1463 kill=True, 1464 screenmessage=False, 1465 importance=importance, 1466 ) 1467 ba.playsound( 1468 self._dingsound if importance == 1 else self._dingsoundhigh, 1469 volume=0.6, 1470 ) 1471 1472 # Normally we pull scores from the score-set, but if there's 1473 # no player lets be explicit. 1474 else: 1475 self._score += pts 1476 self._update_scores() 1477 else: 1478 super().handlemessage(msg)
General message handling; can be passed any message object.
1538 def end_game(self) -> None: 1539 # Tell our bots to celebrate just to rub it in. 1540 assert self._bots is not None 1541 self._bots.final_celebrate() 1542 self._game_over = True 1543 self.do_end('defeat', delay=2.0) 1544 ba.setmusic(None)
Tell the game to wrap up and call ba.Activity.end() immediately.
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 (ba.GameActivity.setup_standard_time_limit()) will work with the game.
1546 def on_continue(self) -> None: 1547 for player in self.players: 1548 if not player.is_alive(): 1549 self.spawn_player(player)
This is called if a game supports and offers a continue and the player accepts. In this case the player should be given an extra life or whatever is relevant to keep the game going.
Inherited Members
- ba._coopgame.CoopGameActivity
- session
- supports_session_type
- get_score_type
- celebrate
- spawn_player_spaz
- fade_to_red
- setup_low_life_warning_sound
- ba._gameactivity.GameActivity
- allow_pausing
- allow_kick_idle_players
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- end
- respawn_player
- spawn_player_if_exists
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- ba._activity.Activity
- settings_raw
- teams
- players
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
- ba._dependency.DependencyComponent
- dep_is_present
- get_dynamic_deps