bascenev1lib.game.onslaught
Provides Onslaught Co-op game.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Provides Onslaught Co-op game.""" 4 5# Yes this is a long one.. 6# pylint: disable=too-many-lines 7 8# ba_meta require api 8 9# (see https://ballistica.net/wiki/meta-tag-system) 10 11from __future__ import annotations 12 13import math 14import random 15import logging 16from enum import Enum, unique 17from dataclasses import dataclass 18from typing import TYPE_CHECKING, 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 max_iterations = 10 + max_dudes * 2 809 810 groups: list[list[tuple[int, int]]] = [] 811 for _g in range(group_count): 812 groups.append([]) 813 types = [1] 814 if max_level > 1: 815 types.append(2) 816 if max_level > 2: 817 types.append(3) 818 if max_level > 3: 819 types.append(4) 820 for iteration in range(max_iterations): 821 diff = self._add_dist_entry_if_possible( 822 groups, max_dudes, target_points, types 823 ) 824 825 total_points, total_dudes = self._get_dist_grp_totals(groups) 826 full = total_points >= target_points 827 828 if full: 829 # Every so often, delete a random entry just to 830 # shake up our distribution. 831 if random.random() < 0.2 and iteration != max_iterations - 1: 832 self._delete_random_dist_entry(groups) 833 834 # If we don't have enough dudes, kill the group with 835 # the biggest point value. 836 elif ( 837 total_dudes < min_dudes and iteration != max_iterations - 1 838 ): 839 self._delete_biggest_dist_entry(groups) 840 841 # If we've got too many dudes, kill the group with the 842 # smallest point value. 843 elif ( 844 total_dudes > max_dudes and iteration != max_iterations - 1 845 ): 846 self._delete_smallest_dist_entry(groups) 847 848 # Close enough.. we're done. 849 else: 850 if diff == 0: 851 break 852 853 return groups 854 855 def _add_dist_entry_if_possible( 856 self, 857 groups: list[list[tuple[int, int]]], 858 max_dudes: int, 859 target_points: int, 860 types: list[int], 861 ) -> int: 862 # See how much we're off our target by. 863 total_points, total_dudes = self._get_dist_grp_totals(groups) 864 diff = target_points - total_points 865 dudes_diff = max_dudes - total_dudes 866 867 # Add an entry if one will fit. 868 value = types[random.randrange(len(types))] 869 group = groups[random.randrange(len(groups))] 870 if not group: 871 max_count = random.randint(1, 6) 872 else: 873 max_count = 2 * random.randint(1, 3) 874 max_count = min(max_count, dudes_diff) 875 count = min(max_count, diff // value) 876 if count > 0: 877 group.append((value, count)) 878 total_points += value * count 879 total_dudes += count 880 diff = target_points - total_points 881 return diff 882 883 def _delete_smallest_dist_entry( 884 self, groups: list[list[tuple[int, int]]] 885 ) -> None: 886 smallest_value = 9999 887 smallest_entry = None 888 smallest_entry_group = None 889 for group in groups: 890 for entry in group: 891 if entry[0] < smallest_value or smallest_entry is None: 892 smallest_value = entry[0] 893 smallest_entry = entry 894 smallest_entry_group = group 895 assert smallest_entry is not None 896 assert smallest_entry_group is not None 897 smallest_entry_group.remove(smallest_entry) 898 899 def _delete_biggest_dist_entry( 900 self, groups: list[list[tuple[int, int]]] 901 ) -> None: 902 biggest_value = 9999 903 biggest_entry = None 904 biggest_entry_group = None 905 for group in groups: 906 for entry in group: 907 if entry[0] > biggest_value or biggest_entry is None: 908 biggest_value = entry[0] 909 biggest_entry = entry 910 biggest_entry_group = group 911 if biggest_entry is not None: 912 assert biggest_entry_group is not None 913 biggest_entry_group.remove(biggest_entry) 914 915 def _delete_random_dist_entry( 916 self, groups: list[list[tuple[int, int]]] 917 ) -> None: 918 entry_count = 0 919 for group in groups: 920 for _ in group: 921 entry_count += 1 922 if entry_count > 1: 923 del_entry = random.randrange(entry_count) 924 entry_count = 0 925 for group in groups: 926 for entry in group: 927 if entry_count == del_entry: 928 group.remove(entry) 929 break 930 entry_count += 1 931 932 @override 933 def spawn_player(self, player: Player) -> bs.Actor: 934 # We keep track of who got hurt each wave for score purposes. 935 player.has_been_hurt = False 936 pos = ( 937 self._spawn_center[0] + random.uniform(-1.5, 1.5), 938 self._spawn_center[1], 939 self._spawn_center[2] + random.uniform(-1.5, 1.5), 940 ) 941 spaz = self.spawn_player_spaz(player, position=pos) 942 if self._preset in { 943 Preset.TRAINING_EASY, 944 Preset.ROOKIE_EASY, 945 Preset.PRO_EASY, 946 Preset.UBER_EASY, 947 }: 948 spaz.impact_scale = 0.25 949 spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb) 950 return spaz 951 952 def _handle_player_dropped_bomb( 953 self, player: bs.Actor, bomb: bs.Actor 954 ) -> None: 955 del player, bomb # Unused. 956 self._player_has_dropped_bomb = True 957 958 def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: 959 poweruptype = PowerupBoxFactory.get().get_random_powerup_type( 960 forcetype=poweruptype, excludetypes=self._excluded_powerups 961 ) 962 PowerupBox( 963 position=self.map.powerup_spawn_points[index], 964 poweruptype=poweruptype, 965 ).autoretain() 966 967 def _start_powerup_drops(self) -> None: 968 self._powerup_drop_timer = bs.Timer( 969 3.0, bs.WeakCall(self._drop_powerups), repeat=True 970 ) 971 972 def _drop_powerups( 973 self, standard_points: bool = False, poweruptype: str | None = None 974 ) -> None: 975 """Generic powerup drop.""" 976 if standard_points: 977 points = self.map.powerup_spawn_points 978 for i in range(len(points)): 979 bs.timer( 980 1.0 + i * 0.5, 981 bs.WeakCall( 982 self._drop_powerup, i, poweruptype if i == 0 else None 983 ), 984 ) 985 else: 986 point = ( 987 self._powerup_center[0] 988 + random.uniform( 989 -1.0 * self._powerup_spread[0], 990 1.0 * self._powerup_spread[0], 991 ), 992 self._powerup_center[1], 993 self._powerup_center[2] 994 + random.uniform( 995 -self._powerup_spread[1], self._powerup_spread[1] 996 ), 997 ) 998 999 # Drop one random one somewhere. 1000 PowerupBox( 1001 position=point, 1002 poweruptype=PowerupBoxFactory.get().get_random_powerup_type( 1003 excludetypes=self._excluded_powerups 1004 ), 1005 ).autoretain() 1006 1007 def do_end(self, outcome: str, delay: float = 0.0) -> None: 1008 """End the game with the specified outcome.""" 1009 if outcome == 'defeat': 1010 self.fade_to_red() 1011 score: int | None 1012 if self._wavenum >= 2: 1013 score = self._score 1014 fail_message = None 1015 else: 1016 score = None 1017 fail_message = bs.Lstr(resource='reachWave2Text') 1018 self.end( 1019 { 1020 'outcome': outcome, 1021 'score': score, 1022 'fail_message': fail_message, 1023 'playerinfos': self.initialplayerinfos, 1024 }, 1025 delay=delay, 1026 ) 1027 1028 def _award_completion_achievements(self) -> None: 1029 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 1030 self._award_achievement('Onslaught Training Victory', sound=False) 1031 if not self._player_has_dropped_bomb: 1032 self._award_achievement('Boxer', sound=False) 1033 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 1034 self._award_achievement('Rookie Onslaught Victory', sound=False) 1035 if not self._a_player_has_been_hurt: 1036 self._award_achievement('Flawless Victory', sound=False) 1037 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 1038 self._award_achievement('Pro Onslaught Victory', sound=False) 1039 if not self._player_has_dropped_bomb: 1040 self._award_achievement('Pro Boxer', sound=False) 1041 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 1042 self._award_achievement('Uber Onslaught Victory', sound=False) 1043 1044 def _update_waves(self) -> None: 1045 # If we have no living bots, go to the next wave. 1046 assert self._bots is not None 1047 if ( 1048 self._can_end_wave 1049 and not self._bots.have_living_bots() 1050 and not self._game_over 1051 ): 1052 self._can_end_wave = False 1053 self._time_bonus_timer = None 1054 self._time_bonus_text = None 1055 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 1056 won = False 1057 else: 1058 won = self._wavenum == len(self._waves) 1059 1060 base_delay = 4.0 if won else 0.0 1061 1062 # Reward time bonus. 1063 if self._time_bonus > 0: 1064 bs.timer(0, self._cashregistersound.play) 1065 bs.timer( 1066 base_delay, 1067 bs.WeakCall(self._award_time_bonus, self._time_bonus), 1068 ) 1069 base_delay += 1.0 1070 1071 # Reward flawless bonus. 1072 if self._wavenum > 0: 1073 have_flawless = False 1074 for player in self.players: 1075 if player.is_alive() and not player.has_been_hurt: 1076 have_flawless = True 1077 bs.timer( 1078 base_delay, 1079 bs.WeakCall(self._award_flawless_bonus, player), 1080 ) 1081 player.has_been_hurt = False # reset 1082 if have_flawless: 1083 base_delay += 1.0 1084 1085 if won: 1086 self.show_zoom_message( 1087 bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0 1088 ) 1089 self.celebrate(20.0) 1090 self._award_completion_achievements() 1091 bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus)) 1092 base_delay += 0.85 1093 self._winsound.play() 1094 bs.cameraflash() 1095 bs.setmusic(bs.MusicType.VICTORY) 1096 self._game_over = True 1097 1098 # Can't just pass delay to do_end because our extra bonuses 1099 # haven't been added yet (once we call do_end the score 1100 # gets locked in). 1101 bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory')) 1102 return 1103 1104 self._wavenum += 1 1105 1106 # Short celebration after waves. 1107 if self._wavenum > 1: 1108 self.celebrate(0.5) 1109 bs.timer(base_delay, bs.WeakCall(self._start_next_wave)) 1110 1111 def _award_completion_bonus(self) -> None: 1112 self._cashregistersound.play() 1113 for player in self.players: 1114 try: 1115 if player.is_alive(): 1116 assert self.initialplayerinfos is not None 1117 self.stats.player_scored( 1118 player, 1119 int(100 / len(self.initialplayerinfos)), 1120 scale=1.4, 1121 color=(0.6, 0.6, 1.0, 1.0), 1122 title=bs.Lstr(resource='completionBonusText'), 1123 screenmessage=False, 1124 ) 1125 except Exception: 1126 logging.exception('error in _award_completion_bonus') 1127 1128 def _award_time_bonus(self, bonus: int) -> None: 1129 self._cashregistersound.play() 1130 PopupText( 1131 bs.Lstr( 1132 value='+${A} ${B}', 1133 subs=[ 1134 ('${A}', str(bonus)), 1135 ('${B}', bs.Lstr(resource='timeBonusText')), 1136 ], 1137 ), 1138 color=(1, 1, 0.5, 1), 1139 scale=1.0, 1140 position=(0, 3, -1), 1141 ).autoretain() 1142 self._score += self._time_bonus 1143 self._update_scores() 1144 1145 def _award_flawless_bonus(self, player: Player) -> None: 1146 self._cashregistersound.play() 1147 try: 1148 if player.is_alive(): 1149 assert self._flawless_bonus is not None 1150 self.stats.player_scored( 1151 player, 1152 self._flawless_bonus, 1153 scale=1.2, 1154 color=(0.6, 1.0, 0.6, 1.0), 1155 title=bs.Lstr(resource='flawlessWaveText'), 1156 screenmessage=False, 1157 ) 1158 except Exception: 1159 logging.exception('error in _award_flawless_bonus') 1160 1161 def _start_time_bonus_timer(self) -> None: 1162 self._time_bonus_timer = bs.Timer( 1163 1.0, bs.WeakCall(self._update_time_bonus), repeat=True 1164 ) 1165 1166 def _update_player_spawn_info(self) -> None: 1167 # If we have no living players lets just blank this. 1168 assert self._spawn_info_text is not None 1169 assert self._spawn_info_text.node 1170 if not any(player.is_alive() for player in self.teams[0].players): 1171 self._spawn_info_text.node.text = '' 1172 else: 1173 text: str | bs.Lstr = '' 1174 for player in self.players: 1175 if not player.is_alive() and ( 1176 self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1177 or (player.respawn_wave <= len(self._waves)) 1178 ): 1179 rtxt = bs.Lstr( 1180 resource='onslaughtRespawnText', 1181 subs=[ 1182 ('${PLAYER}', player.getname()), 1183 ('${WAVE}', str(player.respawn_wave)), 1184 ], 1185 ) 1186 text = bs.Lstr( 1187 value='${A}${B}\n', 1188 subs=[ 1189 ('${A}', text), 1190 ('${B}', rtxt), 1191 ], 1192 ) 1193 self._spawn_info_text.node.text = text 1194 1195 def _respawn_players_for_wave(self) -> None: 1196 # Respawn applicable players. 1197 if self._wavenum > 1 and not self.is_waiting_for_continue(): 1198 for player in self.players: 1199 if ( 1200 not player.is_alive() 1201 and player.respawn_wave == self._wavenum 1202 ): 1203 self.spawn_player(player) 1204 self._update_player_spawn_info() 1205 1206 def _setup_wave_spawns(self, wave: Wave) -> None: 1207 tval = 0.0 1208 dtime = 0.2 1209 if self._wavenum == 1: 1210 spawn_time = 3.973 1211 tval += 0.5 1212 else: 1213 spawn_time = 2.648 1214 1215 bot_angle = wave.base_angle 1216 self._time_bonus = 0 1217 self._flawless_bonus = 0 1218 for info in wave.entries: 1219 if info is None: 1220 continue 1221 if isinstance(info, Delay): 1222 spawn_time += info.duration 1223 continue 1224 if isinstance(info, Spacing): 1225 bot_angle += info.spacing 1226 continue 1227 bot_type_2 = info.bottype 1228 if bot_type_2 is not None: 1229 assert not isinstance(bot_type_2, str) 1230 self._time_bonus += bot_type_2.points_mult * 20 1231 self._flawless_bonus += bot_type_2.points_mult * 5 1232 1233 # If its got a position, use that. 1234 point = info.point 1235 if point is not None: 1236 assert bot_type_2 is not None 1237 spcall = bs.WeakCall( 1238 self.add_bot_at_point, point, bot_type_2, spawn_time 1239 ) 1240 bs.timer(tval, spcall) 1241 tval += dtime 1242 else: 1243 spacing = info.spacing 1244 bot_angle += spacing * 0.5 1245 if bot_type_2 is not None: 1246 tcall = bs.WeakCall( 1247 self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time 1248 ) 1249 bs.timer(tval, tcall) 1250 tval += dtime 1251 bot_angle += spacing * 0.5 1252 1253 # We can end the wave after all the spawning happens. 1254 bs.timer( 1255 tval + spawn_time - dtime + 0.01, 1256 bs.WeakCall(self._set_can_end_wave), 1257 ) 1258 1259 def _start_next_wave(self) -> None: 1260 # This can happen if we beat a wave as we die. 1261 # We don't wanna respawn players and whatnot if this happens. 1262 if self._game_over: 1263 return 1264 1265 self._respawn_players_for_wave() 1266 if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}: 1267 wave = self._generate_random_wave() 1268 else: 1269 wave = self._waves[self._wavenum - 1] 1270 self._setup_wave_spawns(wave) 1271 self._update_wave_ui_and_bonuses() 1272 bs.timer(0.4, self._new_wave_sound.play) 1273 1274 def _update_wave_ui_and_bonuses(self) -> None: 1275 self.show_zoom_message( 1276 bs.Lstr( 1277 value='${A} ${B}', 1278 subs=[ 1279 ('${A}', bs.Lstr(resource='waveText')), 1280 ('${B}', str(self._wavenum)), 1281 ], 1282 ), 1283 scale=1.0, 1284 duration=1.0, 1285 trail=True, 1286 ) 1287 1288 # Reset our time bonus. 1289 tbtcolor = (1, 1, 0, 1) 1290 tbttxt = bs.Lstr( 1291 value='${A}: ${B}', 1292 subs=[ 1293 ('${A}', bs.Lstr(resource='timeBonusText')), 1294 ('${B}', str(self._time_bonus)), 1295 ], 1296 ) 1297 self._time_bonus_text = bs.NodeActor( 1298 bs.newnode( 1299 'text', 1300 attrs={ 1301 'v_attach': 'top', 1302 'h_attach': 'center', 1303 'h_align': 'center', 1304 'vr_depth': -30, 1305 'color': tbtcolor, 1306 'shadow': 1.0, 1307 'flatness': 1.0, 1308 'position': (0, -60), 1309 'scale': 0.8, 1310 'text': tbttxt, 1311 }, 1312 ) 1313 ) 1314 1315 bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer)) 1316 wtcolor = (1, 1, 1, 1) 1317 wttxt = bs.Lstr( 1318 value='${A} ${B}', 1319 subs=[ 1320 ('${A}', bs.Lstr(resource='waveText')), 1321 ( 1322 '${B}', 1323 str(self._wavenum) 1324 + ( 1325 '' 1326 if self._preset 1327 in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT] 1328 else ('/' + str(len(self._waves))) 1329 ), 1330 ), 1331 ], 1332 ) 1333 self._wave_text = bs.NodeActor( 1334 bs.newnode( 1335 'text', 1336 attrs={ 1337 'v_attach': 'top', 1338 'h_attach': 'center', 1339 'h_align': 'center', 1340 'vr_depth': -10, 1341 'color': wtcolor, 1342 'shadow': 1.0, 1343 'flatness': 1.0, 1344 'position': (0, -40), 1345 'scale': 1.3, 1346 'text': wttxt, 1347 }, 1348 ) 1349 ) 1350 1351 def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]: 1352 level = self._wavenum 1353 bot_types = [ 1354 BomberBot, 1355 BrawlerBot, 1356 TriggerBot, 1357 ChargerBot, 1358 BomberBotPro, 1359 BrawlerBotPro, 1360 TriggerBotPro, 1361 BomberBotProShielded, 1362 ExplodeyBot, 1363 ChargerBotProShielded, 1364 StickyBot, 1365 BrawlerBotProShielded, 1366 TriggerBotProShielded, 1367 ] 1368 if level > 5: 1369 bot_types += [ 1370 ExplodeyBot, 1371 TriggerBotProShielded, 1372 BrawlerBotProShielded, 1373 ChargerBotProShielded, 1374 ] 1375 if level > 7: 1376 bot_types += [ 1377 ExplodeyBot, 1378 TriggerBotProShielded, 1379 BrawlerBotProShielded, 1380 ChargerBotProShielded, 1381 ] 1382 if level > 10: 1383 bot_types += [ 1384 TriggerBotProShielded, 1385 TriggerBotProShielded, 1386 TriggerBotProShielded, 1387 TriggerBotProShielded, 1388 ] 1389 if level > 13: 1390 bot_types += [ 1391 TriggerBotProShielded, 1392 TriggerBotProShielded, 1393 TriggerBotProShielded, 1394 TriggerBotProShielded, 1395 ] 1396 bot_levels = [ 1397 [b for b in bot_types if b.points_mult == 1], 1398 [b for b in bot_types if b.points_mult == 2], 1399 [b for b in bot_types if b.points_mult == 3], 1400 [b for b in bot_types if b.points_mult == 4], 1401 ] 1402 1403 # Make sure all lists have something in them 1404 if not all(bot_levels): 1405 raise RuntimeError('Got empty bot level') 1406 return bot_levels 1407 1408 def _add_entries_for_distribution_group( 1409 self, 1410 group: list[tuple[int, int]], 1411 bot_levels: list[list[type[SpazBot]]], 1412 all_entries: list[Spawn | Spacing | Delay | None], 1413 ) -> None: 1414 entries: list[Spawn | Spacing | Delay | None] = [] 1415 for entry in group: 1416 bot_level = bot_levels[entry[0] - 1] 1417 bot_type = bot_level[random.randrange(len(bot_level))] 1418 rval = random.random() 1419 if rval < 0.5: 1420 spacing = 10.0 1421 elif rval < 0.9: 1422 spacing = 20.0 1423 else: 1424 spacing = 40.0 1425 split = random.random() > 0.3 1426 for i in range(entry[1]): 1427 if split and i % 2 == 0: 1428 entries.insert(0, Spawn(bot_type, spacing=spacing)) 1429 else: 1430 entries.append(Spawn(bot_type, spacing=spacing)) 1431 if entries: 1432 all_entries += entries 1433 all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0)) 1434 1435 def _generate_random_wave(self) -> Wave: 1436 level = self._wavenum 1437 bot_levels = self._bot_levels_for_wave() 1438 1439 target_points = level * 3 - 2 1440 min_dudes = min(1 + level // 3, 10) 1441 max_dudes = min(10, level + 1) 1442 max_level = ( 1443 4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1)) 1444 ) 1445 group_count = 3 1446 distribution = self._get_distribution( 1447 target_points, min_dudes, max_dudes, group_count, max_level 1448 ) 1449 all_entries: list[Spawn | Spacing | Delay | None] = [] 1450 for group in distribution: 1451 self._add_entries_for_distribution_group( 1452 group, bot_levels, all_entries 1453 ) 1454 angle_rand = random.random() 1455 if angle_rand > 0.75: 1456 base_angle = 130.0 1457 elif angle_rand > 0.5: 1458 base_angle = 210.0 1459 elif angle_rand > 0.25: 1460 base_angle = 20.0 1461 else: 1462 base_angle = -30.0 1463 base_angle += (0.5 - random.random()) * 20.0 1464 wave = Wave(base_angle=base_angle, entries=all_entries) 1465 return wave 1466 1467 def add_bot_at_point( 1468 self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0 1469 ) -> None: 1470 """Add a new bot at a specified named point.""" 1471 if self._game_over: 1472 return 1473 assert isinstance(point.value, str) 1474 pointpos = self.map.defs.points[point.value] 1475 assert self._bots is not None 1476 self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time) 1477 1478 def add_bot_at_angle( 1479 self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0 1480 ) -> None: 1481 """Add a new bot at a specified angle (for circular maps).""" 1482 if self._game_over: 1483 return 1484 angle_radians = angle / 57.2957795 1485 xval = math.sin(angle_radians) * 1.06 1486 zval = math.cos(angle_radians) * 1.06 1487 point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7) 1488 assert self._bots is not None 1489 self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time) 1490 1491 def _update_time_bonus(self) -> None: 1492 self._time_bonus = int(self._time_bonus * 0.93) 1493 if self._time_bonus > 0 and self._time_bonus_text is not None: 1494 assert self._time_bonus_text.node 1495 self._time_bonus_text.node.text = bs.Lstr( 1496 value='${A}: ${B}', 1497 subs=[ 1498 ('${A}', bs.Lstr(resource='timeBonusText')), 1499 ('${B}', str(self._time_bonus)), 1500 ], 1501 ) 1502 else: 1503 self._time_bonus_text = None 1504 1505 def _start_updating_waves(self) -> None: 1506 self._wave_update_timer = bs.Timer( 1507 2.0, bs.WeakCall(self._update_waves), repeat=True 1508 ) 1509 1510 def _update_scores(self) -> None: 1511 score = self._score 1512 if self._preset is Preset.ENDLESS: 1513 if score >= 500: 1514 self._award_achievement('Onslaught Master') 1515 if score >= 1000: 1516 self._award_achievement('Onslaught Wizard') 1517 if score >= 5000: 1518 self._award_achievement('Onslaught God') 1519 assert self._scoreboard is not None 1520 self._scoreboard.set_team_value(self.teams[0], score, max_score=None) 1521 1522 @override 1523 def handlemessage(self, msg: Any) -> Any: 1524 if isinstance(msg, PlayerSpazHurtMessage): 1525 msg.spaz.getplayer(Player, True).has_been_hurt = True 1526 self._a_player_has_been_hurt = True 1527 1528 elif isinstance(msg, bs.PlayerScoredMessage): 1529 self._score += msg.score 1530 self._update_scores() 1531 1532 elif isinstance(msg, bs.PlayerDiedMessage): 1533 super().handlemessage(msg) # Augment standard behavior. 1534 player = msg.getplayer(Player) 1535 self._a_player_has_been_hurt = True 1536 1537 # Make note with the player when they can respawn: 1538 if self._wavenum < 10: 1539 player.respawn_wave = max(2, self._wavenum + 1) 1540 elif self._wavenum < 15: 1541 player.respawn_wave = max(2, self._wavenum + 2) 1542 else: 1543 player.respawn_wave = max(2, self._wavenum + 3) 1544 bs.timer(0.1, self._update_player_spawn_info) 1545 bs.timer(0.1, self._checkroundover) 1546 1547 elif isinstance(msg, SpazBotDiedMessage): 1548 pts, importance = msg.spazbot.get_death_points(msg.how) 1549 if msg.killerplayer is not None: 1550 self._handle_kill_achievements(msg) 1551 target: Sequence[float] | None 1552 if msg.spazbot.node: 1553 target = msg.spazbot.node.position 1554 else: 1555 target = None 1556 1557 killerplayer = msg.killerplayer 1558 self.stats.player_scored( 1559 killerplayer, 1560 pts, 1561 target=target, 1562 kill=True, 1563 screenmessage=False, 1564 importance=importance, 1565 ) 1566 dingsound = ( 1567 self._dingsound if importance == 1 else self._dingsoundhigh 1568 ) 1569 dingsound.play(volume=0.6) 1570 1571 # Normally we pull scores from the score-set, but if there's 1572 # no player lets be explicit. 1573 else: 1574 self._score += pts 1575 self._update_scores() 1576 else: 1577 super().handlemessage(msg) 1578 1579 def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1580 if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}: 1581 self._handle_training_kill_achievements(msg) 1582 elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}: 1583 self._handle_rookie_kill_achievements(msg) 1584 elif self._preset in {Preset.PRO, Preset.PRO_EASY}: 1585 self._handle_pro_kill_achievements(msg) 1586 elif self._preset in {Preset.UBER, Preset.UBER_EASY}: 1587 self._handle_uber_kill_achievements(msg) 1588 1589 def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1590 # Uber mine achievement: 1591 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1592 self._land_mine_kills += 1 1593 if self._land_mine_kills >= 6: 1594 self._award_achievement('Gold Miner') 1595 1596 # Uber tnt achievement: 1597 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1598 self._tnt_kills += 1 1599 if self._tnt_kills >= 6: 1600 bs.timer( 1601 0.5, bs.WeakCall(self._award_achievement, 'TNT Terror') 1602 ) 1603 1604 def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1605 # TNT achievement: 1606 if msg.spazbot.last_attacked_type == ('explosion', 'tnt'): 1607 self._tnt_kills += 1 1608 if self._tnt_kills >= 3: 1609 bs.timer( 1610 0.5, 1611 bs.WeakCall( 1612 self._award_achievement, 'Boom Goes the Dynamite' 1613 ), 1614 ) 1615 1616 def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None: 1617 # Land-mine achievement: 1618 if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'): 1619 self._land_mine_kills += 1 1620 if self._land_mine_kills >= 3: 1621 self._award_achievement('Mine Games') 1622 1623 def _handle_training_kill_achievements( 1624 self, msg: SpazBotDiedMessage 1625 ) -> None: 1626 # Toss-off-map achievement: 1627 if msg.spazbot.last_attacked_type == ('picked_up', 'default'): 1628 self._throw_off_kills += 1 1629 if self._throw_off_kills >= 3: 1630 self._award_achievement('Off You Go Then') 1631 1632 def _set_can_end_wave(self) -> None: 1633 self._can_end_wave = True 1634 1635 @override 1636 def end_game(self) -> None: 1637 # Tell our bots to celebrate just to rub it in. 1638 assert self._bots is not None 1639 self._bots.final_celebrate() 1640 self._game_over = True 1641 self.do_end('defeat', delay=2.0) 1642 bs.setmusic(None) 1643 1644 @override 1645 def on_continue(self) -> None: 1646 for player in self.players: 1647 if not player.is_alive(): 1648 self.spawn_player(player) 1649 1650 def _checkroundover(self) -> None: 1651 """Potentially end the round based on the state of the game.""" 1652 if self.has_ended(): 1653 return 1654 if not any(player.is_alive() for player in self.teams[0].players): 1655 # Allow continuing after wave 1. 1656 if self._wavenum > 1: 1657 self.continue_or_end_game() 1658 else: 1659 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.
Inherited Members
- enum.Enum
- name
- value
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.
Inherited Members
- enum.Enum
- name
- value
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.
Inherited Members
- bascenev1._player.Player
- character
- actor
- color
- highlight
- on_expire
- team
- customdata
- sessionplayer
- node
- position
- exists
- getname
- is_alive
- get_icon
- assigninput
- resetinput
Our team type for this game.
Inherited Members
- bascenev1._team.Team
- players
- id
- name
- color
- manual_init
- customdata
- on_expire
- sessionteam
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 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 and not self.is_waiting_for_continue(): 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 @override 1646 def on_continue(self) -> None: 1647 for player in self.players: 1648 if not player.is_alive(): 1649 self.spawn_player(player) 1650 1651 def _checkroundover(self) -> None: 1652 """Potentially end the round based on the state of the game.""" 1653 if self.has_ended(): 1654 return 1655 if not any(player.is_alive() for player in self.teams[0].players): 1656 # Allow continuing after wave 1. 1657 if self._wavenum > 1: 1658 self.continue_or_end_game() 1659 else: 1660 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.
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
Spawn something for the provided bascenev1.Player.
The default implementation simply calls spawn_player_spaz().
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 )
End the game with the specified outcome.
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)
Add a new bot at a specified named point.
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)
Add a new bot at a specified angle (for circular maps).
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)
General message handling; can be passed any message object.
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)
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.
1645 @override 1646 def on_continue(self) -> None: 1647 for player in self.players: 1648 if not player.is_alive(): 1649 self.spawn_player(player)
This is called if a game supports and offers a continue and the player accepts. In this case the player should be given an extra life or whatever is relevant to keep the game going.
Inherited Members
- bascenev1._coopgame.CoopGameActivity
- session
- supports_session_type
- get_score_type
- celebrate
- spawn_player_spaz
- fade_to_red
- setup_low_life_warning_sound
- bascenev1._gameactivity.GameActivity
- available_settings
- scoreconfig
- allow_pausing
- allow_kick_idle_players
- show_kill_points
- default_music
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- initialplayerinfos
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- end
- respawn_player
- spawn_player_if_exists
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- bascenev1._activity.Activity
- settings_raw
- teams
- players
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- paused_text
- preloads
- lobby
- context
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
- bascenev1._dependency.DependencyComponent
- dep_is_present
- get_dynamic_deps