bascenev1lib.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 8 9# (see https://ballistica.net/wiki/meta-tag-system) 10 11from __future__ import annotations 12 13import math 14import random 15import logging 16from enum import Enum, unique 17from dataclasses import dataclass 18from typing import TYPE_CHECKING 19 20from bascenev1lib.actor.popuptext import PopupText 21from bascenev1lib.actor.bomb import TNTSpawner 22from bascenev1lib.actor.playerspaz import PlayerSpazHurtMessage 23from bascenev1lib.actor.scoreboard import Scoreboard 24from bascenev1lib.actor.controlsguide import ControlsGuide 25from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory 26from bascenev1lib.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) 48import bascenev1 as bs 49 50if TYPE_CHECKING: 51 from typing import Any, Sequence 52 from bascenev1lib.actor.spazbot import SpazBot 53 54 55@dataclass 56class Wave: 57 """A wave of enemies.""" 58 59 entries: list[Spawn | Spacing | Delay | None] 60 base_angle: float = 0.0 61 62 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 70 71 72@dataclass 73class Spacing: 74 """Empty space in a wave.""" 75 76 spacing: float = 5.0 77 78 79@dataclass 80class Delay: 81 """A delay between events in a wave.""" 82 83 duration: float 84 85 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' 99 100 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' 132 133 134class Player(bs.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 140 141 142class Team(bs.Team[Player]): 143 """Our team type for this game.""" 144 145 146class OnslaughtGame(bs.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 | bs.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 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 = bs.getsound('scoreHit01') 183 self._winsound = bs.getsound('score') 184 self._cashregistersound = bs.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 RuntimeError('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: bs.NodeActor | None = None 208 self._dingsound = bs.getsound('dingSmall') 209 self._dingsoundhigh = bs.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: bs.Timer | None = None 216 self._time_bonus_timer: bs.Timer | None = None 217 self._time_bonus_text: bs.NodeActor | None = None 218 self._flawless_bonus: int | None = None 219 self._wave_text: bs.NodeActor | None = None 220 self._wave_update_timer: bs.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 = bs.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 bs.GameTip( 236 'Land-mines are a good way to stop speedy enemies.', 237 icon=bs.gettexture('powerupLandMines'), 238 sound=bs.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 bs.GameTip( 249 'Take out a group of enemies by\n' 250 'setting off a bomb near a TNT box.', 251 icon=bs.gettexture('tnt'), 252 sound=bs.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 bs.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=bs.gettexture('powerupCurse'), 266 sound=bs.getsound('ding'), 267 ) 268 ] 269 270 self._spawn_info_text = bs.NodeActor( 271 bs.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 bs.setmusic(bs.MusicType.ONSLAUGHT) 284 285 self._scoreboard = Scoreboard( 286 label=bs.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 # Show controls help in demo or arcade modes. 554 env = bs.app.env 555 if env.demo or env.arcade: 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 bs.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 bs.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) -> bs.Actor: 829 # We keep track of who got hurt each wave for score purposes. 830 player.has_been_hurt = False 831 pos = ( 832 self._spawn_center[0] + random.uniform(-1.5, 1.5), 833 self._spawn_center[1], 834 self._spawn_center[2] + random.uniform(-1.5, 1.5), 835 ) 836 spaz = self.spawn_player_spaz(player, position=pos) 837 if self._preset in { 838 Preset.TRAINING_EASY, 839 Preset.ROOKIE_EASY, 840 Preset.PRO_EASY, 841 Preset.UBER_EASY, 842 }: 843 spaz.impact_scale = 0.25 844 spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) 845 return spaz 846 847 def _handle_player_dropped_bomb( 848 self, player: bs.Actor, bomb: bs.Actor 849 ) -> None: 850 del player, bomb # Unused. 851 self._player_has_dropped_bomb = True 852 853 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 854 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 855 forcetype=poweruptype, excludetypes=self._excluded_powerups 856 ) 857 PowerupBox( 858 position=self.map.powerup_spawn_points[index], 859 poweruptype=poweruptype, 860 ).autoretain() 861 862 def _start_powerup_drops(self) -> None: 863 self._powerup_drop_timer = bs.Timer( 864 3.0, bs.WeakCall(self._drop_powerups), repeat=True 865 ) 866 867 def _drop_powerups( 868 self, standard_points: bool = False, poweruptype: str | None = None 869 ) -> None: 870 """Generic powerup drop.""" 871 if standard_points: 872 points = self.map.powerup_spawn_points 873 for i in range(len(points)): 874 bs.timer( 875 1.0 + i * 0.5, 876 bs.WeakCall( 877 self._drop_powerup, i, poweruptype if i == 0 else None 878 ), 879 ) 880 else: 881 point = ( 882 self._powerup_center[0] 883 + random.uniform( 884 -1.0 * self._powerup_spread[0], 885 1.0 * self._powerup_spread[0], 886 ), 887 self._powerup_center[1], 888 self._powerup_center[2] 889 + random.uniform( 890 -self._powerup_spread[1], self._powerup_spread[1] 891 ), 892 ) 893 894 # Drop one random one somewhere. 895 PowerupBox( 896 position=point, 897 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 898 excludetypes=self._excluded_powerups 899 ), 900 ).autoretain() 901 902 def do_end(self, outcome: str, delay: float = 0.0) -> None: 903 """End the game with the specified outcome.""" 904 if outcome == 'defeat': 905 self.fade_to_red() 906 score: int | None 907 if self._wavenum >= 2: 908 score = self._score 909 fail_message = None 910 else: 911 score = None 912 fail_message = bs.Lstr(resource='reachWave2Text') 913 self.end( 914 { 915 'outcome': outcome, 916 'score': score, 917 'fail_message': fail_message, 918 'playerinfos': self.initialplayerinfos, 919 }, 920 delay=delay, 921 ) 922 923 def _award_completion_achievements(self) -> None: 924 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 925 self._award_achievement('Onslaught Training Victory', sound=False) 926 if not self._player_has_dropped_bomb: 927 self._award_achievement('Boxer', sound=False) 928 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 929 self._award_achievement('Rookie Onslaught Victory', sound=False) 930 if not self._a_player_has_been_hurt: 931 self._award_achievement('Flawless Victory', sound=False) 932 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 933 self._award_achievement('Pro Onslaught Victory', sound=False) 934 if not self._player_has_dropped_bomb: 935 self._award_achievement('Pro Boxer', sound=False) 936 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 937 self._award_achievement('Uber Onslaught Victory', sound=False) 938 939 def _update_waves(self) -> None: 940 # If we have no living bots, go to the next wave. 941 assert self._bots is not None 942 if ( 943 self._can_end_wave 944 and not self._bots.have_living_bots() 945 and not self._game_over 946 ): 947 self._can_end_wave = False 948 self._time_bonus_timer = None 949 self._time_bonus_text = None 950 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 951 won = False 952 else: 953 won = self._wavenum == len(self._waves) 954 955 base_delay = 4.0 if won else 0.0 956 957 # Reward time bonus. 958 if self._time_bonus > 0: 959 bs.timer(0, self._cashregistersound.play) 960 bs.timer( 961 base_delay, 962 bs.WeakCall(self._award_time_bonus, self._time_bonus), 963 ) 964 base_delay += 1.0 965 966 # Reward flawless bonus. 967 if self._wavenum > 0: 968 have_flawless = False 969 for player in self.players: 970 if player.is_alive() and not player.has_been_hurt: 971 have_flawless = True 972 bs.timer( 973 base_delay, 974 bs.WeakCall(self._award_flawless_bonus, player), 975 ) 976 player.has_been_hurt = False # reset 977 if have_flawless: 978 base_delay += 1.0 979 980 if won: 981 self.show_zoom_message( 982 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 983 ) 984 self.celebrate(20.0) 985 self._award_completion_achievements() 986 bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus)) 987 base_delay += 0.85 988 self._winsound.play() 989 bs.cameraflash() 990 bs.setmusic(bs.MusicType.VICTORY) 991 self._game_over = True 992 993 # Can't just pass delay to do_end because our extra bonuses 994 # haven't been added yet (once we call do_end the score 995 # gets locked in). 996 bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory')) 997 return 998 999 self._wavenum += 1 1000 1001 # Short celebration after waves. 1002 if self._wavenum > 1: 1003 self.celebrate(0.5) 1004 bs.timer(base_delay, bs.WeakCall(self._start_next_wave)) 1005 1006 def _award_completion_bonus(self) -> None: 1007 self._cashregistersound.play() 1008 for player in self.players: 1009 try: 1010 if player.is_alive(): 1011 assert self.initialplayerinfos is not None 1012 self.stats.player_scored( 1013 player, 1014 int(100 / len(self.initialplayerinfos)), 1015 scale=1.4, 1016 color=(0.6, 0.6, 1.0, 1.0), 1017 title=bs.Lstr(resource='completionBonusText'), 1018 screenmessage=False, 1019 ) 1020 except Exception: 1021 logging.exception('error in _award_completion_bonus') 1022 1023 def _award_time_bonus(self, bonus: int) -> None: 1024 self._cashregistersound.play() 1025 PopupText( 1026 bs.Lstr( 1027 value='+${A} ${B}', 1028 subs=[ 1029 ('${A}', str(bonus)), 1030 ('${B}', bs.Lstr(resource='timeBonusText')), 1031 ], 1032 ), 1033 color=(1, 1, 0.5, 1), 1034 scale=1.0, 1035 position=(0, 3, -1), 1036 ).autoretain() 1037 self._score += self._time_bonus 1038 self._update_scores() 1039 1040 def _award_flawless_bonus(self, player: Player) -> None: 1041 self._cashregistersound.play() 1042 try: 1043 if player.is_alive(): 1044 assert self._flawless_bonus is not None 1045 self.stats.player_scored( 1046 player, 1047 self._flawless_bonus, 1048 scale=1.2, 1049 color=(0.6, 1.0, 0.6, 1.0), 1050 title=bs.Lstr(resource='flawlessWaveText'), 1051 screenmessage=False, 1052 ) 1053 except Exception: 1054 logging.exception('error in _award_flawless_bonus') 1055 1056 def _start_time_bonus_timer(self) -> None: 1057 self._time_bonus_timer = bs.Timer( 1058 1.0, bs.WeakCall(self._update_time_bonus), repeat=True 1059 ) 1060 1061 def _update_player_spawn_info(self) -> None: 1062 # If we have no living players lets just blank this. 1063 assert self._spawn_info_text is not None 1064 assert self._spawn_info_text.node 1065 if not any(player.is_alive() for player in self.teams[0].players): 1066 self._spawn_info_text.node.text = '' 1067 else: 1068 text: str | bs.Lstr = '' 1069 for player in self.players: 1070 if not player.is_alive() and ( 1071 self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1072 or (player.respawn_wave <= len(self._waves)) 1073 ): 1074 rtxt = bs.Lstr( 1075 resource='onslaughtRespawnText', 1076 subs=[ 1077 ('${PLAYER}', player.getname()), 1078 ('${WAVE}', str(player.respawn_wave)), 1079 ], 1080 ) 1081 text = bs.Lstr( 1082 value='${A}${B}\n', 1083 subs=[ 1084 ('${A}', text), 1085 ('${B}', rtxt), 1086 ], 1087 ) 1088 self._spawn_info_text.node.text = text 1089 1090 def _respawn_players_for_wave(self) -> None: 1091 # Respawn applicable players. 1092 if self._wavenum > 1 and not self.is_waiting_for_continue(): 1093 for player in self.players: 1094 if ( 1095 not player.is_alive() 1096 and player.respawn_wave == self._wavenum 1097 ): 1098 self.spawn_player(player) 1099 self._update_player_spawn_info() 1100 1101 def _setup_wave_spawns(self, wave: Wave) -> None: 1102 tval = 0.0 1103 dtime = 0.2 1104 if self._wavenum == 1: 1105 spawn_time = 3.973 1106 tval += 0.5 1107 else: 1108 spawn_time = 2.648 1109 1110 bot_angle = wave.base_angle 1111 self._time_bonus = 0 1112 self._flawless_bonus = 0 1113 for info in wave.entries: 1114 if info is None: 1115 continue 1116 if isinstance(info, Delay): 1117 spawn_time += info.duration 1118 continue 1119 if isinstance(info, Spacing): 1120 bot_angle += info.spacing 1121 continue 1122 bot_type_2 = info.bottype 1123 if bot_type_2 is not None: 1124 assert not isinstance(bot_type_2, str) 1125 self._time_bonus += bot_type_2.points_mult * 20 1126 self._flawless_bonus += bot_type_2.points_mult * 5 1127 1128 # If its got a position, use that. 1129 point = info.point 1130 if point is not None: 1131 assert bot_type_2 is not None 1132 spcall = bs.WeakCall( 1133 self.add_bot_at_point, point, bot_type_2, spawn_time 1134 ) 1135 bs.timer(tval, spcall) 1136 tval += dtime 1137 else: 1138 spacing = info.spacing 1139 bot_angle += spacing * 0.5 1140 if bot_type_2 is not None: 1141 tcall = bs.WeakCall( 1142 self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time 1143 ) 1144 bs.timer(tval, tcall) 1145 tval += dtime 1146 bot_angle += spacing * 0.5 1147 1148 # We can end the wave after all the spawning happens. 1149 bs.timer( 1150 tval + spawn_time - dtime + 0.01, 1151 bs.WeakCall(self._set_can_end_wave), 1152 ) 1153 1154 def _start_next_wave(self) -> None: 1155 # This can happen if we beat a wave as we die. 1156 # We don't wanna respawn players and whatnot if this happens. 1157 if self._game_over: 1158 return 1159 1160 self._respawn_players_for_wave() 1161 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 1162 wave = self._generate_random_wave() 1163 else: 1164 wave = self._waves[self._wavenum - 1] 1165 self._setup_wave_spawns(wave) 1166 self._update_wave_ui_and_bonuses() 1167 bs.timer(0.4, self._new_wave_sound.play) 1168 1169 def _update_wave_ui_and_bonuses(self) -> None: 1170 self.show_zoom_message( 1171 bs.Lstr( 1172 value='${A} ${B}', 1173 subs=[ 1174 ('${A}', bs.Lstr(resource='waveText')), 1175 ('${B}', str(self._wavenum)), 1176 ], 1177 ), 1178 scale=1.0, 1179 duration=1.0, 1180 trail=True, 1181 ) 1182 1183 # Reset our time bonus. 1184 tbtcolor = (1, 1, 0, 1) 1185 tbttxt = bs.Lstr( 1186 value='${A}: ${B}', 1187 subs=[ 1188 ('${A}', bs.Lstr(resource='timeBonusText')), 1189 ('${B}', str(self._time_bonus)), 1190 ], 1191 ) 1192 self._time_bonus_text = bs.NodeActor( 1193 bs.newnode( 1194 'text', 1195 attrs={ 1196 'v_attach': 'top', 1197 'h_attach': 'center', 1198 'h_align': 'center', 1199 'vr_depth': -30, 1200 'color': tbtcolor, 1201 'shadow': 1.0, 1202 'flatness': 1.0, 1203 'position': (0, -60), 1204 'scale': 0.8, 1205 'text': tbttxt, 1206 }, 1207 ) 1208 ) 1209 1210 bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer)) 1211 wtcolor = (1, 1, 1, 1) 1212 wttxt = bs.Lstr( 1213 value='${A} ${B}', 1214 subs=[ 1215 ('${A}', bs.Lstr(resource='waveText')), 1216 ( 1217 '${B}', 1218 str(self._wavenum) 1219 + ( 1220 '' 1221 if self._preset 1222 in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1223 else ('/' + str(len(self._waves))) 1224 ), 1225 ), 1226 ], 1227 ) 1228 self._wave_text = bs.NodeActor( 1229 bs.newnode( 1230 'text', 1231 attrs={ 1232 'v_attach': 'top', 1233 'h_attach': 'center', 1234 'h_align': 'center', 1235 'vr_depth': -10, 1236 'color': wtcolor, 1237 'shadow': 1.0, 1238 'flatness': 1.0, 1239 'position': (0, -40), 1240 'scale': 1.3, 1241 'text': wttxt, 1242 }, 1243 ) 1244 ) 1245 1246 def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]: 1247 level = self._wavenum 1248 bot_types = [ 1249 BomberBot, 1250 BrawlerBot, 1251 TriggerBot, 1252 ChargerBot, 1253 BomberBotPro, 1254 BrawlerBotPro, 1255 TriggerBotPro, 1256 BomberBotProShielded, 1257 ExplodeyBot, 1258 ChargerBotProShielded, 1259 StickyBot, 1260 BrawlerBotProShielded, 1261 TriggerBotProShielded, 1262 ] 1263 if level > 5: 1264 bot_types += [ 1265 ExplodeyBot, 1266 TriggerBotProShielded, 1267 BrawlerBotProShielded, 1268 ChargerBotProShielded, 1269 ] 1270 if level > 7: 1271 bot_types += [ 1272 ExplodeyBot, 1273 TriggerBotProShielded, 1274 BrawlerBotProShielded, 1275 ChargerBotProShielded, 1276 ] 1277 if level > 10: 1278 bot_types += [ 1279 TriggerBotProShielded, 1280 TriggerBotProShielded, 1281 TriggerBotProShielded, 1282 TriggerBotProShielded, 1283 ] 1284 if level > 13: 1285 bot_types += [ 1286 TriggerBotProShielded, 1287 TriggerBotProShielded, 1288 TriggerBotProShielded, 1289 TriggerBotProShielded, 1290 ] 1291 bot_levels = [ 1292 [b for b in bot_types if b.points_mult == 1], 1293 [b for b in bot_types if b.points_mult == 2], 1294 [b for b in bot_types if b.points_mult == 3], 1295 [b for b in bot_types if b.points_mult == 4], 1296 ] 1297 1298 # Make sure all lists have something in them 1299 if not all(bot_levels): 1300 raise RuntimeError('Got empty bot level') 1301 return bot_levels 1302 1303 def _add_entries_for_distribution_group( 1304 self, 1305 group: list[tuple[int, int]], 1306 bot_levels: list[list[type[SpazBot]]], 1307 all_entries: list[Spawn | Spacing | Delay | None], 1308 ) -> None: 1309 entries: list[Spawn | Spacing | Delay | None] = [] 1310 for entry in group: 1311 bot_level = bot_levels[entry[0] - 1] 1312 bot_type = bot_level[random.randrange(len(bot_level))] 1313 rval = random.random() 1314 if rval < 0.5: 1315 spacing = 10.0 1316 elif rval < 0.9: 1317 spacing = 20.0 1318 else: 1319 spacing = 40.0 1320 split = random.random() > 0.3 1321 for i in range(entry[1]): 1322 if split and i % 2 == 0: 1323 entries.insert(0, Spawn(bot_type, spacing=spacing)) 1324 else: 1325 entries.append(Spawn(bot_type, spacing=spacing)) 1326 if entries: 1327 all_entries += entries 1328 all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0)) 1329 1330 def _generate_random_wave(self) -> Wave: 1331 level = self._wavenum 1332 bot_levels = self._bot_levels_for_wave() 1333 1334 target_points = level * 3 - 2 1335 min_dudes = min(1 + level // 3, 10) 1336 max_dudes = min(10, level + 1) 1337 max_level = ( 1338 4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1)) 1339 ) 1340 group_count = 3 1341 distribution = self._get_distribution( 1342 target_points, min_dudes, max_dudes, group_count, max_level 1343 ) 1344 all_entries: list[Spawn | Spacing | Delay | None] = [] 1345 for group in distribution: 1346 self._add_entries_for_distribution_group( 1347 group, bot_levels, all_entries 1348 ) 1349 angle_rand = random.random() 1350 if angle_rand > 0.75: 1351 base_angle = 130.0 1352 elif angle_rand > 0.5: 1353 base_angle = 210.0 1354 elif angle_rand > 0.25: 1355 base_angle = 20.0 1356 else: 1357 base_angle = -30.0 1358 base_angle += (0.5 - random.random()) * 20.0 1359 wave = Wave(base_angle=base_angle, entries=all_entries) 1360 return wave 1361 1362 def add_bot_at_point( 1363 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1364 ) -> None: 1365 """Add a new bot at a specified named point.""" 1366 if self._game_over: 1367 return 1368 assert isinstance(point.value, str) 1369 pointpos = self.map.defs.points[point.value] 1370 assert self._bots is not None 1371 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time) 1372 1373 def add_bot_at_angle( 1374 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1375 ) -> None: 1376 """Add a new bot at a specified angle (for circular maps).""" 1377 if self._game_over: 1378 return 1379 angle_radians = angle / 57.2957795 1380 xval = math.sin(angle_radians) * 1.06 1381 zval = math.cos(angle_radians) * 1.06 1382 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1383 assert self._bots is not None 1384 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time) 1385 1386 def _update_time_bonus(self) -> None: 1387 self._time_bonus = int(self._time_bonus * 0.93) 1388 if self._time_bonus > 0 and self._time_bonus_text is not None: 1389 assert self._time_bonus_text.node 1390 self._time_bonus_text.node.text = bs.Lstr( 1391 value='${A}: ${B}', 1392 subs=[ 1393 ('${A}', bs.Lstr(resource='timeBonusText')), 1394 ('${B}', str(self._time_bonus)), 1395 ], 1396 ) 1397 else: 1398 self._time_bonus_text = None 1399 1400 def _start_updating_waves(self) -> None: 1401 self._wave_update_timer = bs.Timer( 1402 2.0, bs.WeakCall(self._update_waves), repeat=True 1403 ) 1404 1405 def _update_scores(self) -> None: 1406 score = self._score 1407 if self._preset is Preset.ENDLESS: 1408 if score >= 500: 1409 self._award_achievement('Onslaught Master') 1410 if score >= 1000: 1411 self._award_achievement('Onslaught Wizard') 1412 if score >= 5000: 1413 self._award_achievement('Onslaught God') 1414 assert self._scoreboard is not None 1415 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1416 1417 def handlemessage(self, msg: Any) -> Any: 1418 if isinstance(msg, PlayerSpazHurtMessage): 1419 msg.spaz.getplayer(Player, True).has_been_hurt = True 1420 self._a_player_has_been_hurt = True 1421 1422 elif isinstance(msg, bs.PlayerScoredMessage): 1423 self._score += msg.score 1424 self._update_scores() 1425 1426 elif isinstance(msg, bs.PlayerDiedMessage): 1427 super().handlemessage(msg) # Augment standard behavior. 1428 player = msg.getplayer(Player) 1429 self._a_player_has_been_hurt = True 1430 1431 # Make note with the player when they can respawn: 1432 if self._wavenum < 10: 1433 player.respawn_wave = max(2, self._wavenum + 1) 1434 elif self._wavenum < 15: 1435 player.respawn_wave = max(2, self._wavenum + 2) 1436 else: 1437 player.respawn_wave = max(2, self._wavenum + 3) 1438 bs.timer(0.1, self._update_player_spawn_info) 1439 bs.timer(0.1, self._checkroundover) 1440 1441 elif isinstance(msg, SpazBotDiedMessage): 1442 pts, importance = msg.spazbot.get_death_points(msg.how) 1443 if msg.killerplayer is not None: 1444 self._handle_kill_achievements(msg) 1445 target: Sequence[float] | None 1446 if msg.spazbot.node: 1447 target = msg.spazbot.node.position 1448 else: 1449 target = None 1450 1451 killerplayer = msg.killerplayer 1452 self.stats.player_scored( 1453 killerplayer, 1454 pts, 1455 target=target, 1456 kill=True, 1457 screenmessage=False, 1458 importance=importance, 1459 ) 1460 dingsound = ( 1461 self._dingsound if importance == 1 else self._dingsoundhigh 1462 ) 1463 dingsound.play(volume=0.6) 1464 1465 # Normally we pull scores from the score-set, but if there's 1466 # no player lets be explicit. 1467 else: 1468 self._score += pts 1469 self._update_scores() 1470 else: 1471 super().handlemessage(msg) 1472 1473 def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1474 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 1475 self._handle_training_kill_achievements(msg) 1476 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 1477 self._handle_rookie_kill_achievements(msg) 1478 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 1479 self._handle_pro_kill_achievements(msg) 1480 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 1481 self._handle_uber_kill_achievements(msg) 1482 1483 def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1484 # Uber mine achievement: 1485 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1486 self._land_mine_kills += 1 1487 if self._land_mine_kills >= 6: 1488 self._award_achievement('Gold Miner') 1489 1490 # Uber tnt achievement: 1491 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1492 self._tnt_kills += 1 1493 if self._tnt_kills >= 6: 1494 bs.timer( 1495 0.5, bs.WeakCall(self._award_achievement, 'TNT Terror') 1496 ) 1497 1498 def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1499 # TNT achievement: 1500 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1501 self._tnt_kills += 1 1502 if self._tnt_kills >= 3: 1503 bs.timer( 1504 0.5, 1505 bs.WeakCall( 1506 self._award_achievement, 'Boom Goes the Dynamite' 1507 ), 1508 ) 1509 1510 def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1511 # Land-mine achievement: 1512 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1513 self._land_mine_kills += 1 1514 if self._land_mine_kills >= 3: 1515 self._award_achievement('Mine Games') 1516 1517 def _handle_training_kill_achievements( 1518 self, msg: SpazBotDiedMessage 1519 ) -> None: 1520 # Toss-off-map achievement: 1521 if msg.spazbot.last_attacked_type == ('picked_up', 'default'): 1522 self._throw_off_kills += 1 1523 if self._throw_off_kills >= 3: 1524 self._award_achievement('Off You Go Then') 1525 1526 def _set_can_end_wave(self) -> None: 1527 self._can_end_wave = True 1528 1529 def end_game(self) -> None: 1530 # Tell our bots to celebrate just to rub it in. 1531 assert self._bots is not None 1532 self._bots.final_celebrate() 1533 self._game_over = True 1534 self.do_end('defeat', delay=2.0) 1535 bs.setmusic(None) 1536 1537 def on_continue(self) -> None: 1538 for player in self.players: 1539 if not player.is_alive(): 1540 self.spawn_player(player) 1541 1542 def _checkroundover(self) -> None: 1543 """Potentially end the round based on the state of the game.""" 1544 if self.has_ended(): 1545 return 1546 if not any(player.is_alive() for player in self.teams[0].players): 1547 # Allow continuing after wave 1. 1548 if self._wavenum > 1: 1549 self.continue_or_end_game() 1550 else: 1551 self.end_game()
56@dataclass 57class Wave: 58 """A wave of enemies.""" 59 60 entries: list[Spawn | Spacing | Delay | None] 61 base_angle: float = 0.0
A wave of enemies.
64@dataclass 65class Spawn: 66 """A bot spawn event in a wave.""" 67 68 bottype: type[SpazBot] | str 69 point: Point | None = None 70 spacing: float = 5.0
A bot spawn event in a wave.
Empty space in a wave.
A delay between events in a wave.
87class Preset(Enum): 88 """Game presets we support.""" 89 90 TRAINING = 'training' 91 TRAINING_EASY = 'training_easy' 92 ROOKIE = 'rookie' 93 ROOKIE_EASY = 'rookie_easy' 94 PRO = 'pro' 95 PRO_EASY = 'pro_easy' 96 UBER = 'uber' 97 UBER_EASY = 'uber_easy' 98 ENDLESS = 'endless' 99 ENDLESS_TOURNAMENT = 'endless_tournament'
Game presets we support.
Inherited Members
- enum.Enum
- name
- value
102@unique 103class Point(Enum): 104 """Points on the map we can spawn at.""" 105 106 LEFT_UPPER_MORE = 'bot_spawn_left_upper_more' 107 LEFT_UPPER = 'bot_spawn_left_upper' 108 TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right' 109 RIGHT_UPPER = 'bot_spawn_right_upper' 110 TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left' 111 TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right' 112 TURRET_TOP_LEFT = 'bot_spawn_turret_top_left' 113 TOP_RIGHT = 'bot_spawn_top_right' 114 TOP_LEFT = 'bot_spawn_top_left' 115 TOP = 'bot_spawn_top' 116 BOTTOM = 'bot_spawn_bottom' 117 LEFT = 'bot_spawn_left' 118 RIGHT = 'bot_spawn_right' 119 RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more' 120 RIGHT_LOWER = 'bot_spawn_right_lower' 121 RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more' 122 BOTTOM_RIGHT = 'bot_spawn_bottom_right' 123 BOTTOM_LEFT = 'bot_spawn_bottom_left' 124 TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right' 125 TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left' 126 LEFT_LOWER = 'bot_spawn_left_lower' 127 LEFT_LOWER_MORE = 'bot_spawn_left_lower_more' 128 TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle' 129 BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right' 130 BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left' 131 TOP_HALF_RIGHT = 'bot_spawn_top_half_right' 132 TOP_HALF_LEFT = 'bot_spawn_top_half_left'
Points on the map we can spawn at.
Inherited Members
- enum.Enum
- name
- value
135class Player(bs.Player['Team']): 136 """Our player type for this game.""" 137 138 def __init__(self) -> None: 139 self.has_been_hurt = False 140 self.respawn_wave = 0
Our player type for this game.
Inherited Members
- bascenev1._player.Player
- character
- actor
- color
- highlight
- on_expire
- team
- customdata
- sessionplayer
- node
- position
- exists
- getname
- is_alive
- get_icon
- assigninput
- resetinput
Our team type for this game.
Inherited Members
- bascenev1._team.Team
- players
- id
- name
- color
- manual_init
- customdata
- on_expire
- sessionteam
147class OnslaughtGame(bs.CoopGameActivity[Player, Team]): 148 """Co-op game where players try to survive attacking waves of enemies.""" 149 150 name = 'Onslaught' 151 description = 'Defeat all enemies.' 152 153 tips: list[str | bs.GameTip] = [ 154 'Hold any button to run.' 155 ' (Trigger buttons work well if you have them)', 156 'Try tricking enemies into killing eachother or running off cliffs.', 157 'Try \'Cooking off\' bombs for a second or two before throwing them.', 158 'It\'s easier to win with a friend or two helping.', 159 'If you stay in one place, you\'re toast. Run and dodge to survive..', 160 'Practice using your momentum to throw bombs more accurately.', 161 'Your punches do much more damage if you are running or spinning.', 162 ] 163 164 # Show messages when players die since it matters here. 165 announce_player_deaths = True 166 167 def __init__(self, settings: dict): 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 = bs.getsound('scoreHit01') 184 self._winsound = bs.getsound('score') 185 self._cashregistersound = bs.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 RuntimeError('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: bs.NodeActor | None = None 209 self._dingsound = bs.getsound('dingSmall') 210 self._dingsoundhigh = bs.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: bs.Timer | None = None 217 self._time_bonus_timer: bs.Timer | None = None 218 self._time_bonus_text: bs.NodeActor | None = None 219 self._flawless_bonus: int | None = None 220 self._wave_text: bs.NodeActor | None = None 221 self._wave_update_timer: bs.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 = bs.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 bs.GameTip( 237 'Land-mines are a good way to stop speedy enemies.', 238 icon=bs.gettexture('powerupLandMines'), 239 sound=bs.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 bs.GameTip( 250 'Take out a group of enemies by\n' 251 'setting off a bomb near a TNT box.', 252 icon=bs.gettexture('tnt'), 253 sound=bs.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 bs.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=bs.gettexture('powerupCurse'), 267 sound=bs.getsound('ding'), 268 ) 269 ] 270 271 self._spawn_info_text = bs.NodeActor( 272 bs.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 bs.setmusic(bs.MusicType.ONSLAUGHT) 285 286 self._scoreboard = Scoreboard( 287 label=bs.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 # Show controls help in demo or arcade modes. 555 env = bs.app.env 556 if env.demo or env.arcade: 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 bs.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 bs.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) -> bs.Actor: 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: bs.Actor, bomb: bs.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 = bs.Timer( 865 3.0, bs.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 bs.timer( 876 1.0 + i * 0.5, 877 bs.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 = bs.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 # If we have no living bots, go to the next wave. 942 assert self._bots is not None 943 if ( 944 self._can_end_wave 945 and not self._bots.have_living_bots() 946 and not self._game_over 947 ): 948 self._can_end_wave = False 949 self._time_bonus_timer = None 950 self._time_bonus_text = None 951 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 952 won = False 953 else: 954 won = self._wavenum == len(self._waves) 955 956 base_delay = 4.0 if won else 0.0 957 958 # Reward time bonus. 959 if self._time_bonus > 0: 960 bs.timer(0, self._cashregistersound.play) 961 bs.timer( 962 base_delay, 963 bs.WeakCall(self._award_time_bonus, self._time_bonus), 964 ) 965 base_delay += 1.0 966 967 # Reward flawless bonus. 968 if self._wavenum > 0: 969 have_flawless = False 970 for player in self.players: 971 if player.is_alive() and not player.has_been_hurt: 972 have_flawless = True 973 bs.timer( 974 base_delay, 975 bs.WeakCall(self._award_flawless_bonus, player), 976 ) 977 player.has_been_hurt = False # reset 978 if have_flawless: 979 base_delay += 1.0 980 981 if won: 982 self.show_zoom_message( 983 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 984 ) 985 self.celebrate(20.0) 986 self._award_completion_achievements() 987 bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus)) 988 base_delay += 0.85 989 self._winsound.play() 990 bs.cameraflash() 991 bs.setmusic(bs.MusicType.VICTORY) 992 self._game_over = True 993 994 # Can't just pass delay to do_end because our extra bonuses 995 # haven't been added yet (once we call do_end the score 996 # gets locked in). 997 bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory')) 998 return 999 1000 self._wavenum += 1 1001 1002 # Short celebration after waves. 1003 if self._wavenum > 1: 1004 self.celebrate(0.5) 1005 bs.timer(base_delay, bs.WeakCall(self._start_next_wave)) 1006 1007 def _award_completion_bonus(self) -> None: 1008 self._cashregistersound.play() 1009 for player in self.players: 1010 try: 1011 if player.is_alive(): 1012 assert self.initialplayerinfos is not None 1013 self.stats.player_scored( 1014 player, 1015 int(100 / len(self.initialplayerinfos)), 1016 scale=1.4, 1017 color=(0.6, 0.6, 1.0, 1.0), 1018 title=bs.Lstr(resource='completionBonusText'), 1019 screenmessage=False, 1020 ) 1021 except Exception: 1022 logging.exception('error in _award_completion_bonus') 1023 1024 def _award_time_bonus(self, bonus: int) -> None: 1025 self._cashregistersound.play() 1026 PopupText( 1027 bs.Lstr( 1028 value='+${A} ${B}', 1029 subs=[ 1030 ('${A}', str(bonus)), 1031 ('${B}', bs.Lstr(resource='timeBonusText')), 1032 ], 1033 ), 1034 color=(1, 1, 0.5, 1), 1035 scale=1.0, 1036 position=(0, 3, -1), 1037 ).autoretain() 1038 self._score += self._time_bonus 1039 self._update_scores() 1040 1041 def _award_flawless_bonus(self, player: Player) -> None: 1042 self._cashregistersound.play() 1043 try: 1044 if player.is_alive(): 1045 assert self._flawless_bonus is not None 1046 self.stats.player_scored( 1047 player, 1048 self._flawless_bonus, 1049 scale=1.2, 1050 color=(0.6, 1.0, 0.6, 1.0), 1051 title=bs.Lstr(resource='flawlessWaveText'), 1052 screenmessage=False, 1053 ) 1054 except Exception: 1055 logging.exception('error in _award_flawless_bonus') 1056 1057 def _start_time_bonus_timer(self) -> None: 1058 self._time_bonus_timer = bs.Timer( 1059 1.0, bs.WeakCall(self._update_time_bonus), repeat=True 1060 ) 1061 1062 def _update_player_spawn_info(self) -> None: 1063 # If we have no living players lets just blank this. 1064 assert self._spawn_info_text is not None 1065 assert self._spawn_info_text.node 1066 if not any(player.is_alive() for player in self.teams[0].players): 1067 self._spawn_info_text.node.text = '' 1068 else: 1069 text: str | bs.Lstr = '' 1070 for player in self.players: 1071 if not player.is_alive() and ( 1072 self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1073 or (player.respawn_wave <= len(self._waves)) 1074 ): 1075 rtxt = bs.Lstr( 1076 resource='onslaughtRespawnText', 1077 subs=[ 1078 ('${PLAYER}', player.getname()), 1079 ('${WAVE}', str(player.respawn_wave)), 1080 ], 1081 ) 1082 text = bs.Lstr( 1083 value='${A}${B}\n', 1084 subs=[ 1085 ('${A}', text), 1086 ('${B}', rtxt), 1087 ], 1088 ) 1089 self._spawn_info_text.node.text = text 1090 1091 def _respawn_players_for_wave(self) -> None: 1092 # Respawn applicable players. 1093 if self._wavenum > 1 and not self.is_waiting_for_continue(): 1094 for player in self.players: 1095 if ( 1096 not player.is_alive() 1097 and player.respawn_wave == self._wavenum 1098 ): 1099 self.spawn_player(player) 1100 self._update_player_spawn_info() 1101 1102 def _setup_wave_spawns(self, wave: Wave) -> None: 1103 tval = 0.0 1104 dtime = 0.2 1105 if self._wavenum == 1: 1106 spawn_time = 3.973 1107 tval += 0.5 1108 else: 1109 spawn_time = 2.648 1110 1111 bot_angle = wave.base_angle 1112 self._time_bonus = 0 1113 self._flawless_bonus = 0 1114 for info in wave.entries: 1115 if info is None: 1116 continue 1117 if isinstance(info, Delay): 1118 spawn_time += info.duration 1119 continue 1120 if isinstance(info, Spacing): 1121 bot_angle += info.spacing 1122 continue 1123 bot_type_2 = info.bottype 1124 if bot_type_2 is not None: 1125 assert not isinstance(bot_type_2, str) 1126 self._time_bonus += bot_type_2.points_mult * 20 1127 self._flawless_bonus += bot_type_2.points_mult * 5 1128 1129 # If its got a position, use that. 1130 point = info.point 1131 if point is not None: 1132 assert bot_type_2 is not None 1133 spcall = bs.WeakCall( 1134 self.add_bot_at_point, point, bot_type_2, spawn_time 1135 ) 1136 bs.timer(tval, spcall) 1137 tval += dtime 1138 else: 1139 spacing = info.spacing 1140 bot_angle += spacing * 0.5 1141 if bot_type_2 is not None: 1142 tcall = bs.WeakCall( 1143 self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time 1144 ) 1145 bs.timer(tval, tcall) 1146 tval += dtime 1147 bot_angle += spacing * 0.5 1148 1149 # We can end the wave after all the spawning happens. 1150 bs.timer( 1151 tval + spawn_time - dtime + 0.01, 1152 bs.WeakCall(self._set_can_end_wave), 1153 ) 1154 1155 def _start_next_wave(self) -> None: 1156 # This can happen if we beat a wave as we die. 1157 # We don't wanna respawn players and whatnot if this happens. 1158 if self._game_over: 1159 return 1160 1161 self._respawn_players_for_wave() 1162 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 1163 wave = self._generate_random_wave() 1164 else: 1165 wave = self._waves[self._wavenum - 1] 1166 self._setup_wave_spawns(wave) 1167 self._update_wave_ui_and_bonuses() 1168 bs.timer(0.4, self._new_wave_sound.play) 1169 1170 def _update_wave_ui_and_bonuses(self) -> None: 1171 self.show_zoom_message( 1172 bs.Lstr( 1173 value='${A} ${B}', 1174 subs=[ 1175 ('${A}', bs.Lstr(resource='waveText')), 1176 ('${B}', str(self._wavenum)), 1177 ], 1178 ), 1179 scale=1.0, 1180 duration=1.0, 1181 trail=True, 1182 ) 1183 1184 # Reset our time bonus. 1185 tbtcolor = (1, 1, 0, 1) 1186 tbttxt = bs.Lstr( 1187 value='${A}: ${B}', 1188 subs=[ 1189 ('${A}', bs.Lstr(resource='timeBonusText')), 1190 ('${B}', str(self._time_bonus)), 1191 ], 1192 ) 1193 self._time_bonus_text = bs.NodeActor( 1194 bs.newnode( 1195 'text', 1196 attrs={ 1197 'v_attach': 'top', 1198 'h_attach': 'center', 1199 'h_align': 'center', 1200 'vr_depth': -30, 1201 'color': tbtcolor, 1202 'shadow': 1.0, 1203 'flatness': 1.0, 1204 'position': (0, -60), 1205 'scale': 0.8, 1206 'text': tbttxt, 1207 }, 1208 ) 1209 ) 1210 1211 bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer)) 1212 wtcolor = (1, 1, 1, 1) 1213 wttxt = bs.Lstr( 1214 value='${A} ${B}', 1215 subs=[ 1216 ('${A}', bs.Lstr(resource='waveText')), 1217 ( 1218 '${B}', 1219 str(self._wavenum) 1220 + ( 1221 '' 1222 if self._preset 1223 in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1224 else ('/' + str(len(self._waves))) 1225 ), 1226 ), 1227 ], 1228 ) 1229 self._wave_text = bs.NodeActor( 1230 bs.newnode( 1231 'text', 1232 attrs={ 1233 'v_attach': 'top', 1234 'h_attach': 'center', 1235 'h_align': 'center', 1236 'vr_depth': -10, 1237 'color': wtcolor, 1238 'shadow': 1.0, 1239 'flatness': 1.0, 1240 'position': (0, -40), 1241 'scale': 1.3, 1242 'text': wttxt, 1243 }, 1244 ) 1245 ) 1246 1247 def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]: 1248 level = self._wavenum 1249 bot_types = [ 1250 BomberBot, 1251 BrawlerBot, 1252 TriggerBot, 1253 ChargerBot, 1254 BomberBotPro, 1255 BrawlerBotPro, 1256 TriggerBotPro, 1257 BomberBotProShielded, 1258 ExplodeyBot, 1259 ChargerBotProShielded, 1260 StickyBot, 1261 BrawlerBotProShielded, 1262 TriggerBotProShielded, 1263 ] 1264 if level > 5: 1265 bot_types += [ 1266 ExplodeyBot, 1267 TriggerBotProShielded, 1268 BrawlerBotProShielded, 1269 ChargerBotProShielded, 1270 ] 1271 if level > 7: 1272 bot_types += [ 1273 ExplodeyBot, 1274 TriggerBotProShielded, 1275 BrawlerBotProShielded, 1276 ChargerBotProShielded, 1277 ] 1278 if level > 10: 1279 bot_types += [ 1280 TriggerBotProShielded, 1281 TriggerBotProShielded, 1282 TriggerBotProShielded, 1283 TriggerBotProShielded, 1284 ] 1285 if level > 13: 1286 bot_types += [ 1287 TriggerBotProShielded, 1288 TriggerBotProShielded, 1289 TriggerBotProShielded, 1290 TriggerBotProShielded, 1291 ] 1292 bot_levels = [ 1293 [b for b in bot_types if b.points_mult == 1], 1294 [b for b in bot_types if b.points_mult == 2], 1295 [b for b in bot_types if b.points_mult == 3], 1296 [b for b in bot_types if b.points_mult == 4], 1297 ] 1298 1299 # Make sure all lists have something in them 1300 if not all(bot_levels): 1301 raise RuntimeError('Got empty bot level') 1302 return bot_levels 1303 1304 def _add_entries_for_distribution_group( 1305 self, 1306 group: list[tuple[int, int]], 1307 bot_levels: list[list[type[SpazBot]]], 1308 all_entries: list[Spawn | Spacing | Delay | None], 1309 ) -> None: 1310 entries: list[Spawn | Spacing | Delay | None] = [] 1311 for entry in group: 1312 bot_level = bot_levels[entry[0] - 1] 1313 bot_type = bot_level[random.randrange(len(bot_level))] 1314 rval = random.random() 1315 if rval < 0.5: 1316 spacing = 10.0 1317 elif rval < 0.9: 1318 spacing = 20.0 1319 else: 1320 spacing = 40.0 1321 split = random.random() > 0.3 1322 for i in range(entry[1]): 1323 if split and i % 2 == 0: 1324 entries.insert(0, Spawn(bot_type, spacing=spacing)) 1325 else: 1326 entries.append(Spawn(bot_type, spacing=spacing)) 1327 if entries: 1328 all_entries += entries 1329 all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0)) 1330 1331 def _generate_random_wave(self) -> Wave: 1332 level = self._wavenum 1333 bot_levels = self._bot_levels_for_wave() 1334 1335 target_points = level * 3 - 2 1336 min_dudes = min(1 + level // 3, 10) 1337 max_dudes = min(10, level + 1) 1338 max_level = ( 1339 4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1)) 1340 ) 1341 group_count = 3 1342 distribution = self._get_distribution( 1343 target_points, min_dudes, max_dudes, group_count, max_level 1344 ) 1345 all_entries: list[Spawn | Spacing | Delay | None] = [] 1346 for group in distribution: 1347 self._add_entries_for_distribution_group( 1348 group, bot_levels, all_entries 1349 ) 1350 angle_rand = random.random() 1351 if angle_rand > 0.75: 1352 base_angle = 130.0 1353 elif angle_rand > 0.5: 1354 base_angle = 210.0 1355 elif angle_rand > 0.25: 1356 base_angle = 20.0 1357 else: 1358 base_angle = -30.0 1359 base_angle += (0.5 - random.random()) * 20.0 1360 wave = Wave(base_angle=base_angle, entries=all_entries) 1361 return wave 1362 1363 def add_bot_at_point( 1364 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1365 ) -> None: 1366 """Add a new bot at a specified named point.""" 1367 if self._game_over: 1368 return 1369 assert isinstance(point.value, str) 1370 pointpos = self.map.defs.points[point.value] 1371 assert self._bots is not None 1372 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time) 1373 1374 def add_bot_at_angle( 1375 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1376 ) -> None: 1377 """Add a new bot at a specified angle (for circular maps).""" 1378 if self._game_over: 1379 return 1380 angle_radians = angle / 57.2957795 1381 xval = math.sin(angle_radians) * 1.06 1382 zval = math.cos(angle_radians) * 1.06 1383 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1384 assert self._bots is not None 1385 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time) 1386 1387 def _update_time_bonus(self) -> None: 1388 self._time_bonus = int(self._time_bonus * 0.93) 1389 if self._time_bonus > 0 and self._time_bonus_text is not None: 1390 assert self._time_bonus_text.node 1391 self._time_bonus_text.node.text = bs.Lstr( 1392 value='${A}: ${B}', 1393 subs=[ 1394 ('${A}', bs.Lstr(resource='timeBonusText')), 1395 ('${B}', str(self._time_bonus)), 1396 ], 1397 ) 1398 else: 1399 self._time_bonus_text = None 1400 1401 def _start_updating_waves(self) -> None: 1402 self._wave_update_timer = bs.Timer( 1403 2.0, bs.WeakCall(self._update_waves), repeat=True 1404 ) 1405 1406 def _update_scores(self) -> None: 1407 score = self._score 1408 if self._preset is Preset.ENDLESS: 1409 if score >= 500: 1410 self._award_achievement('Onslaught Master') 1411 if score >= 1000: 1412 self._award_achievement('Onslaught Wizard') 1413 if score >= 5000: 1414 self._award_achievement('Onslaught God') 1415 assert self._scoreboard is not None 1416 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1417 1418 def handlemessage(self, msg: Any) -> Any: 1419 if isinstance(msg, PlayerSpazHurtMessage): 1420 msg.spaz.getplayer(Player, True).has_been_hurt = True 1421 self._a_player_has_been_hurt = True 1422 1423 elif isinstance(msg, bs.PlayerScoredMessage): 1424 self._score += msg.score 1425 self._update_scores() 1426 1427 elif isinstance(msg, bs.PlayerDiedMessage): 1428 super().handlemessage(msg) # Augment standard behavior. 1429 player = msg.getplayer(Player) 1430 self._a_player_has_been_hurt = True 1431 1432 # Make note with the player when they can respawn: 1433 if self._wavenum < 10: 1434 player.respawn_wave = max(2, self._wavenum + 1) 1435 elif self._wavenum < 15: 1436 player.respawn_wave = max(2, self._wavenum + 2) 1437 else: 1438 player.respawn_wave = max(2, self._wavenum + 3) 1439 bs.timer(0.1, self._update_player_spawn_info) 1440 bs.timer(0.1, self._checkroundover) 1441 1442 elif isinstance(msg, SpazBotDiedMessage): 1443 pts, importance = msg.spazbot.get_death_points(msg.how) 1444 if msg.killerplayer is not None: 1445 self._handle_kill_achievements(msg) 1446 target: Sequence[float] | None 1447 if msg.spazbot.node: 1448 target = msg.spazbot.node.position 1449 else: 1450 target = None 1451 1452 killerplayer = msg.killerplayer 1453 self.stats.player_scored( 1454 killerplayer, 1455 pts, 1456 target=target, 1457 kill=True, 1458 screenmessage=False, 1459 importance=importance, 1460 ) 1461 dingsound = ( 1462 self._dingsound if importance == 1 else self._dingsoundhigh 1463 ) 1464 dingsound.play(volume=0.6) 1465 1466 # Normally we pull scores from the score-set, but if there's 1467 # no player lets be explicit. 1468 else: 1469 self._score += pts 1470 self._update_scores() 1471 else: 1472 super().handlemessage(msg) 1473 1474 def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1475 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 1476 self._handle_training_kill_achievements(msg) 1477 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 1478 self._handle_rookie_kill_achievements(msg) 1479 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 1480 self._handle_pro_kill_achievements(msg) 1481 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 1482 self._handle_uber_kill_achievements(msg) 1483 1484 def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1485 # Uber mine achievement: 1486 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1487 self._land_mine_kills += 1 1488 if self._land_mine_kills >= 6: 1489 self._award_achievement('Gold Miner') 1490 1491 # Uber tnt achievement: 1492 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1493 self._tnt_kills += 1 1494 if self._tnt_kills >= 6: 1495 bs.timer( 1496 0.5, bs.WeakCall(self._award_achievement, 'TNT Terror') 1497 ) 1498 1499 def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1500 # TNT achievement: 1501 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1502 self._tnt_kills += 1 1503 if self._tnt_kills >= 3: 1504 bs.timer( 1505 0.5, 1506 bs.WeakCall( 1507 self._award_achievement, 'Boom Goes the Dynamite' 1508 ), 1509 ) 1510 1511 def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1512 # Land-mine achievement: 1513 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1514 self._land_mine_kills += 1 1515 if self._land_mine_kills >= 3: 1516 self._award_achievement('Mine Games') 1517 1518 def _handle_training_kill_achievements( 1519 self, msg: SpazBotDiedMessage 1520 ) -> None: 1521 # Toss-off-map achievement: 1522 if msg.spazbot.last_attacked_type == ('picked_up', 'default'): 1523 self._throw_off_kills += 1 1524 if self._throw_off_kills >= 3: 1525 self._award_achievement('Off You Go Then') 1526 1527 def _set_can_end_wave(self) -> None: 1528 self._can_end_wave = True 1529 1530 def end_game(self) -> None: 1531 # Tell our bots to celebrate just to rub it in. 1532 assert self._bots is not None 1533 self._bots.final_celebrate() 1534 self._game_over = True 1535 self.do_end('defeat', delay=2.0) 1536 bs.setmusic(None) 1537 1538 def on_continue(self) -> None: 1539 for player in self.players: 1540 if not player.is_alive(): 1541 self.spawn_player(player) 1542 1543 def _checkroundover(self) -> None: 1544 """Potentially end the round based on the state of the game.""" 1545 if self.has_ended(): 1546 return 1547 if not any(player.is_alive() for player in self.teams[0].players): 1548 # Allow continuing after wave 1. 1549 if self._wavenum > 1: 1550 self.continue_or_end_game() 1551 else: 1552 self.end_game()
Co-op game where players try to survive attacking waves of enemies.
167 def __init__(self, settings: dict): 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 = bs.getsound('scoreHit01') 184 self._winsound = bs.getsound('score') 185 self._cashregistersound = bs.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 RuntimeError('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: bs.NodeActor | None = None 209 self._dingsound = bs.getsound('dingSmall') 210 self._dingsoundhigh = bs.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: bs.Timer | None = None 217 self._time_bonus_timer: bs.Timer | None = None 218 self._time_bonus_text: bs.NodeActor | None = None 219 self._flawless_bonus: int | None = None 220 self._wave_text: bs.NodeActor | None = None 221 self._wave_update_timer: bs.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 = bs.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 bs.GameTip( 237 'Land-mines are a good way to stop speedy enemies.', 238 icon=bs.gettexture('powerupLandMines'), 239 sound=bs.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 bs.GameTip( 250 'Take out a group of enemies by\n' 251 'setting off a bomb near a TNT box.', 252 icon=bs.gettexture('tnt'), 253 sound=bs.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 bs.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=bs.gettexture('powerupCurse'), 267 sound=bs.getsound('ding'), 268 ) 269 ] 270 271 self._spawn_info_text = bs.NodeActor( 272 bs.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 bs.setmusic(bs.MusicType.ONSLAUGHT) 285 286 self._scoreboard = Scoreboard( 287 label=bs.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 bascenev1.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 # Show controls help in demo or arcade modes. 555 env = bs.app.env 556 if env.demo or env.arcade: 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 bs.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 bs.timer(4.0, self._start_updating_waves)
Called once the previous Activity has finished transitioning out.
At this point the activity's initial players and teams are filled in and it should begin its actual game logic.
829 def spawn_player(self, player: Player) -> bs.Actor: 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
Spawn something for the provided bascenev1.Player.
The default implementation simply calls spawn_player_spaz().
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 = bs.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 )
End the game with the specified outcome.
1363 def add_bot_at_point( 1364 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1365 ) -> None: 1366 """Add a new bot at a specified named point.""" 1367 if self._game_over: 1368 return 1369 assert isinstance(point.value, str) 1370 pointpos = self.map.defs.points[point.value] 1371 assert self._bots is not None 1372 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)
Add a new bot at a specified named point.
1374 def add_bot_at_angle( 1375 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1376 ) -> None: 1377 """Add a new bot at a specified angle (for circular maps).""" 1378 if self._game_over: 1379 return 1380 angle_radians = angle / 57.2957795 1381 xval = math.sin(angle_radians) * 1.06 1382 zval = math.cos(angle_radians) * 1.06 1383 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1384 assert self._bots is not None 1385 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)
Add a new bot at a specified angle (for circular maps).
1418 def handlemessage(self, msg: Any) -> Any: 1419 if isinstance(msg, PlayerSpazHurtMessage): 1420 msg.spaz.getplayer(Player, True).has_been_hurt = True 1421 self._a_player_has_been_hurt = True 1422 1423 elif isinstance(msg, bs.PlayerScoredMessage): 1424 self._score += msg.score 1425 self._update_scores() 1426 1427 elif isinstance(msg, bs.PlayerDiedMessage): 1428 super().handlemessage(msg) # Augment standard behavior. 1429 player = msg.getplayer(Player) 1430 self._a_player_has_been_hurt = True 1431 1432 # Make note with the player when they can respawn: 1433 if self._wavenum < 10: 1434 player.respawn_wave = max(2, self._wavenum + 1) 1435 elif self._wavenum < 15: 1436 player.respawn_wave = max(2, self._wavenum + 2) 1437 else: 1438 player.respawn_wave = max(2, self._wavenum + 3) 1439 bs.timer(0.1, self._update_player_spawn_info) 1440 bs.timer(0.1, self._checkroundover) 1441 1442 elif isinstance(msg, SpazBotDiedMessage): 1443 pts, importance = msg.spazbot.get_death_points(msg.how) 1444 if msg.killerplayer is not None: 1445 self._handle_kill_achievements(msg) 1446 target: Sequence[float] | None 1447 if msg.spazbot.node: 1448 target = msg.spazbot.node.position 1449 else: 1450 target = None 1451 1452 killerplayer = msg.killerplayer 1453 self.stats.player_scored( 1454 killerplayer, 1455 pts, 1456 target=target, 1457 kill=True, 1458 screenmessage=False, 1459 importance=importance, 1460 ) 1461 dingsound = ( 1462 self._dingsound if importance == 1 else self._dingsoundhigh 1463 ) 1464 dingsound.play(volume=0.6) 1465 1466 # Normally we pull scores from the score-set, but if there's 1467 # no player lets be explicit. 1468 else: 1469 self._score += pts 1470 self._update_scores() 1471 else: 1472 super().handlemessage(msg)
General message handling; can be passed any message object.
1530 def end_game(self) -> None: 1531 # Tell our bots to celebrate just to rub it in. 1532 assert self._bots is not None 1533 self._bots.final_celebrate() 1534 self._game_over = True 1535 self.do_end('defeat', delay=2.0) 1536 bs.setmusic(None)
Tell the game to wrap up and call bascenev1.Activity.end().
This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (bascenev1.GameActivity.setup_standard_time_limit()) will work with the game.
1538 def on_continue(self) -> None: 1539 for player in self.players: 1540 if not player.is_alive(): 1541 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
- bascenev1._coopgame.CoopGameActivity
- session
- supports_session_type
- get_score_type
- celebrate
- spawn_player_spaz
- fade_to_red
- setup_low_life_warning_sound
- bascenev1._gameactivity.GameActivity
- available_settings
- scoreconfig
- allow_pausing
- allow_kick_idle_players
- show_kill_points
- default_music
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- initialplayerinfos
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- end
- respawn_player
- spawn_player_if_exists
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- bascenev1._activity.Activity
- settings_raw
- teams
- players
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- paused_text
- preloads
- lobby
- context
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
- bascenev1._dependency.DependencyComponent
- dep_is_present
- get_dynamic_deps