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