baclassic
Classic ballistica components.
This package is used as a 'dumping ground' for functionality that is necessary to keep legacy parts of the app working, but which may no longer be the best way to do things going forward.
New code should try to avoid using code from here when possible.
Functionality in this package should be exposed through the ClassicSubsystem. This allows type-checked code to go through the babase.app.classic singleton which forces it to explicitly handle the possibility of babase.app.classic being None. When code instead imports classic submodules directly, it is much harder to make it cleanly handle classic not being present.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Classic ballistica components. 4 5This package is used as a 'dumping ground' for functionality that is 6necessary to keep legacy parts of the app working, but which may no 7longer be the best way to do things going forward. 8 9New code should try to avoid using code from here when possible. 10 11Functionality in this package should be exposed through the 12ClassicSubsystem. This allows type-checked code to go through the 13babase.app.classic singleton which forces it to explicitly handle the 14possibility of babase.app.classic being None. When code instead imports 15classic submodules directly, it is much harder to make it cleanly handle 16classic not being present. 17""" 18 19# ba_meta require api 8 20 21# Note: Code relying on classic should import things from here *only* 22# for type-checking and use the versions in app.classic at runtime; that 23# way type-checking will cleanly cover the classic-not-present case 24# (app.classic being None). 25import logging 26 27from baclassic._subsystem import ClassicSubsystem 28from baclassic._achievement import Achievement, AchievementSubsystem 29 30__all__ = [ 31 'ClassicSubsystem', 32 'Achievement', 33 'AchievementSubsystem', 34] 35 36# Sanity check: we want to keep ballistica's dependencies and 37# bootstrapping order clearly defined; let's check a few particular 38# modules to make sure they never directly or indirectly import us 39# before their own execs complete. 40if __debug__: 41 for _mdl in 'babase', '_babase': 42 if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'): 43 logging.warning( 44 '%s was imported before %s finished importing;' 45 ' should not happen.', 46 __name__, 47 _mdl, 48 )
38class ClassicSubsystem(babase.AppSubsystem): 39 """Subsystem for classic functionality in the app. 40 41 The single shared instance of this app can be accessed at 42 babase.app.classic. Note that it is possible for babase.app.classic to 43 be None if the classic package is not present, and code should handle 44 that case gracefully. 45 """ 46 47 # pylint: disable=too-many-public-methods 48 49 # noinspection PyUnresolvedReferences 50 from baclassic._music import MusicPlayMode 51 52 def __init__(self) -> None: 53 super().__init__() 54 self._env = babase.env() 55 56 self.accounts = AccountV1Subsystem() 57 self.ads = AdsSubsystem() 58 self.ach = AchievementSubsystem() 59 self.store = StoreSubsystem() 60 self.music = MusicSubsystem() 61 62 # Co-op Campaigns. 63 self.campaigns: dict[str, bascenev1.Campaign] = {} 64 self.custom_coop_practice_games: list[str] = [] 65 66 # Lobby. 67 self.lobby_random_profile_index: int = 1 68 self.lobby_random_char_index_offset = random.randrange(1000) 69 self.lobby_account_profile_device_id: int | None = None 70 71 # Misc. 72 self.tips: list[str] = [] 73 self.stress_test_update_timer: babase.AppTimer | None = None 74 self.stress_test_update_timer_2: babase.AppTimer | None = None 75 self.value_test_defaults: dict = {} 76 self.special_offer: dict | None = None 77 self.ping_thread_count = 0 78 self.allow_ticket_purchases: bool = True 79 80 # Main Menu. 81 self.main_menu_did_initial_transition = False 82 self.main_menu_last_news_fetch_time: float | None = None 83 84 # Spaz. 85 self.spaz_appearances: dict[str, spazappearance.Appearance] = {} 86 self.last_spaz_turbo_warn_time = babase.AppTime(-99999.0) 87 88 # Server Mode. 89 self.server: ServerController | None = None 90 91 self.log_have_new = False 92 self.log_upload_timer_started = False 93 self.printed_live_object_warning = False 94 95 # We include this extra hash with shared input-mapping names so 96 # that we don't share mappings between differently-configured 97 # systems. For instance, different android devices may give different 98 # key values for the same controller type so we keep their mappings 99 # distinct. 100 self.input_map_hash: str | None = None 101 102 # Maps. 103 self.maps: dict[str, type[bascenev1.Map]] = {} 104 105 # Gameplay. 106 self.teams_series_length = 7 # deprecated, left for old mods 107 self.ffa_series_length = 24 # deprecated, left for old mods 108 self.coop_session_args: dict = {} 109 110 # UI. 111 self.first_main_menu = True # FIXME: Move to mainmenu class. 112 self.did_menu_intro = False # FIXME: Move to mainmenu class. 113 self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu. 114 self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any. 115 self.delegate: AppDelegate | None = None 116 self.party_window: weakref.ref[PartyWindow] | None = None 117 118 # Store. 119 self.store_layout: dict[str, list[dict[str, Any]]] | None = None 120 self.store_items: dict[str, dict] | None = None 121 self.pro_sale_start_time: int | None = None 122 self.pro_sale_start_val: int | None = None 123 124 @property 125 def platform(self) -> str: 126 """Name of the current platform. 127 128 Examples are: 'mac', 'windows', android'. 129 """ 130 assert isinstance(self._env['platform'], str) 131 return self._env['platform'] 132 133 def scene_v1_protocol_version(self) -> int: 134 """(internal)""" 135 return bascenev1.protocol_version() 136 137 @property 138 def subplatform(self) -> str: 139 """String for subplatform. 140 141 Can be empty. For the 'android' platform, subplatform may 142 be 'google', 'amazon', etc. 143 """ 144 assert isinstance(self._env['subplatform'], str) 145 return self._env['subplatform'] 146 147 @property 148 def legacy_user_agent_string(self) -> str: 149 """String containing various bits of info about OS/device/etc.""" 150 assert isinstance(self._env['legacy_user_agent_string'], str) 151 return self._env['legacy_user_agent_string'] 152 153 @override 154 def on_app_loading(self) -> None: 155 from bascenev1lib.actor import spazappearance 156 from bascenev1lib import maps as stdmaps 157 158 from baclassic._appdelegate import AppDelegate 159 160 plus = babase.app.plus 161 assert plus is not None 162 163 env = babase.app.env 164 cfg = babase.app.config 165 166 self.music.on_app_loading() 167 168 self.delegate = AppDelegate() 169 170 # Non-test, non-debug builds should generally be blessed; warn if not. 171 # (so I don't accidentally release a build that can't play tourneys) 172 if not env.debug and not env.test and not plus.is_blessed(): 173 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 174 175 # FIXME: This should not be hard-coded. 176 for maptype in [ 177 stdmaps.HockeyStadium, 178 stdmaps.FootballStadium, 179 stdmaps.Bridgit, 180 stdmaps.BigG, 181 stdmaps.Roundabout, 182 stdmaps.MonkeyFace, 183 stdmaps.ZigZag, 184 stdmaps.ThePad, 185 stdmaps.DoomShroom, 186 stdmaps.LakeFrigid, 187 stdmaps.TipTop, 188 stdmaps.CragCastle, 189 stdmaps.TowerD, 190 stdmaps.HappyThoughts, 191 stdmaps.StepRightUp, 192 stdmaps.Courtyard, 193 stdmaps.Rampage, 194 ]: 195 bascenev1.register_map(maptype) 196 197 spazappearance.register_appearances() 198 bascenev1.init_campaigns() 199 200 launch_count = cfg.get('launchCount', 0) 201 launch_count += 1 202 203 # So we know how many times we've run the game at various 204 # version milestones. 205 for key in ('lc14173', 'lc14292'): 206 cfg.setdefault(key, launch_count) 207 208 cfg['launchCount'] = launch_count 209 cfg.commit() 210 211 # Run a test in a few seconds to see if we should pop up an existing 212 # pending special offer. 213 def check_special_offer() -> None: 214 assert plus is not None 215 216 from bauiv1lib.specialoffer import show_offer 217 218 if ( 219 'pendingSpecialOffer' in cfg 220 and plus.get_v1_account_public_login_id() 221 == cfg['pendingSpecialOffer']['a'] 222 ): 223 self.special_offer = cfg['pendingSpecialOffer']['o'] 224 show_offer() 225 226 if babase.app.env.gui: 227 babase.apptimer(3.0, check_special_offer) 228 229 # If there's a leftover log file, attempt to upload it to the 230 # master-server and/or get rid of it. 231 babase.handle_leftover_v1_cloud_log_file() 232 233 self.accounts.on_app_loading() 234 235 @override 236 def on_app_suspend(self) -> None: 237 self.accounts.on_app_suspend() 238 239 @override 240 def on_app_unsuspend(self) -> None: 241 self.accounts.on_app_unsuspend() 242 self.music.on_app_unsuspend() 243 244 @override 245 def on_app_shutdown(self) -> None: 246 self.music.on_app_shutdown() 247 248 def pause(self) -> None: 249 """Pause the game due to a user request or menu popping up. 250 251 If there's a foreground host-activity that says it's pausable, tell it 252 to pause. Note: we now no longer pause if there are connected clients. 253 """ 254 activity: bascenev1.Activity | None = ( 255 bascenev1.get_foreground_host_activity() 256 ) 257 if ( 258 activity is not None 259 and activity.allow_pausing 260 and not bascenev1.have_connected_clients() 261 ): 262 from babase import Lstr 263 from bascenev1 import NodeActor 264 265 # FIXME: Shouldn't be touching scene stuff here; 266 # should just pass the request on to the host-session. 267 with activity.context: 268 globs = activity.globalsnode 269 if not globs.paused: 270 bascenev1.getsound('refWhistle').play() 271 globs.paused = True 272 273 # FIXME: This should not be an attr on Actor. 274 activity.paused_text = NodeActor( 275 bascenev1.newnode( 276 'text', 277 attrs={ 278 'text': Lstr(resource='pausedByHostText'), 279 'client_only': True, 280 'flatness': 1.0, 281 'h_align': 'center', 282 }, 283 ) 284 ) 285 286 def resume(self) -> None: 287 """Resume the game due to a user request or menu closing. 288 289 If there's a foreground host-activity that's currently paused, tell it 290 to resume. 291 """ 292 293 # FIXME: Shouldn't be touching scene stuff here; 294 # should just pass the request on to the host-session. 295 activity = bascenev1.get_foreground_host_activity() 296 if activity is not None: 297 with activity.context: 298 globs = activity.globalsnode 299 if globs.paused: 300 bascenev1.getsound('refWhistle').play() 301 globs.paused = False 302 303 # FIXME: This should not be an actor attr. 304 activity.paused_text = None 305 306 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 307 """Adds an individual level to the 'practice' section in Co-op.""" 308 309 # Assign this level to our catch-all campaign. 310 self.campaigns['Challenges'].addlevel(level) 311 312 # Make note to add it to our challenges UI. 313 self.custom_coop_practice_games.append(f'Challenges:{level.name}') 314 315 def launch_coop_game( 316 self, game: str, force: bool = False, args: dict | None = None 317 ) -> bool: 318 """High level way to launch a local co-op session.""" 319 # pylint: disable=cyclic-import 320 from bauiv1lib.coop.level import CoopLevelLockedWindow 321 322 assert babase.app.classic is not None 323 324 if args is None: 325 args = {} 326 if game == '': 327 raise ValueError('empty game name') 328 campaignname, levelname = game.split(':') 329 campaign = babase.app.classic.getcampaign(campaignname) 330 331 # If this campaign is sequential, make sure we've completed the 332 # one before this. 333 if campaign.sequential and not force: 334 for level in campaign.levels: 335 if level.name == levelname: 336 break 337 if not level.complete: 338 CoopLevelLockedWindow( 339 campaign.getlevel(levelname).displayname, 340 campaign.getlevel(level.name).displayname, 341 ) 342 return False 343 344 # Ok, we're good to go. 345 self.coop_session_args = { 346 'campaign': campaignname, 347 'level': levelname, 348 } 349 for arg_name, arg_val in list(args.items()): 350 self.coop_session_args[arg_name] = arg_val 351 352 def _fade_end() -> None: 353 from bascenev1 import CoopSession 354 355 try: 356 bascenev1.new_host_session(CoopSession) 357 except Exception: 358 logging.exception('Error creating coopsession after fade end.') 359 from bascenev1lib.mainmenu import MainMenuSession 360 361 bascenev1.new_host_session(MainMenuSession) 362 363 babase.fade_screen(False, endcall=_fade_end) 364 return True 365 366 def return_to_main_menu_session_gracefully( 367 self, reset_ui: bool = True 368 ) -> None: 369 """Attempt to cleanly get back to the main menu.""" 370 # pylint: disable=cyclic-import 371 from baclassic import _benchmark 372 from bascenev1lib.mainmenu import MainMenuSession 373 374 plus = babase.app.plus 375 assert plus is not None 376 377 if reset_ui: 378 babase.app.ui_v1.clear_main_menu_window() 379 380 if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession): 381 # It may be possible we're on the main menu but the screen is faded 382 # so fade back in. 383 babase.fade_screen(True) 384 return 385 386 _benchmark.stop_stress_test() # Stop stress-test if in progress. 387 388 # If we're in a host-session, tell them to end. 389 # This lets them tear themselves down gracefully. 390 host_session: bascenev1.Session | None = ( 391 bascenev1.get_foreground_host_session() 392 ) 393 if host_session is not None: 394 # Kick off a little transaction so we'll hopefully have all the 395 # latest account state when we get back to the menu. 396 plus.add_v1_account_transaction( 397 {'type': 'END_SESSION', 'sType': str(type(host_session))} 398 ) 399 plus.run_v1_account_transactions() 400 401 host_session.end() 402 403 # Otherwise just force the issue. 404 else: 405 babase.pushcall( 406 babase.Call(bascenev1.new_host_session, MainMenuSession) 407 ) 408 409 def getmaps(self, playtype: str) -> list[str]: 410 """Return a list of bascenev1.Map types supporting a playtype str. 411 412 Category: **Asset Functions** 413 414 Maps supporting a given playtype must provide a particular set of 415 features and lend themselves to a certain style of play. 416 417 Play Types: 418 419 'melee' 420 General fighting map. 421 Has one or more 'spawn' locations. 422 423 'team_flag' 424 For games such as Capture The Flag where each team spawns by a flag. 425 Has two or more 'spawn' locations, each with a corresponding 'flag' 426 location (based on index). 427 428 'single_flag' 429 For games such as King of the Hill or Keep Away where multiple teams 430 are fighting over a single flag. 431 Has two or more 'spawn' locations and 1 'flag_default' location. 432 433 'conquest' 434 For games such as Conquest where flags are spread throughout the map 435 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 436 437 'king_of_the_hill' - has 2+ 'spawn' locations, 438 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 439 440 'hockey' 441 For hockey games. 442 Has two 'goal' locations, corresponding 'spawn' locations, and one 443 'flag_default' location (for where puck spawns) 444 445 'football' 446 For football games. 447 Has two 'goal' locations, corresponding 'spawn' locations, and one 448 'flag_default' location (for where flag/ball/etc. spawns) 449 450 'race' 451 For racing games where players much touch each region in order. 452 Has two or more 'race_point' locations. 453 """ 454 return sorted( 455 key 456 for key, val in self.maps.items() 457 if playtype in val.get_play_types() 458 ) 459 460 def game_begin_analytics(self) -> None: 461 """(internal)""" 462 from baclassic import _analytics 463 464 _analytics.game_begin_analytics() 465 466 @classmethod 467 def json_prep(cls, data: Any) -> Any: 468 """Return a json-friendly version of the provided data. 469 470 This converts any tuples to lists and any bytes to strings 471 (interpreted as utf-8, ignoring errors). Logs errors (just once) 472 if any data is modified/discarded/unsupported. 473 """ 474 475 if isinstance(data, dict): 476 return dict( 477 (cls.json_prep(key), cls.json_prep(value)) 478 for key, value in list(data.items()) 479 ) 480 if isinstance(data, list): 481 return [cls.json_prep(element) for element in data] 482 if isinstance(data, tuple): 483 logging.exception('json_prep encountered tuple') 484 return [cls.json_prep(element) for element in data] 485 if isinstance(data, bytes): 486 try: 487 return data.decode(errors='ignore') 488 except Exception: 489 logging.exception('json_prep encountered utf-8 decode error') 490 return data.decode(errors='ignore') 491 if not isinstance(data, (str, float, bool, type(None), int)): 492 logging.exception( 493 'got unsupported type in json_prep: %s', type(data) 494 ) 495 return data 496 497 def master_server_v1_get( 498 self, 499 request: str, 500 data: dict[str, Any], 501 callback: MasterServerCallback | None = None, 502 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 503 ) -> None: 504 """Make a call to the master server via a http GET.""" 505 506 MasterServerV1CallThread( 507 request, 'get', data, callback, response_type 508 ).start() 509 510 def master_server_v1_post( 511 self, 512 request: str, 513 data: dict[str, Any], 514 callback: MasterServerCallback | None = None, 515 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 516 ) -> None: 517 """Make a call to the master server via a http POST.""" 518 MasterServerV1CallThread( 519 request, 'post', data, callback, response_type 520 ).start() 521 522 def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: 523 """Given a tournament entry, return strings for its prize levels.""" 524 from baclassic import _tournament 525 526 return _tournament.get_tournament_prize_strings(entry) 527 528 def getcampaign(self, name: str) -> bascenev1.Campaign: 529 """Return a campaign by name.""" 530 return self.campaigns[name] 531 532 def get_next_tip(self) -> str: 533 """Returns the next tip to be displayed.""" 534 if not self.tips: 535 for tip in get_all_tips(): 536 self.tips.insert(random.randint(0, len(self.tips)), tip) 537 tip = self.tips.pop() 538 return tip 539 540 def run_gpu_benchmark(self) -> None: 541 """Kick off a benchmark to test gpu speeds.""" 542 from baclassic._benchmark import run_gpu_benchmark as run 543 544 run() 545 546 def run_cpu_benchmark(self) -> None: 547 """Kick off a benchmark to test cpu speeds.""" 548 from baclassic._benchmark import run_cpu_benchmark as run 549 550 run() 551 552 def run_media_reload_benchmark(self) -> None: 553 """Kick off a benchmark to test media reloading speeds.""" 554 from baclassic._benchmark import run_media_reload_benchmark as run 555 556 run() 557 558 def run_stress_test( 559 self, 560 playlist_type: str = 'Random', 561 playlist_name: str = '__default__', 562 player_count: int = 8, 563 round_duration: int = 30, 564 attract_mode: bool = False, 565 ) -> None: 566 """Run a stress test.""" 567 from baclassic._benchmark import run_stress_test as run 568 569 run( 570 playlist_type=playlist_type, 571 playlist_name=playlist_name, 572 player_count=player_count, 573 round_duration=round_duration, 574 attract_mode=attract_mode, 575 ) 576 577 def get_input_device_mapped_value( 578 self, 579 device: bascenev1.InputDevice, 580 name: str, 581 default: bool = False, 582 ) -> Any: 583 """Return a mapped value for an input device. 584 585 This checks the user config and falls back to default values 586 where available. 587 """ 588 return _input.get_input_device_mapped_value( 589 device.name, device.unique_identifier, name, default 590 ) 591 592 def get_input_device_map_hash( 593 self, inputdevice: bascenev1.InputDevice 594 ) -> str: 595 """Given an input device, return hash based on its raw input values.""" 596 del inputdevice # unused currently 597 return _input.get_input_device_map_hash() 598 599 def get_input_device_config( 600 self, inputdevice: bascenev1.InputDevice, default: bool 601 ) -> tuple[dict, str]: 602 """Given an input device, return its config dict in the app config. 603 604 The dict will be created if it does not exist. 605 """ 606 return _input.get_input_device_config( 607 inputdevice.name, inputdevice.unique_identifier, default 608 ) 609 610 def get_player_colors(self) -> list[tuple[float, float, float]]: 611 """Return user-selectable player colors.""" 612 return bascenev1.get_player_colors() 613 614 def get_player_profile_icon(self, profilename: str) -> str: 615 """Given a profile name, returns an icon string for it. 616 617 (non-account profiles only) 618 """ 619 return bascenev1.get_player_profile_icon(profilename) 620 621 def get_player_profile_colors( 622 self, 623 profilename: str | None, 624 profiles: dict[str, dict[str, Any]] | None = None, 625 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 626 """Given a profile, return colors for them.""" 627 return bascenev1.get_player_profile_colors(profilename, profiles) 628 629 def get_foreground_host_session(self) -> bascenev1.Session | None: 630 """(internal)""" 631 return bascenev1.get_foreground_host_session() 632 633 def get_foreground_host_activity(self) -> bascenev1.Activity | None: 634 """(internal)""" 635 return bascenev1.get_foreground_host_activity() 636 637 def value_test( 638 self, 639 arg: str, 640 change: float | None = None, 641 absolute: float | None = None, 642 ) -> float: 643 """(internal)""" 644 return _baclassic.value_test(arg, change, absolute) 645 646 def set_master_server_source(self, source: int) -> None: 647 """(internal)""" 648 bascenev1.set_master_server_source(source) 649 650 def get_game_port(self) -> int: 651 """(internal)""" 652 return bascenev1.get_game_port() 653 654 def v2_upgrade_window(self, login_name: str, code: str) -> None: 655 """(internal)""" 656 657 from bauiv1lib.v2upgrade import V2UpgradeWindow 658 659 V2UpgradeWindow(login_name, code) 660 661 def account_link_code_window(self, data: dict[str, Any]) -> None: 662 """(internal)""" 663 from bauiv1lib.account.link import AccountLinkCodeWindow 664 665 AccountLinkCodeWindow(data) 666 667 def server_dialog(self, delay: float, data: dict[str, Any]) -> None: 668 """(internal)""" 669 from bauiv1lib.serverdialog import ( 670 ServerDialogData, 671 ServerDialogWindow, 672 ) 673 674 try: 675 sddata = dataclass_from_dict(ServerDialogData, data) 676 except Exception: 677 sddata = None 678 logging.warning( 679 'Got malformatted ServerDialogData: %s', 680 data, 681 ) 682 if sddata is not None: 683 babase.apptimer( 684 delay, 685 babase.Call(ServerDialogWindow, sddata), 686 ) 687 688 def ticket_icon_press(self) -> None: 689 """(internal)""" 690 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 691 692 ResourceTypeInfoWindow( 693 origin_widget=bauiv1.get_special_widget('tickets_info_button') 694 ) 695 696 def show_url_window(self, address: str) -> None: 697 """(internal)""" 698 from bauiv1lib.url import ShowURLWindow 699 700 ShowURLWindow(address) 701 702 def quit_window(self, quit_type: babase.QuitType) -> None: 703 """(internal)""" 704 from bauiv1lib.confirm import QuitWindow 705 706 QuitWindow(quit_type) 707 708 def tournament_entry_window( 709 self, 710 tournament_id: str, 711 tournament_activity: bascenev1.Activity | None = None, 712 position: tuple[float, float] = (0.0, 0.0), 713 delegate: Any = None, 714 scale: float | None = None, 715 offset: tuple[float, float] = (0.0, 0.0), 716 on_close_call: Callable[[], Any] | None = None, 717 ) -> None: 718 """(internal)""" 719 from bauiv1lib.tournamententry import TournamentEntryWindow 720 721 TournamentEntryWindow( 722 tournament_id, 723 tournament_activity, 724 position, 725 delegate, 726 scale, 727 offset, 728 on_close_call, 729 ) 730 731 def get_main_menu_session(self) -> type[bascenev1.Session]: 732 """(internal)""" 733 from bascenev1lib.mainmenu import MainMenuSession 734 735 return MainMenuSession 736 737 def continues_window( 738 self, 739 activity: bascenev1.Activity, 740 cost: int, 741 continue_call: Callable[[], Any], 742 cancel_call: Callable[[], Any], 743 ) -> None: 744 """(internal)""" 745 from bauiv1lib.continues import ContinuesWindow 746 747 ContinuesWindow(activity, cost, continue_call, cancel_call) 748 749 def profile_browser_window( 750 self, 751 transition: str = 'in_right', 752 in_main_menu: bool = True, 753 selected_profile: str | None = None, 754 origin_widget: bauiv1.Widget | None = None, 755 ) -> None: 756 """(internal)""" 757 from bauiv1lib.profile.browser import ProfileBrowserWindow 758 759 ProfileBrowserWindow( 760 transition, in_main_menu, selected_profile, origin_widget 761 ) 762 763 def preload_map_preview_media(self) -> None: 764 """Preload media needed for map preview UIs. 765 766 Category: **Asset Functions** 767 """ 768 try: 769 bauiv1.getmesh('level_select_button_opaque') 770 bauiv1.getmesh('level_select_button_transparent') 771 for maptype in list(self.maps.values()): 772 map_tex_name = maptype.get_preview_texture_name() 773 if map_tex_name is not None: 774 bauiv1.gettexture(map_tex_name) 775 except Exception: 776 logging.exception('Error preloading map preview media.') 777 778 def party_icon_activate(self, origin: Sequence[float]) -> None: 779 """(internal)""" 780 from bauiv1lib.party import PartyWindow 781 from babase import app 782 783 assert app.env.gui 784 785 bauiv1.getsound('swish').play() 786 787 # If it exists, dismiss it; otherwise make a new one. 788 party_window = ( 789 None if self.party_window is None else self.party_window() 790 ) 791 if party_window is not None: 792 party_window.close() 793 else: 794 self.party_window = weakref.ref(PartyWindow(origin=origin)) 795 796 def device_menu_press(self, device_id: int | None) -> None: 797 """(internal)""" 798 from bauiv1lib.mainmenu import MainMenuWindow 799 from bauiv1 import set_ui_input_device 800 801 assert babase.app is not None 802 in_main_menu = babase.app.ui_v1.has_main_menu_window() 803 if not in_main_menu: 804 set_ui_input_device(device_id) 805 806 if babase.app.env.gui: 807 bauiv1.getsound('swish').play() 808 809 babase.app.ui_v1.set_main_menu_window( 810 MainMenuWindow().get_root_widget(), 811 from_window=False, # Disable check here. 812 )
Subsystem for classic functionality in the app.
The single shared instance of this app can be accessed at babase.app.classic. Note that it is possible for babase.app.classic to be None if the classic package is not present, and code should handle that case gracefully.
124 @property 125 def platform(self) -> str: 126 """Name of the current platform. 127 128 Examples are: 'mac', 'windows', android'. 129 """ 130 assert isinstance(self._env['platform'], str) 131 return self._env['platform']
Name of the current platform.
Examples are: 'mac', 'windows', android'.
137 @property 138 def subplatform(self) -> str: 139 """String for subplatform. 140 141 Can be empty. For the 'android' platform, subplatform may 142 be 'google', 'amazon', etc. 143 """ 144 assert isinstance(self._env['subplatform'], str) 145 return self._env['subplatform']
String for subplatform.
Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.
147 @property 148 def legacy_user_agent_string(self) -> str: 149 """String containing various bits of info about OS/device/etc.""" 150 assert isinstance(self._env['legacy_user_agent_string'], str) 151 return self._env['legacy_user_agent_string']
String containing various bits of info about OS/device/etc.
153 @override 154 def on_app_loading(self) -> None: 155 from bascenev1lib.actor import spazappearance 156 from bascenev1lib import maps as stdmaps 157 158 from baclassic._appdelegate import AppDelegate 159 160 plus = babase.app.plus 161 assert plus is not None 162 163 env = babase.app.env 164 cfg = babase.app.config 165 166 self.music.on_app_loading() 167 168 self.delegate = AppDelegate() 169 170 # Non-test, non-debug builds should generally be blessed; warn if not. 171 # (so I don't accidentally release a build that can't play tourneys) 172 if not env.debug and not env.test and not plus.is_blessed(): 173 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 174 175 # FIXME: This should not be hard-coded. 176 for maptype in [ 177 stdmaps.HockeyStadium, 178 stdmaps.FootballStadium, 179 stdmaps.Bridgit, 180 stdmaps.BigG, 181 stdmaps.Roundabout, 182 stdmaps.MonkeyFace, 183 stdmaps.ZigZag, 184 stdmaps.ThePad, 185 stdmaps.DoomShroom, 186 stdmaps.LakeFrigid, 187 stdmaps.TipTop, 188 stdmaps.CragCastle, 189 stdmaps.TowerD, 190 stdmaps.HappyThoughts, 191 stdmaps.StepRightUp, 192 stdmaps.Courtyard, 193 stdmaps.Rampage, 194 ]: 195 bascenev1.register_map(maptype) 196 197 spazappearance.register_appearances() 198 bascenev1.init_campaigns() 199 200 launch_count = cfg.get('launchCount', 0) 201 launch_count += 1 202 203 # So we know how many times we've run the game at various 204 # version milestones. 205 for key in ('lc14173', 'lc14292'): 206 cfg.setdefault(key, launch_count) 207 208 cfg['launchCount'] = launch_count 209 cfg.commit() 210 211 # Run a test in a few seconds to see if we should pop up an existing 212 # pending special offer. 213 def check_special_offer() -> None: 214 assert plus is not None 215 216 from bauiv1lib.specialoffer import show_offer 217 218 if ( 219 'pendingSpecialOffer' in cfg 220 and plus.get_v1_account_public_login_id() 221 == cfg['pendingSpecialOffer']['a'] 222 ): 223 self.special_offer = cfg['pendingSpecialOffer']['o'] 224 show_offer() 225 226 if babase.app.env.gui: 227 babase.apptimer(3.0, check_special_offer) 228 229 # If there's a leftover log file, attempt to upload it to the 230 # master-server and/or get rid of it. 231 babase.handle_leftover_v1_cloud_log_file() 232 233 self.accounts.on_app_loading()
Called when the app reaches the loading state.
Note that subsystems created after the app switches to the loading state will not receive this callback. Subsystems created by plugins are an example of this.
239 @override 240 def on_app_unsuspend(self) -> None: 241 self.accounts.on_app_unsuspend() 242 self.music.on_app_unsuspend()
Called when the app exits the suspended state.
248 def pause(self) -> None: 249 """Pause the game due to a user request or menu popping up. 250 251 If there's a foreground host-activity that says it's pausable, tell it 252 to pause. Note: we now no longer pause if there are connected clients. 253 """ 254 activity: bascenev1.Activity | None = ( 255 bascenev1.get_foreground_host_activity() 256 ) 257 if ( 258 activity is not None 259 and activity.allow_pausing 260 and not bascenev1.have_connected_clients() 261 ): 262 from babase import Lstr 263 from bascenev1 import NodeActor 264 265 # FIXME: Shouldn't be touching scene stuff here; 266 # should just pass the request on to the host-session. 267 with activity.context: 268 globs = activity.globalsnode 269 if not globs.paused: 270 bascenev1.getsound('refWhistle').play() 271 globs.paused = True 272 273 # FIXME: This should not be an attr on Actor. 274 activity.paused_text = NodeActor( 275 bascenev1.newnode( 276 'text', 277 attrs={ 278 'text': Lstr(resource='pausedByHostText'), 279 'client_only': True, 280 'flatness': 1.0, 281 'h_align': 'center', 282 }, 283 ) 284 )
Pause the game due to a user request or menu popping up.
If there's a foreground host-activity that says it's pausable, tell it to pause. Note: we now no longer pause if there are connected clients.
286 def resume(self) -> None: 287 """Resume the game due to a user request or menu closing. 288 289 If there's a foreground host-activity that's currently paused, tell it 290 to resume. 291 """ 292 293 # FIXME: Shouldn't be touching scene stuff here; 294 # should just pass the request on to the host-session. 295 activity = bascenev1.get_foreground_host_activity() 296 if activity is not None: 297 with activity.context: 298 globs = activity.globalsnode 299 if globs.paused: 300 bascenev1.getsound('refWhistle').play() 301 globs.paused = False 302 303 # FIXME: This should not be an actor attr. 304 activity.paused_text = None
Resume the game due to a user request or menu closing.
If there's a foreground host-activity that's currently paused, tell it to resume.
306 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 307 """Adds an individual level to the 'practice' section in Co-op.""" 308 309 # Assign this level to our catch-all campaign. 310 self.campaigns['Challenges'].addlevel(level) 311 312 # Make note to add it to our challenges UI. 313 self.custom_coop_practice_games.append(f'Challenges:{level.name}')
Adds an individual level to the 'practice' section in Co-op.
315 def launch_coop_game( 316 self, game: str, force: bool = False, args: dict | None = None 317 ) -> bool: 318 """High level way to launch a local co-op session.""" 319 # pylint: disable=cyclic-import 320 from bauiv1lib.coop.level import CoopLevelLockedWindow 321 322 assert babase.app.classic is not None 323 324 if args is None: 325 args = {} 326 if game == '': 327 raise ValueError('empty game name') 328 campaignname, levelname = game.split(':') 329 campaign = babase.app.classic.getcampaign(campaignname) 330 331 # If this campaign is sequential, make sure we've completed the 332 # one before this. 333 if campaign.sequential and not force: 334 for level in campaign.levels: 335 if level.name == levelname: 336 break 337 if not level.complete: 338 CoopLevelLockedWindow( 339 campaign.getlevel(levelname).displayname, 340 campaign.getlevel(level.name).displayname, 341 ) 342 return False 343 344 # Ok, we're good to go. 345 self.coop_session_args = { 346 'campaign': campaignname, 347 'level': levelname, 348 } 349 for arg_name, arg_val in list(args.items()): 350 self.coop_session_args[arg_name] = arg_val 351 352 def _fade_end() -> None: 353 from bascenev1 import CoopSession 354 355 try: 356 bascenev1.new_host_session(CoopSession) 357 except Exception: 358 logging.exception('Error creating coopsession after fade end.') 359 from bascenev1lib.mainmenu import MainMenuSession 360 361 bascenev1.new_host_session(MainMenuSession) 362 363 babase.fade_screen(False, endcall=_fade_end) 364 return True
High level way to launch a local co-op session.
409 def getmaps(self, playtype: str) -> list[str]: 410 """Return a list of bascenev1.Map types supporting a playtype str. 411 412 Category: **Asset Functions** 413 414 Maps supporting a given playtype must provide a particular set of 415 features and lend themselves to a certain style of play. 416 417 Play Types: 418 419 'melee' 420 General fighting map. 421 Has one or more 'spawn' locations. 422 423 'team_flag' 424 For games such as Capture The Flag where each team spawns by a flag. 425 Has two or more 'spawn' locations, each with a corresponding 'flag' 426 location (based on index). 427 428 'single_flag' 429 For games such as King of the Hill or Keep Away where multiple teams 430 are fighting over a single flag. 431 Has two or more 'spawn' locations and 1 'flag_default' location. 432 433 'conquest' 434 For games such as Conquest where flags are spread throughout the map 435 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 436 437 'king_of_the_hill' - has 2+ 'spawn' locations, 438 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 439 440 'hockey' 441 For hockey games. 442 Has two 'goal' locations, corresponding 'spawn' locations, and one 443 'flag_default' location (for where puck spawns) 444 445 'football' 446 For football games. 447 Has two 'goal' locations, corresponding 'spawn' locations, and one 448 'flag_default' location (for where flag/ball/etc. spawns) 449 450 'race' 451 For racing games where players much touch each region in order. 452 Has two or more 'race_point' locations. 453 """ 454 return sorted( 455 key 456 for key, val in self.maps.items() 457 if playtype in val.get_play_types() 458 )
Return a list of bascenev1.Map types supporting a playtype str.
Category: Asset Functions
Maps supporting a given playtype must provide a particular set of features and lend themselves to a certain style of play.
Play Types:
'melee' General fighting map. Has one or more 'spawn' locations.
'team_flag' For games such as Capture The Flag where each team spawns by a flag. Has two or more 'spawn' locations, each with a corresponding 'flag' location (based on index).
'single_flag' For games such as King of the Hill or Keep Away where multiple teams are fighting over a single flag. Has two or more 'spawn' locations and 1 'flag_default' location.
'conquest' For games such as Conquest where flags are spread throughout the map
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
'hockey' For hockey games. Has two 'goal' locations, corresponding 'spawn' locations, and one 'flag_default' location (for where puck spawns)
'football' For football games. Has two 'goal' locations, corresponding 'spawn' locations, and one 'flag_default' location (for where flag/ball/etc. spawns)
'race' For racing games where players much touch each region in order. Has two or more 'race_point' locations.
466 @classmethod 467 def json_prep(cls, data: Any) -> Any: 468 """Return a json-friendly version of the provided data. 469 470 This converts any tuples to lists and any bytes to strings 471 (interpreted as utf-8, ignoring errors). Logs errors (just once) 472 if any data is modified/discarded/unsupported. 473 """ 474 475 if isinstance(data, dict): 476 return dict( 477 (cls.json_prep(key), cls.json_prep(value)) 478 for key, value in list(data.items()) 479 ) 480 if isinstance(data, list): 481 return [cls.json_prep(element) for element in data] 482 if isinstance(data, tuple): 483 logging.exception('json_prep encountered tuple') 484 return [cls.json_prep(element) for element in data] 485 if isinstance(data, bytes): 486 try: 487 return data.decode(errors='ignore') 488 except Exception: 489 logging.exception('json_prep encountered utf-8 decode error') 490 return data.decode(errors='ignore') 491 if not isinstance(data, (str, float, bool, type(None), int)): 492 logging.exception( 493 'got unsupported type in json_prep: %s', type(data) 494 ) 495 return data
Return a json-friendly version of the provided data.
This converts any tuples to lists and any bytes to strings (interpreted as utf-8, ignoring errors). Logs errors (just once) if any data is modified/discarded/unsupported.
497 def master_server_v1_get( 498 self, 499 request: str, 500 data: dict[str, Any], 501 callback: MasterServerCallback | None = None, 502 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 503 ) -> None: 504 """Make a call to the master server via a http GET.""" 505 506 MasterServerV1CallThread( 507 request, 'get', data, callback, response_type 508 ).start()
Make a call to the master server via a http GET.
510 def master_server_v1_post( 511 self, 512 request: str, 513 data: dict[str, Any], 514 callback: MasterServerCallback | None = None, 515 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 516 ) -> None: 517 """Make a call to the master server via a http POST.""" 518 MasterServerV1CallThread( 519 request, 'post', data, callback, response_type 520 ).start()
Make a call to the master server via a http POST.
522 def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: 523 """Given a tournament entry, return strings for its prize levels.""" 524 from baclassic import _tournament 525 526 return _tournament.get_tournament_prize_strings(entry)
Given a tournament entry, return strings for its prize levels.
528 def getcampaign(self, name: str) -> bascenev1.Campaign: 529 """Return a campaign by name.""" 530 return self.campaigns[name]
Return a campaign by name.
532 def get_next_tip(self) -> str: 533 """Returns the next tip to be displayed.""" 534 if not self.tips: 535 for tip in get_all_tips(): 536 self.tips.insert(random.randint(0, len(self.tips)), tip) 537 tip = self.tips.pop() 538 return tip
Returns the next tip to be displayed.
540 def run_gpu_benchmark(self) -> None: 541 """Kick off a benchmark to test gpu speeds.""" 542 from baclassic._benchmark import run_gpu_benchmark as run 543 544 run()
Kick off a benchmark to test gpu speeds.
546 def run_cpu_benchmark(self) -> None: 547 """Kick off a benchmark to test cpu speeds.""" 548 from baclassic._benchmark import run_cpu_benchmark as run 549 550 run()
Kick off a benchmark to test cpu speeds.
552 def run_media_reload_benchmark(self) -> None: 553 """Kick off a benchmark to test media reloading speeds.""" 554 from baclassic._benchmark import run_media_reload_benchmark as run 555 556 run()
Kick off a benchmark to test media reloading speeds.
558 def run_stress_test( 559 self, 560 playlist_type: str = 'Random', 561 playlist_name: str = '__default__', 562 player_count: int = 8, 563 round_duration: int = 30, 564 attract_mode: bool = False, 565 ) -> None: 566 """Run a stress test.""" 567 from baclassic._benchmark import run_stress_test as run 568 569 run( 570 playlist_type=playlist_type, 571 playlist_name=playlist_name, 572 player_count=player_count, 573 round_duration=round_duration, 574 attract_mode=attract_mode, 575 )
Run a stress test.
577 def get_input_device_mapped_value( 578 self, 579 device: bascenev1.InputDevice, 580 name: str, 581 default: bool = False, 582 ) -> Any: 583 """Return a mapped value for an input device. 584 585 This checks the user config and falls back to default values 586 where available. 587 """ 588 return _input.get_input_device_mapped_value( 589 device.name, device.unique_identifier, name, default 590 )
Return a mapped value for an input device.
This checks the user config and falls back to default values where available.
592 def get_input_device_map_hash( 593 self, inputdevice: bascenev1.InputDevice 594 ) -> str: 595 """Given an input device, return hash based on its raw input values.""" 596 del inputdevice # unused currently 597 return _input.get_input_device_map_hash()
Given an input device, return hash based on its raw input values.
599 def get_input_device_config( 600 self, inputdevice: bascenev1.InputDevice, default: bool 601 ) -> tuple[dict, str]: 602 """Given an input device, return its config dict in the app config. 603 604 The dict will be created if it does not exist. 605 """ 606 return _input.get_input_device_config( 607 inputdevice.name, inputdevice.unique_identifier, default 608 )
Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
610 def get_player_colors(self) -> list[tuple[float, float, float]]: 611 """Return user-selectable player colors.""" 612 return bascenev1.get_player_colors()
Return user-selectable player colors.
614 def get_player_profile_icon(self, profilename: str) -> str: 615 """Given a profile name, returns an icon string for it. 616 617 (non-account profiles only) 618 """ 619 return bascenev1.get_player_profile_icon(profilename)
Given a profile name, returns an icon string for it.
(non-account profiles only)
621 def get_player_profile_colors( 622 self, 623 profilename: str | None, 624 profiles: dict[str, dict[str, Any]] | None = None, 625 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 626 """Given a profile, return colors for them.""" 627 return bascenev1.get_player_profile_colors(profilename, profiles)
Given a profile, return colors for them.
763 def preload_map_preview_media(self) -> None: 764 """Preload media needed for map preview UIs. 765 766 Category: **Asset Functions** 767 """ 768 try: 769 bauiv1.getmesh('level_select_button_opaque') 770 bauiv1.getmesh('level_select_button_transparent') 771 for maptype in list(self.maps.values()): 772 map_tex_name = maptype.get_preview_texture_name() 773 if map_tex_name is not None: 774 bauiv1.gettexture(map_tex_name) 775 except Exception: 776 logging.exception('Error preloading map preview media.')
Preload media needed for map preview UIs.
Category: Asset Functions
Inherited Members
- babase._appsubsystem.AppSubsystem
- on_app_running
- on_app_shutdown_complete
- do_apply_app_config
21class MusicPlayMode(Enum): 22 """Influences behavior when playing music. 23 24 Category: **Enums** 25 """ 26 27 REGULAR = 'regular' 28 TEST = 'test'
Influences behavior when playing music.
Category: Enums
Inherited Members
- baclassic._music.MusicPlayMode
- REGULAR
- TEST
- enum.Enum
- name
- value
646class Achievement: 647 """Represents attributes and state for an individual achievement. 648 649 Category: **App Classes** 650 """ 651 652 def __init__( 653 self, 654 name: str, 655 icon_name: str, 656 icon_color: Sequence[float], 657 level_name: str, 658 award: int, 659 hard_mode_only: bool = False, 660 ): 661 self._name = name 662 self._icon_name = icon_name 663 self._icon_color: Sequence[float] = list(icon_color) + [1] 664 self._level_name = level_name 665 self._completion_banner_slot: int | None = None 666 self._award = award 667 self._hard_mode_only = hard_mode_only 668 669 @property 670 def name(self) -> str: 671 """The name of this achievement.""" 672 return self._name 673 674 @property 675 def level_name(self) -> str: 676 """The name of the level this achievement applies to.""" 677 return self._level_name 678 679 def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture: 680 """Return the icon texture to display for this achievement""" 681 return bauiv1.gettexture( 682 self._icon_name if complete else 'achievementEmpty' 683 ) 684 685 def get_icon_texture(self, complete: bool) -> bascenev1.Texture: 686 """Return the icon texture to display for this achievement""" 687 return bascenev1.gettexture( 688 self._icon_name if complete else 'achievementEmpty' 689 ) 690 691 def get_icon_color(self, complete: bool) -> Sequence[float]: 692 """Return the color tint for this Achievement's icon.""" 693 if complete: 694 return self._icon_color 695 return 1.0, 1.0, 1.0, 0.6 696 697 @property 698 def hard_mode_only(self) -> bool: 699 """Whether this Achievement is only unlockable in hard-mode.""" 700 return self._hard_mode_only 701 702 @property 703 def complete(self) -> bool: 704 """Whether this Achievement is currently complete.""" 705 val: bool = self._getconfig()['Complete'] 706 assert isinstance(val, bool) 707 return val 708 709 def announce_completion(self, sound: bool = True) -> None: 710 """Kick off an announcement for this achievement's completion.""" 711 712 app = babase.app 713 plus = app.plus 714 classic = app.classic 715 if plus is None or classic is None: 716 logging.warning('ach account_completion not available.') 717 return 718 719 ach_ss = classic.ach 720 721 # Even though there are technically achievements when we're not 722 # signed in, lets not show them (otherwise we tend to get 723 # confusing 'controller connected' achievements popping up while 724 # waiting to sign in which can be confusing). 725 if plus.get_v1_account_state() != 'signed_in': 726 return 727 728 # If we're being freshly complete, display/report it and whatnot. 729 if (self, sound) not in ach_ss.achievements_to_display: 730 ach_ss.achievements_to_display.append((self, sound)) 731 732 # If there's no achievement display timer going, kick one off 733 # (if one's already running it will pick this up before it dies). 734 735 # Need to check last time too; its possible our timer wasn't able to 736 # clear itself if an activity died and took it down with it. 737 if ( 738 ach_ss.achievement_display_timer is None 739 or babase.apptime() - ach_ss.last_achievement_display_time > 2.0 740 ) and bascenev1.getactivity(doraise=False) is not None: 741 ach_ss.achievement_display_timer = bascenev1.BaseTimer( 742 1.0, _display_next_achievement, repeat=True 743 ) 744 745 # Show the first immediately. 746 _display_next_achievement() 747 748 def set_complete(self, complete: bool = True) -> None: 749 """Set an achievement's completed state. 750 751 note this only sets local state; use a transaction to 752 actually award achievements. 753 """ 754 config = self._getconfig() 755 if complete != config['Complete']: 756 config['Complete'] = complete 757 758 @property 759 def display_name(self) -> babase.Lstr: 760 """Return a babase.Lstr for this Achievement's name.""" 761 name: babase.Lstr | str 762 try: 763 if self._level_name != '': 764 campaignname, campaign_level = self._level_name.split(':') 765 classic = babase.app.classic 766 assert classic is not None 767 name = ( 768 classic.getcampaign(campaignname) 769 .getlevel(campaign_level) 770 .displayname 771 ) 772 else: 773 name = '' 774 except Exception: 775 name = '' 776 logging.exception('Error calcing achievement display-name.') 777 return babase.Lstr( 778 resource='achievements.' + self._name + '.name', 779 subs=[('${LEVEL}', name)], 780 ) 781 782 @property 783 def description(self) -> babase.Lstr: 784 """Get a babase.Lstr for the Achievement's brief description.""" 785 if ( 786 'description' 787 in babase.app.lang.get_resource('achievements')[self._name] 788 ): 789 return babase.Lstr( 790 resource='achievements.' + self._name + '.description' 791 ) 792 return babase.Lstr( 793 resource='achievements.' + self._name + '.descriptionFull' 794 ) 795 796 @property 797 def description_complete(self) -> babase.Lstr: 798 """Get a babase.Lstr for the Achievement's description when complete.""" 799 if ( 800 'descriptionComplete' 801 in babase.app.lang.get_resource('achievements')[self._name] 802 ): 803 return babase.Lstr( 804 resource='achievements.' + self._name + '.descriptionComplete' 805 ) 806 return babase.Lstr( 807 resource='achievements.' + self._name + '.descriptionFullComplete' 808 ) 809 810 @property 811 def description_full(self) -> babase.Lstr: 812 """Get a babase.Lstr for the Achievement's full description.""" 813 return babase.Lstr( 814 resource='achievements.' + self._name + '.descriptionFull', 815 subs=[ 816 ( 817 '${LEVEL}', 818 babase.Lstr( 819 translate=( 820 'coopLevelNames', 821 ACH_LEVEL_NAMES.get(self._name, '?'), 822 ) 823 ), 824 ) 825 ], 826 ) 827 828 @property 829 def description_full_complete(self) -> babase.Lstr: 830 """Get a babase.Lstr for the Achievement's full desc. when completed.""" 831 return babase.Lstr( 832 resource='achievements.' + self._name + '.descriptionFullComplete', 833 subs=[ 834 ( 835 '${LEVEL}', 836 babase.Lstr( 837 translate=( 838 'coopLevelNames', 839 ACH_LEVEL_NAMES.get(self._name, '?'), 840 ) 841 ), 842 ) 843 ], 844 ) 845 846 def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: 847 """Get the ticket award value for this achievement.""" 848 plus = babase.app.plus 849 if plus is None: 850 return 0 851 val: int = plus.get_v1_account_misc_read_val( 852 'achAward.' + self._name, self._award 853 ) * _get_ach_mult(include_pro_bonus) 854 assert isinstance(val, int) 855 return val 856 857 @property 858 def power_ranking_value(self) -> int: 859 """Get the power-ranking award value for this achievement.""" 860 plus = babase.app.plus 861 if plus is None: 862 return 0 863 val: int = plus.get_v1_account_misc_read_val( 864 'achLeaguePoints.' + self._name, self._award 865 ) 866 assert isinstance(val, int) 867 return val 868 869 def create_display( 870 self, 871 x: float, 872 y: float, 873 delay: float, 874 outdelay: float | None = None, 875 color: Sequence[float] | None = None, 876 style: str = 'post_game', 877 ) -> list[bascenev1.Actor]: 878 """Create a display for the Achievement. 879 880 Shows the Achievement icon, name, and description. 881 """ 882 # pylint: disable=cyclic-import 883 from bascenev1 import CoopSession 884 from bascenev1lib.actor.image import Image 885 from bascenev1lib.actor.text import Text 886 887 # Yeah this needs cleaning up. 888 if style == 'post_game': 889 in_game_colors = False 890 in_main_menu = False 891 h_attach = Text.HAttach.CENTER 892 v_attach = Text.VAttach.CENTER 893 attach = Image.Attach.CENTER 894 elif style == 'in_game': 895 in_game_colors = True 896 in_main_menu = False 897 h_attach = Text.HAttach.LEFT 898 v_attach = Text.VAttach.TOP 899 attach = Image.Attach.TOP_LEFT 900 elif style == 'news': 901 in_game_colors = True 902 in_main_menu = True 903 h_attach = Text.HAttach.CENTER 904 v_attach = Text.VAttach.TOP 905 attach = Image.Attach.TOP_CENTER 906 else: 907 raise ValueError('invalid style "' + style + '"') 908 909 # Attempt to determine what campaign we're in 910 # (so we know whether to show "hard mode only"). 911 if in_main_menu: 912 hmo = False 913 else: 914 try: 915 session = bascenev1.getsession() 916 if isinstance(session, CoopSession): 917 campaign = session.campaign 918 assert campaign is not None 919 hmo = self._hard_mode_only and campaign.name == 'Easy' 920 else: 921 hmo = False 922 except Exception: 923 logging.exception('Error determining campaign.') 924 hmo = False 925 926 objs: list[bascenev1.Actor] 927 928 if in_game_colors: 929 objs = [] 930 out_delay_fin = (delay + outdelay) if outdelay is not None else None 931 if color is not None: 932 cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) 933 cl2 = color 934 else: 935 cl1 = (1.5, 1.5, 2, 1.0) 936 cl2 = (0.8, 0.8, 1.0, 1.0) 937 938 if hmo: 939 cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) 940 cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) 941 942 objs.append( 943 Image( 944 self.get_icon_texture(False), 945 host_only=True, 946 color=cl1, 947 position=(x - 25, y + 5), 948 attach=attach, 949 transition=Image.Transition.FADE_IN, 950 transition_delay=delay, 951 vr_depth=4, 952 transition_out_delay=out_delay_fin, 953 scale=(40, 40), 954 ).autoretain() 955 ) 956 txt = self.display_name 957 txt_s = 0.85 958 txt_max_w = 300 959 objs.append( 960 Text( 961 txt, 962 host_only=True, 963 maxwidth=txt_max_w, 964 position=(x, y + 2), 965 transition=Text.Transition.FADE_IN, 966 scale=txt_s, 967 flatness=0.6, 968 shadow=0.5, 969 h_attach=h_attach, 970 v_attach=v_attach, 971 color=cl2, 972 transition_delay=delay + 0.05, 973 transition_out_delay=out_delay_fin, 974 ).autoretain() 975 ) 976 txt2_s = 0.62 977 txt2_max_w = 400 978 objs.append( 979 Text( 980 self.description_full if in_main_menu else self.description, 981 host_only=True, 982 maxwidth=txt2_max_w, 983 position=(x, y - 14), 984 transition=Text.Transition.FADE_IN, 985 vr_depth=-5, 986 h_attach=h_attach, 987 v_attach=v_attach, 988 scale=txt2_s, 989 flatness=1.0, 990 shadow=0.5, 991 color=cl2, 992 transition_delay=delay + 0.1, 993 transition_out_delay=out_delay_fin, 994 ).autoretain() 995 ) 996 997 if hmo: 998 txtactor = Text( 999 babase.Lstr(resource='difficultyHardOnlyText'), 1000 host_only=True, 1001 maxwidth=txt2_max_w * 0.7, 1002 position=(x + 60, y + 5), 1003 transition=Text.Transition.FADE_IN, 1004 vr_depth=-5, 1005 h_attach=h_attach, 1006 v_attach=v_attach, 1007 h_align=Text.HAlign.CENTER, 1008 v_align=Text.VAlign.CENTER, 1009 scale=txt_s * 0.8, 1010 flatness=1.0, 1011 shadow=0.5, 1012 color=(1, 1, 0.6, 1), 1013 transition_delay=delay + 0.1, 1014 transition_out_delay=out_delay_fin, 1015 ).autoretain() 1016 txtactor.node.rotate = 10 1017 objs.append(txtactor) 1018 1019 # Ticket-award. 1020 award_x = -100 1021 objs.append( 1022 Text( 1023 babase.charstr(babase.SpecialChar.TICKET), 1024 host_only=True, 1025 position=(x + award_x + 33, y + 7), 1026 transition=Text.Transition.FADE_IN, 1027 scale=1.5, 1028 h_attach=h_attach, 1029 v_attach=v_attach, 1030 h_align=Text.HAlign.CENTER, 1031 v_align=Text.VAlign.CENTER, 1032 color=(1, 1, 1, 0.2 if hmo else 0.4), 1033 transition_delay=delay + 0.05, 1034 transition_out_delay=out_delay_fin, 1035 ).autoretain() 1036 ) 1037 objs.append( 1038 Text( 1039 '+' + str(self.get_award_ticket_value()), 1040 host_only=True, 1041 position=(x + award_x + 28, y + 16), 1042 transition=Text.Transition.FADE_IN, 1043 scale=0.7, 1044 flatness=1, 1045 h_attach=h_attach, 1046 v_attach=v_attach, 1047 h_align=Text.HAlign.CENTER, 1048 v_align=Text.VAlign.CENTER, 1049 color=cl2, 1050 transition_delay=delay + 0.05, 1051 transition_out_delay=out_delay_fin, 1052 ).autoretain() 1053 ) 1054 1055 else: 1056 complete = self.complete 1057 objs = [] 1058 c_icon = self.get_icon_color(complete) 1059 if hmo and not complete: 1060 c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) 1061 objs.append( 1062 Image( 1063 self.get_icon_texture(complete), 1064 host_only=True, 1065 color=c_icon, 1066 position=(x - 25, y + 5), 1067 attach=attach, 1068 vr_depth=4, 1069 transition=Image.Transition.IN_RIGHT, 1070 transition_delay=delay, 1071 transition_out_delay=None, 1072 scale=(40, 40), 1073 ).autoretain() 1074 ) 1075 if complete: 1076 objs.append( 1077 Image( 1078 bascenev1.gettexture('achievementOutline'), 1079 host_only=True, 1080 mesh_transparent=bascenev1.getmesh( 1081 'achievementOutline' 1082 ), 1083 color=(2, 1.4, 0.4, 1), 1084 vr_depth=8, 1085 position=(x - 25, y + 5), 1086 attach=attach, 1087 transition=Image.Transition.IN_RIGHT, 1088 transition_delay=delay, 1089 transition_out_delay=None, 1090 scale=(40, 40), 1091 ).autoretain() 1092 ) 1093 else: 1094 if not complete: 1095 award_x = -100 1096 objs.append( 1097 Text( 1098 babase.charstr(babase.SpecialChar.TICKET), 1099 host_only=True, 1100 position=(x + award_x + 33, y + 7), 1101 transition=Text.Transition.IN_RIGHT, 1102 scale=1.5, 1103 h_attach=h_attach, 1104 v_attach=v_attach, 1105 h_align=Text.HAlign.CENTER, 1106 v_align=Text.VAlign.CENTER, 1107 color=(1, 1, 1, (0.1 if hmo else 0.2)), 1108 transition_delay=delay + 0.05, 1109 transition_out_delay=None, 1110 ).autoretain() 1111 ) 1112 objs.append( 1113 Text( 1114 '+' + str(self.get_award_ticket_value()), 1115 host_only=True, 1116 position=(x + award_x + 28, y + 16), 1117 transition=Text.Transition.IN_RIGHT, 1118 scale=0.7, 1119 flatness=1, 1120 h_attach=h_attach, 1121 v_attach=v_attach, 1122 h_align=Text.HAlign.CENTER, 1123 v_align=Text.VAlign.CENTER, 1124 color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)), 1125 transition_delay=delay + 0.05, 1126 transition_out_delay=None, 1127 ).autoretain() 1128 ) 1129 1130 # Show 'hard-mode-only' only over incomplete achievements 1131 # when that's the case. 1132 if hmo: 1133 txtactor = Text( 1134 babase.Lstr(resource='difficultyHardOnlyText'), 1135 host_only=True, 1136 maxwidth=300 * 0.7, 1137 position=(x + 60, y + 5), 1138 transition=Text.Transition.FADE_IN, 1139 vr_depth=-5, 1140 h_attach=h_attach, 1141 v_attach=v_attach, 1142 h_align=Text.HAlign.CENTER, 1143 v_align=Text.VAlign.CENTER, 1144 scale=0.85 * 0.8, 1145 flatness=1.0, 1146 shadow=0.5, 1147 color=(1, 1, 0.6, 1), 1148 transition_delay=delay + 0.05, 1149 transition_out_delay=None, 1150 ).autoretain() 1151 assert txtactor.node 1152 txtactor.node.rotate = 10 1153 objs.append(txtactor) 1154 1155 objs.append( 1156 Text( 1157 self.display_name, 1158 host_only=True, 1159 maxwidth=300, 1160 position=(x, y + 2), 1161 transition=Text.Transition.IN_RIGHT, 1162 scale=0.85, 1163 flatness=0.6, 1164 h_attach=h_attach, 1165 v_attach=v_attach, 1166 color=( 1167 (0.8, 0.93, 0.8, 1.0) 1168 if complete 1169 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1170 ), 1171 transition_delay=delay + 0.05, 1172 transition_out_delay=None, 1173 ).autoretain() 1174 ) 1175 objs.append( 1176 Text( 1177 self.description_complete if complete else self.description, 1178 host_only=True, 1179 maxwidth=400, 1180 position=(x, y - 14), 1181 transition=Text.Transition.IN_RIGHT, 1182 vr_depth=-5, 1183 h_attach=h_attach, 1184 v_attach=v_attach, 1185 scale=0.62, 1186 flatness=1.0, 1187 color=( 1188 (0.6, 0.6, 0.6, 1.0) 1189 if complete 1190 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1191 ), 1192 transition_delay=delay + 0.1, 1193 transition_out_delay=None, 1194 ).autoretain() 1195 ) 1196 return objs 1197 1198 def _getconfig(self) -> dict[str, Any]: 1199 """ 1200 Return the sub-dict in settings where this achievement's 1201 state is stored, creating it if need be. 1202 """ 1203 val: dict[str, Any] = babase.app.config.setdefault( 1204 'Achievements', {} 1205 ).setdefault(self._name, {'Complete': False}) 1206 assert isinstance(val, dict) 1207 return val 1208 1209 def _remove_banner_slot(self) -> None: 1210 classic = babase.app.classic 1211 assert classic is not None 1212 assert self._completion_banner_slot is not None 1213 classic.ach.achievement_completion_banner_slots.remove( 1214 self._completion_banner_slot 1215 ) 1216 self._completion_banner_slot = None 1217 1218 def show_completion_banner(self, sound: bool = True) -> None: 1219 """Create the banner/sound for an acquired achievement announcement.""" 1220 from bascenev1lib.actor.text import Text 1221 from bascenev1lib.actor.image import Image 1222 1223 app = babase.app 1224 assert app.classic is not None 1225 app.classic.ach.last_achievement_display_time = babase.apptime() 1226 1227 # Just piggy-back onto any current activity 1228 # (should we use the session instead?..) 1229 activity = bascenev1.getactivity(doraise=False) 1230 1231 # If this gets called while this achievement is occupying a slot 1232 # already, ignore it. (probably should never happen in real 1233 # life but whatevs). 1234 if self._completion_banner_slot is not None: 1235 return 1236 1237 if activity is None: 1238 print('show_completion_banner() called with no current activity!') 1239 return 1240 1241 if sound: 1242 bascenev1.getsound('achievement').play(host_only=True) 1243 else: 1244 bascenev1.timer( 1245 0.5, lambda: bascenev1.getsound('ding').play(host_only=True) 1246 ) 1247 1248 in_time = 0.300 1249 out_time = 3.5 1250 1251 base_vr_depth = 200 1252 1253 # Find the first free slot. 1254 i = 0 1255 while True: 1256 if i not in app.classic.ach.achievement_completion_banner_slots: 1257 app.classic.ach.achievement_completion_banner_slots.add(i) 1258 self._completion_banner_slot = i 1259 1260 # Remove us from that slot when we close. 1261 # Use an app-timer in an empty context so the removal 1262 # runs even if our activity/session dies. 1263 with babase.ContextRef.empty(): 1264 babase.apptimer( 1265 in_time + out_time, self._remove_banner_slot 1266 ) 1267 break 1268 i += 1 1269 assert self._completion_banner_slot is not None 1270 y_offs = 110 * self._completion_banner_slot 1271 objs: list[bascenev1.Actor] = [] 1272 obj = Image( 1273 bascenev1.gettexture('shadow'), 1274 position=(-30, 30 + y_offs), 1275 front=True, 1276 attach=Image.Attach.BOTTOM_CENTER, 1277 transition=Image.Transition.IN_BOTTOM, 1278 vr_depth=base_vr_depth - 100, 1279 transition_delay=in_time, 1280 transition_out_delay=out_time, 1281 color=(0.0, 0.1, 0, 1), 1282 scale=(1000, 300), 1283 ).autoretain() 1284 objs.append(obj) 1285 assert obj.node 1286 obj.node.host_only = True 1287 obj = Image( 1288 bascenev1.gettexture('light'), 1289 position=(-180, 60 + y_offs), 1290 front=True, 1291 attach=Image.Attach.BOTTOM_CENTER, 1292 vr_depth=base_vr_depth, 1293 transition=Image.Transition.IN_BOTTOM, 1294 transition_delay=in_time, 1295 transition_out_delay=out_time, 1296 color=(1.8, 1.8, 1.0, 0.0), 1297 scale=(40, 300), 1298 ).autoretain() 1299 objs.append(obj) 1300 assert obj.node 1301 obj.node.host_only = True 1302 obj.node.premultiplied = True 1303 combine = bascenev1.newnode( 1304 'combine', owner=obj.node, attrs={'size': 2} 1305 ) 1306 bascenev1.animate( 1307 combine, 1308 'input0', 1309 { 1310 in_time: 0, 1311 in_time + 0.4: 30, 1312 in_time + 0.5: 40, 1313 in_time + 0.6: 30, 1314 in_time + 2.0: 0, 1315 }, 1316 ) 1317 bascenev1.animate( 1318 combine, 1319 'input1', 1320 { 1321 in_time: 0, 1322 in_time + 0.4: 200, 1323 in_time + 0.5: 500, 1324 in_time + 0.6: 200, 1325 in_time + 2.0: 0, 1326 }, 1327 ) 1328 combine.connectattr('output', obj.node, 'scale') 1329 bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True) 1330 obj = Image( 1331 self.get_icon_texture(True), 1332 position=(-180, 60 + y_offs), 1333 attach=Image.Attach.BOTTOM_CENTER, 1334 front=True, 1335 vr_depth=base_vr_depth - 10, 1336 transition=Image.Transition.IN_BOTTOM, 1337 transition_delay=in_time, 1338 transition_out_delay=out_time, 1339 scale=(100, 100), 1340 ).autoretain() 1341 objs.append(obj) 1342 assert obj.node 1343 obj.node.host_only = True 1344 1345 # Flash. 1346 color = self.get_icon_color(True) 1347 combine = bascenev1.newnode( 1348 'combine', owner=obj.node, attrs={'size': 3} 1349 ) 1350 keys = { 1351 in_time: 1.0 * color[0], 1352 in_time + 0.4: 1.5 * color[0], 1353 in_time + 0.5: 6.0 * color[0], 1354 in_time + 0.6: 1.5 * color[0], 1355 in_time + 2.0: 1.0 * color[0], 1356 } 1357 bascenev1.animate(combine, 'input0', keys) 1358 keys = { 1359 in_time: 1.0 * color[1], 1360 in_time + 0.4: 1.5 * color[1], 1361 in_time + 0.5: 6.0 * color[1], 1362 in_time + 0.6: 1.5 * color[1], 1363 in_time + 2.0: 1.0 * color[1], 1364 } 1365 bascenev1.animate(combine, 'input1', keys) 1366 keys = { 1367 in_time: 1.0 * color[2], 1368 in_time + 0.4: 1.5 * color[2], 1369 in_time + 0.5: 6.0 * color[2], 1370 in_time + 0.6: 1.5 * color[2], 1371 in_time + 2.0: 1.0 * color[2], 1372 } 1373 bascenev1.animate(combine, 'input2', keys) 1374 combine.connectattr('output', obj.node, 'color') 1375 1376 obj = Image( 1377 bascenev1.gettexture('achievementOutline'), 1378 mesh_transparent=bascenev1.getmesh('achievementOutline'), 1379 position=(-180, 60 + y_offs), 1380 front=True, 1381 attach=Image.Attach.BOTTOM_CENTER, 1382 vr_depth=base_vr_depth, 1383 transition=Image.Transition.IN_BOTTOM, 1384 transition_delay=in_time, 1385 transition_out_delay=out_time, 1386 scale=(100, 100), 1387 ).autoretain() 1388 assert obj.node 1389 obj.node.host_only = True 1390 1391 # Flash. 1392 color = (2, 1.4, 0.4, 1) 1393 combine = bascenev1.newnode( 1394 'combine', owner=obj.node, attrs={'size': 3} 1395 ) 1396 keys = { 1397 in_time: 1.0 * color[0], 1398 in_time + 0.4: 1.5 * color[0], 1399 in_time + 0.5: 6.0 * color[0], 1400 in_time + 0.6: 1.5 * color[0], 1401 in_time + 2.0: 1.0 * color[0], 1402 } 1403 bascenev1.animate(combine, 'input0', keys) 1404 keys = { 1405 in_time: 1.0 * color[1], 1406 in_time + 0.4: 1.5 * color[1], 1407 in_time + 0.5: 6.0 * color[1], 1408 in_time + 0.6: 1.5 * color[1], 1409 in_time + 2.0: 1.0 * color[1], 1410 } 1411 bascenev1.animate(combine, 'input1', keys) 1412 keys = { 1413 in_time: 1.0 * color[2], 1414 in_time + 0.4: 1.5 * color[2], 1415 in_time + 0.5: 6.0 * color[2], 1416 in_time + 0.6: 1.5 * color[2], 1417 in_time + 2.0: 1.0 * color[2], 1418 } 1419 bascenev1.animate(combine, 'input2', keys) 1420 combine.connectattr('output', obj.node, 'color') 1421 objs.append(obj) 1422 1423 objt = Text( 1424 babase.Lstr( 1425 value='${A}:', 1426 subs=[('${A}', babase.Lstr(resource='achievementText'))], 1427 ), 1428 position=(-120, 91 + y_offs), 1429 front=True, 1430 v_attach=Text.VAttach.BOTTOM, 1431 vr_depth=base_vr_depth - 10, 1432 transition=Text.Transition.IN_BOTTOM, 1433 flatness=0.5, 1434 transition_delay=in_time, 1435 transition_out_delay=out_time, 1436 color=(1, 1, 1, 0.8), 1437 scale=0.65, 1438 ).autoretain() 1439 objs.append(objt) 1440 assert objt.node 1441 objt.node.host_only = True 1442 1443 objt = Text( 1444 self.display_name, 1445 position=(-120, 50 + y_offs), 1446 front=True, 1447 v_attach=Text.VAttach.BOTTOM, 1448 transition=Text.Transition.IN_BOTTOM, 1449 vr_depth=base_vr_depth, 1450 flatness=0.5, 1451 transition_delay=in_time, 1452 transition_out_delay=out_time, 1453 flash=True, 1454 color=(1, 0.8, 0, 1.0), 1455 scale=1.5, 1456 ).autoretain() 1457 objs.append(objt) 1458 assert objt.node 1459 objt.node.host_only = True 1460 1461 objt = Text( 1462 babase.charstr(babase.SpecialChar.TICKET), 1463 position=(-120 - 170 + 5, 75 + y_offs - 20), 1464 front=True, 1465 v_attach=Text.VAttach.BOTTOM, 1466 h_align=Text.HAlign.CENTER, 1467 v_align=Text.VAlign.CENTER, 1468 transition=Text.Transition.IN_BOTTOM, 1469 vr_depth=base_vr_depth, 1470 transition_delay=in_time, 1471 transition_out_delay=out_time, 1472 flash=True, 1473 color=(0.5, 0.5, 0.5, 1), 1474 scale=3.0, 1475 ).autoretain() 1476 objs.append(objt) 1477 assert objt.node 1478 objt.node.host_only = True 1479 1480 objt = Text( 1481 '+' + str(self.get_award_ticket_value()), 1482 position=(-120 - 180 + 5, 80 + y_offs - 20), 1483 v_attach=Text.VAttach.BOTTOM, 1484 front=True, 1485 h_align=Text.HAlign.CENTER, 1486 v_align=Text.VAlign.CENTER, 1487 transition=Text.Transition.IN_BOTTOM, 1488 vr_depth=base_vr_depth, 1489 flatness=0.5, 1490 shadow=1.0, 1491 transition_delay=in_time, 1492 transition_out_delay=out_time, 1493 flash=True, 1494 color=(0, 1, 0, 1), 1495 scale=1.5, 1496 ).autoretain() 1497 objs.append(objt) 1498 assert objt.node 1499 objt.node.host_only = True 1500 1501 # Add the 'x 2' if we've got pro. 1502 if app.classic.accounts.have_pro(): 1503 objt = Text( 1504 'x 2', 1505 position=(-120 - 180 + 45, 80 + y_offs - 50), 1506 v_attach=Text.VAttach.BOTTOM, 1507 front=True, 1508 h_align=Text.HAlign.CENTER, 1509 v_align=Text.VAlign.CENTER, 1510 transition=Text.Transition.IN_BOTTOM, 1511 vr_depth=base_vr_depth, 1512 flatness=0.5, 1513 shadow=1.0, 1514 transition_delay=in_time, 1515 transition_out_delay=out_time, 1516 flash=True, 1517 color=(0.4, 0, 1, 1), 1518 scale=0.9, 1519 ).autoretain() 1520 objs.append(objt) 1521 assert objt.node 1522 objt.node.host_only = True 1523 1524 objt = Text( 1525 self.description_complete, 1526 position=(-120, 30 + y_offs), 1527 front=True, 1528 v_attach=Text.VAttach.BOTTOM, 1529 transition=Text.Transition.IN_BOTTOM, 1530 vr_depth=base_vr_depth - 10, 1531 flatness=0.5, 1532 transition_delay=in_time, 1533 transition_out_delay=out_time, 1534 color=(1.0, 0.7, 0.5, 1.0), 1535 scale=0.8, 1536 ).autoretain() 1537 objs.append(objt) 1538 assert objt.node 1539 objt.node.host_only = True 1540 1541 for actor in objs: 1542 bascenev1.timer( 1543 out_time + 1.000, 1544 babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()), 1545 )
Represents attributes and state for an individual achievement.
Category: App Classes
652 def __init__( 653 self, 654 name: str, 655 icon_name: str, 656 icon_color: Sequence[float], 657 level_name: str, 658 award: int, 659 hard_mode_only: bool = False, 660 ): 661 self._name = name 662 self._icon_name = icon_name 663 self._icon_color: Sequence[float] = list(icon_color) + [1] 664 self._level_name = level_name 665 self._completion_banner_slot: int | None = None 666 self._award = award 667 self._hard_mode_only = hard_mode_only
669 @property 670 def name(self) -> str: 671 """The name of this achievement.""" 672 return self._name
The name of this achievement.
674 @property 675 def level_name(self) -> str: 676 """The name of the level this achievement applies to.""" 677 return self._level_name
The name of the level this achievement applies to.
679 def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture: 680 """Return the icon texture to display for this achievement""" 681 return bauiv1.gettexture( 682 self._icon_name if complete else 'achievementEmpty' 683 )
Return the icon texture to display for this achievement
685 def get_icon_texture(self, complete: bool) -> bascenev1.Texture: 686 """Return the icon texture to display for this achievement""" 687 return bascenev1.gettexture( 688 self._icon_name if complete else 'achievementEmpty' 689 )
Return the icon texture to display for this achievement
691 def get_icon_color(self, complete: bool) -> Sequence[float]: 692 """Return the color tint for this Achievement's icon.""" 693 if complete: 694 return self._icon_color 695 return 1.0, 1.0, 1.0, 0.6
Return the color tint for this Achievement's icon.
697 @property 698 def hard_mode_only(self) -> bool: 699 """Whether this Achievement is only unlockable in hard-mode.""" 700 return self._hard_mode_only
Whether this Achievement is only unlockable in hard-mode.
702 @property 703 def complete(self) -> bool: 704 """Whether this Achievement is currently complete.""" 705 val: bool = self._getconfig()['Complete'] 706 assert isinstance(val, bool) 707 return val
Whether this Achievement is currently complete.
709 def announce_completion(self, sound: bool = True) -> None: 710 """Kick off an announcement for this achievement's completion.""" 711 712 app = babase.app 713 plus = app.plus 714 classic = app.classic 715 if plus is None or classic is None: 716 logging.warning('ach account_completion not available.') 717 return 718 719 ach_ss = classic.ach 720 721 # Even though there are technically achievements when we're not 722 # signed in, lets not show them (otherwise we tend to get 723 # confusing 'controller connected' achievements popping up while 724 # waiting to sign in which can be confusing). 725 if plus.get_v1_account_state() != 'signed_in': 726 return 727 728 # If we're being freshly complete, display/report it and whatnot. 729 if (self, sound) not in ach_ss.achievements_to_display: 730 ach_ss.achievements_to_display.append((self, sound)) 731 732 # If there's no achievement display timer going, kick one off 733 # (if one's already running it will pick this up before it dies). 734 735 # Need to check last time too; its possible our timer wasn't able to 736 # clear itself if an activity died and took it down with it. 737 if ( 738 ach_ss.achievement_display_timer is None 739 or babase.apptime() - ach_ss.last_achievement_display_time > 2.0 740 ) and bascenev1.getactivity(doraise=False) is not None: 741 ach_ss.achievement_display_timer = bascenev1.BaseTimer( 742 1.0, _display_next_achievement, repeat=True 743 ) 744 745 # Show the first immediately. 746 _display_next_achievement()
Kick off an announcement for this achievement's completion.
748 def set_complete(self, complete: bool = True) -> None: 749 """Set an achievement's completed state. 750 751 note this only sets local state; use a transaction to 752 actually award achievements. 753 """ 754 config = self._getconfig() 755 if complete != config['Complete']: 756 config['Complete'] = complete
Set an achievement's completed state.
note this only sets local state; use a transaction to actually award achievements.
758 @property 759 def display_name(self) -> babase.Lstr: 760 """Return a babase.Lstr for this Achievement's name.""" 761 name: babase.Lstr | str 762 try: 763 if self._level_name != '': 764 campaignname, campaign_level = self._level_name.split(':') 765 classic = babase.app.classic 766 assert classic is not None 767 name = ( 768 classic.getcampaign(campaignname) 769 .getlevel(campaign_level) 770 .displayname 771 ) 772 else: 773 name = '' 774 except Exception: 775 name = '' 776 logging.exception('Error calcing achievement display-name.') 777 return babase.Lstr( 778 resource='achievements.' + self._name + '.name', 779 subs=[('${LEVEL}', name)], 780 )
Return a babase.Lstr for this Achievement's name.
782 @property 783 def description(self) -> babase.Lstr: 784 """Get a babase.Lstr for the Achievement's brief description.""" 785 if ( 786 'description' 787 in babase.app.lang.get_resource('achievements')[self._name] 788 ): 789 return babase.Lstr( 790 resource='achievements.' + self._name + '.description' 791 ) 792 return babase.Lstr( 793 resource='achievements.' + self._name + '.descriptionFull' 794 )
Get a babase.Lstr for the Achievement's brief description.
796 @property 797 def description_complete(self) -> babase.Lstr: 798 """Get a babase.Lstr for the Achievement's description when complete.""" 799 if ( 800 'descriptionComplete' 801 in babase.app.lang.get_resource('achievements')[self._name] 802 ): 803 return babase.Lstr( 804 resource='achievements.' + self._name + '.descriptionComplete' 805 ) 806 return babase.Lstr( 807 resource='achievements.' + self._name + '.descriptionFullComplete' 808 )
Get a babase.Lstr for the Achievement's description when complete.
810 @property 811 def description_full(self) -> babase.Lstr: 812 """Get a babase.Lstr for the Achievement's full description.""" 813 return babase.Lstr( 814 resource='achievements.' + self._name + '.descriptionFull', 815 subs=[ 816 ( 817 '${LEVEL}', 818 babase.Lstr( 819 translate=( 820 'coopLevelNames', 821 ACH_LEVEL_NAMES.get(self._name, '?'), 822 ) 823 ), 824 ) 825 ], 826 )
Get a babase.Lstr for the Achievement's full description.
828 @property 829 def description_full_complete(self) -> babase.Lstr: 830 """Get a babase.Lstr for the Achievement's full desc. when completed.""" 831 return babase.Lstr( 832 resource='achievements.' + self._name + '.descriptionFullComplete', 833 subs=[ 834 ( 835 '${LEVEL}', 836 babase.Lstr( 837 translate=( 838 'coopLevelNames', 839 ACH_LEVEL_NAMES.get(self._name, '?'), 840 ) 841 ), 842 ) 843 ], 844 )
Get a babase.Lstr for the Achievement's full desc. when completed.
846 def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: 847 """Get the ticket award value for this achievement.""" 848 plus = babase.app.plus 849 if plus is None: 850 return 0 851 val: int = plus.get_v1_account_misc_read_val( 852 'achAward.' + self._name, self._award 853 ) * _get_ach_mult(include_pro_bonus) 854 assert isinstance(val, int) 855 return val
Get the ticket award value for this achievement.
857 @property 858 def power_ranking_value(self) -> int: 859 """Get the power-ranking award value for this achievement.""" 860 plus = babase.app.plus 861 if plus is None: 862 return 0 863 val: int = plus.get_v1_account_misc_read_val( 864 'achLeaguePoints.' + self._name, self._award 865 ) 866 assert isinstance(val, int) 867 return val
Get the power-ranking award value for this achievement.
869 def create_display( 870 self, 871 x: float, 872 y: float, 873 delay: float, 874 outdelay: float | None = None, 875 color: Sequence[float] | None = None, 876 style: str = 'post_game', 877 ) -> list[bascenev1.Actor]: 878 """Create a display for the Achievement. 879 880 Shows the Achievement icon, name, and description. 881 """ 882 # pylint: disable=cyclic-import 883 from bascenev1 import CoopSession 884 from bascenev1lib.actor.image import Image 885 from bascenev1lib.actor.text import Text 886 887 # Yeah this needs cleaning up. 888 if style == 'post_game': 889 in_game_colors = False 890 in_main_menu = False 891 h_attach = Text.HAttach.CENTER 892 v_attach = Text.VAttach.CENTER 893 attach = Image.Attach.CENTER 894 elif style == 'in_game': 895 in_game_colors = True 896 in_main_menu = False 897 h_attach = Text.HAttach.LEFT 898 v_attach = Text.VAttach.TOP 899 attach = Image.Attach.TOP_LEFT 900 elif style == 'news': 901 in_game_colors = True 902 in_main_menu = True 903 h_attach = Text.HAttach.CENTER 904 v_attach = Text.VAttach.TOP 905 attach = Image.Attach.TOP_CENTER 906 else: 907 raise ValueError('invalid style "' + style + '"') 908 909 # Attempt to determine what campaign we're in 910 # (so we know whether to show "hard mode only"). 911 if in_main_menu: 912 hmo = False 913 else: 914 try: 915 session = bascenev1.getsession() 916 if isinstance(session, CoopSession): 917 campaign = session.campaign 918 assert campaign is not None 919 hmo = self._hard_mode_only and campaign.name == 'Easy' 920 else: 921 hmo = False 922 except Exception: 923 logging.exception('Error determining campaign.') 924 hmo = False 925 926 objs: list[bascenev1.Actor] 927 928 if in_game_colors: 929 objs = [] 930 out_delay_fin = (delay + outdelay) if outdelay is not None else None 931 if color is not None: 932 cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) 933 cl2 = color 934 else: 935 cl1 = (1.5, 1.5, 2, 1.0) 936 cl2 = (0.8, 0.8, 1.0, 1.0) 937 938 if hmo: 939 cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) 940 cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) 941 942 objs.append( 943 Image( 944 self.get_icon_texture(False), 945 host_only=True, 946 color=cl1, 947 position=(x - 25, y + 5), 948 attach=attach, 949 transition=Image.Transition.FADE_IN, 950 transition_delay=delay, 951 vr_depth=4, 952 transition_out_delay=out_delay_fin, 953 scale=(40, 40), 954 ).autoretain() 955 ) 956 txt = self.display_name 957 txt_s = 0.85 958 txt_max_w = 300 959 objs.append( 960 Text( 961 txt, 962 host_only=True, 963 maxwidth=txt_max_w, 964 position=(x, y + 2), 965 transition=Text.Transition.FADE_IN, 966 scale=txt_s, 967 flatness=0.6, 968 shadow=0.5, 969 h_attach=h_attach, 970 v_attach=v_attach, 971 color=cl2, 972 transition_delay=delay + 0.05, 973 transition_out_delay=out_delay_fin, 974 ).autoretain() 975 ) 976 txt2_s = 0.62 977 txt2_max_w = 400 978 objs.append( 979 Text( 980 self.description_full if in_main_menu else self.description, 981 host_only=True, 982 maxwidth=txt2_max_w, 983 position=(x, y - 14), 984 transition=Text.Transition.FADE_IN, 985 vr_depth=-5, 986 h_attach=h_attach, 987 v_attach=v_attach, 988 scale=txt2_s, 989 flatness=1.0, 990 shadow=0.5, 991 color=cl2, 992 transition_delay=delay + 0.1, 993 transition_out_delay=out_delay_fin, 994 ).autoretain() 995 ) 996 997 if hmo: 998 txtactor = Text( 999 babase.Lstr(resource='difficultyHardOnlyText'), 1000 host_only=True, 1001 maxwidth=txt2_max_w * 0.7, 1002 position=(x + 60, y + 5), 1003 transition=Text.Transition.FADE_IN, 1004 vr_depth=-5, 1005 h_attach=h_attach, 1006 v_attach=v_attach, 1007 h_align=Text.HAlign.CENTER, 1008 v_align=Text.VAlign.CENTER, 1009 scale=txt_s * 0.8, 1010 flatness=1.0, 1011 shadow=0.5, 1012 color=(1, 1, 0.6, 1), 1013 transition_delay=delay + 0.1, 1014 transition_out_delay=out_delay_fin, 1015 ).autoretain() 1016 txtactor.node.rotate = 10 1017 objs.append(txtactor) 1018 1019 # Ticket-award. 1020 award_x = -100 1021 objs.append( 1022 Text( 1023 babase.charstr(babase.SpecialChar.TICKET), 1024 host_only=True, 1025 position=(x + award_x + 33, y + 7), 1026 transition=Text.Transition.FADE_IN, 1027 scale=1.5, 1028 h_attach=h_attach, 1029 v_attach=v_attach, 1030 h_align=Text.HAlign.CENTER, 1031 v_align=Text.VAlign.CENTER, 1032 color=(1, 1, 1, 0.2 if hmo else 0.4), 1033 transition_delay=delay + 0.05, 1034 transition_out_delay=out_delay_fin, 1035 ).autoretain() 1036 ) 1037 objs.append( 1038 Text( 1039 '+' + str(self.get_award_ticket_value()), 1040 host_only=True, 1041 position=(x + award_x + 28, y + 16), 1042 transition=Text.Transition.FADE_IN, 1043 scale=0.7, 1044 flatness=1, 1045 h_attach=h_attach, 1046 v_attach=v_attach, 1047 h_align=Text.HAlign.CENTER, 1048 v_align=Text.VAlign.CENTER, 1049 color=cl2, 1050 transition_delay=delay + 0.05, 1051 transition_out_delay=out_delay_fin, 1052 ).autoretain() 1053 ) 1054 1055 else: 1056 complete = self.complete 1057 objs = [] 1058 c_icon = self.get_icon_color(complete) 1059 if hmo and not complete: 1060 c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) 1061 objs.append( 1062 Image( 1063 self.get_icon_texture(complete), 1064 host_only=True, 1065 color=c_icon, 1066 position=(x - 25, y + 5), 1067 attach=attach, 1068 vr_depth=4, 1069 transition=Image.Transition.IN_RIGHT, 1070 transition_delay=delay, 1071 transition_out_delay=None, 1072 scale=(40, 40), 1073 ).autoretain() 1074 ) 1075 if complete: 1076 objs.append( 1077 Image( 1078 bascenev1.gettexture('achievementOutline'), 1079 host_only=True, 1080 mesh_transparent=bascenev1.getmesh( 1081 'achievementOutline' 1082 ), 1083 color=(2, 1.4, 0.4, 1), 1084 vr_depth=8, 1085 position=(x - 25, y + 5), 1086 attach=attach, 1087 transition=Image.Transition.IN_RIGHT, 1088 transition_delay=delay, 1089 transition_out_delay=None, 1090 scale=(40, 40), 1091 ).autoretain() 1092 ) 1093 else: 1094 if not complete: 1095 award_x = -100 1096 objs.append( 1097 Text( 1098 babase.charstr(babase.SpecialChar.TICKET), 1099 host_only=True, 1100 position=(x + award_x + 33, y + 7), 1101 transition=Text.Transition.IN_RIGHT, 1102 scale=1.5, 1103 h_attach=h_attach, 1104 v_attach=v_attach, 1105 h_align=Text.HAlign.CENTER, 1106 v_align=Text.VAlign.CENTER, 1107 color=(1, 1, 1, (0.1 if hmo else 0.2)), 1108 transition_delay=delay + 0.05, 1109 transition_out_delay=None, 1110 ).autoretain() 1111 ) 1112 objs.append( 1113 Text( 1114 '+' + str(self.get_award_ticket_value()), 1115 host_only=True, 1116 position=(x + award_x + 28, y + 16), 1117 transition=Text.Transition.IN_RIGHT, 1118 scale=0.7, 1119 flatness=1, 1120 h_attach=h_attach, 1121 v_attach=v_attach, 1122 h_align=Text.HAlign.CENTER, 1123 v_align=Text.VAlign.CENTER, 1124 color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)), 1125 transition_delay=delay + 0.05, 1126 transition_out_delay=None, 1127 ).autoretain() 1128 ) 1129 1130 # Show 'hard-mode-only' only over incomplete achievements 1131 # when that's the case. 1132 if hmo: 1133 txtactor = Text( 1134 babase.Lstr(resource='difficultyHardOnlyText'), 1135 host_only=True, 1136 maxwidth=300 * 0.7, 1137 position=(x + 60, y + 5), 1138 transition=Text.Transition.FADE_IN, 1139 vr_depth=-5, 1140 h_attach=h_attach, 1141 v_attach=v_attach, 1142 h_align=Text.HAlign.CENTER, 1143 v_align=Text.VAlign.CENTER, 1144 scale=0.85 * 0.8, 1145 flatness=1.0, 1146 shadow=0.5, 1147 color=(1, 1, 0.6, 1), 1148 transition_delay=delay + 0.05, 1149 transition_out_delay=None, 1150 ).autoretain() 1151 assert txtactor.node 1152 txtactor.node.rotate = 10 1153 objs.append(txtactor) 1154 1155 objs.append( 1156 Text( 1157 self.display_name, 1158 host_only=True, 1159 maxwidth=300, 1160 position=(x, y + 2), 1161 transition=Text.Transition.IN_RIGHT, 1162 scale=0.85, 1163 flatness=0.6, 1164 h_attach=h_attach, 1165 v_attach=v_attach, 1166 color=( 1167 (0.8, 0.93, 0.8, 1.0) 1168 if complete 1169 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1170 ), 1171 transition_delay=delay + 0.05, 1172 transition_out_delay=None, 1173 ).autoretain() 1174 ) 1175 objs.append( 1176 Text( 1177 self.description_complete if complete else self.description, 1178 host_only=True, 1179 maxwidth=400, 1180 position=(x, y - 14), 1181 transition=Text.Transition.IN_RIGHT, 1182 vr_depth=-5, 1183 h_attach=h_attach, 1184 v_attach=v_attach, 1185 scale=0.62, 1186 flatness=1.0, 1187 color=( 1188 (0.6, 0.6, 0.6, 1.0) 1189 if complete 1190 else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) 1191 ), 1192 transition_delay=delay + 0.1, 1193 transition_out_delay=None, 1194 ).autoretain() 1195 ) 1196 return objs
Create a display for the Achievement.
Shows the Achievement icon, name, and description.
69class AchievementSubsystem: 70 """Subsystem for achievement handling. 71 72 Category: **App Classes** 73 74 Access the single shared instance of this class at 'ba.app.ach'. 75 """ 76 77 def __init__(self) -> None: 78 self.achievements: list[Achievement] = [] 79 self.achievements_to_display: list[ 80 tuple[baclassic.Achievement, bool] 81 ] = [] 82 self.achievement_display_timer: bascenev1.BaseTimer | None = None 83 self.last_achievement_display_time: float = 0.0 84 self.achievement_completion_banner_slots: set[int] = set() 85 self._init_achievements() 86 87 def _init_achievements(self) -> None: 88 """Fill in available achievements.""" 89 90 achs = self.achievements 91 92 # 5 93 achs.append( 94 Achievement('In Control', 'achievementInControl', (1, 1, 1), '', 5) 95 ) 96 # 15 97 achs.append( 98 Achievement( 99 'Sharing is Caring', 100 'achievementSharingIsCaring', 101 (1, 1, 1), 102 '', 103 15, 104 ) 105 ) 106 # 10 107 achs.append( 108 Achievement( 109 'Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', 10 110 ) 111 ) 112 113 # 10 114 achs.append( 115 Achievement( 116 'Free Loader', 'achievementFreeLoader', (1, 1, 1), '', 10 117 ) 118 ) 119 # 20 120 achs.append( 121 Achievement( 122 'Team Player', 'achievementTeamPlayer', (1, 1, 1), '', 20 123 ) 124 ) 125 126 # 5 127 achs.append( 128 Achievement( 129 'Onslaught Training Victory', 130 'achievementOnslaught', 131 (1, 1, 1), 132 'Default:Onslaught Training', 133 5, 134 ) 135 ) 136 # 5 137 achs.append( 138 Achievement( 139 'Off You Go Then', 140 'achievementOffYouGo', 141 (1, 1.1, 1.3), 142 'Default:Onslaught Training', 143 5, 144 ) 145 ) 146 # 10 147 achs.append( 148 Achievement( 149 'Boxer', 150 'achievementBoxer', 151 (1, 0.6, 0.6), 152 'Default:Onslaught Training', 153 10, 154 hard_mode_only=True, 155 ) 156 ) 157 158 # 10 159 achs.append( 160 Achievement( 161 'Rookie Onslaught Victory', 162 'achievementOnslaught', 163 (0.5, 1.4, 0.6), 164 'Default:Rookie Onslaught', 165 10, 166 ) 167 ) 168 # 10 169 achs.append( 170 Achievement( 171 'Mine Games', 172 'achievementMine', 173 (1, 1, 1.4), 174 'Default:Rookie Onslaught', 175 10, 176 ) 177 ) 178 # 15 179 achs.append( 180 Achievement( 181 'Flawless Victory', 182 'achievementFlawlessVictory', 183 (1, 1, 1), 184 'Default:Rookie Onslaught', 185 15, 186 hard_mode_only=True, 187 ) 188 ) 189 190 # 10 191 achs.append( 192 Achievement( 193 'Rookie Football Victory', 194 'achievementFootballVictory', 195 (1.0, 1, 0.6), 196 'Default:Rookie Football', 197 10, 198 ) 199 ) 200 # 10 201 achs.append( 202 Achievement( 203 'Super Punch', 204 'achievementSuperPunch', 205 (1, 1, 1.8), 206 'Default:Rookie Football', 207 10, 208 ) 209 ) 210 # 15 211 achs.append( 212 Achievement( 213 'Rookie Football Shutout', 214 'achievementFootballShutout', 215 (1, 1, 1), 216 'Default:Rookie Football', 217 15, 218 hard_mode_only=True, 219 ) 220 ) 221 222 # 15 223 achs.append( 224 Achievement( 225 'Pro Onslaught Victory', 226 'achievementOnslaught', 227 (0.3, 1, 2.0), 228 'Default:Pro Onslaught', 229 15, 230 ) 231 ) 232 # 15 233 achs.append( 234 Achievement( 235 'Boom Goes the Dynamite', 236 'achievementTNT', 237 (1.4, 1.2, 0.8), 238 'Default:Pro Onslaught', 239 15, 240 ) 241 ) 242 # 20 243 achs.append( 244 Achievement( 245 'Pro Boxer', 246 'achievementBoxer', 247 (2, 2, 0), 248 'Default:Pro Onslaught', 249 20, 250 hard_mode_only=True, 251 ) 252 ) 253 254 # 15 255 achs.append( 256 Achievement( 257 'Pro Football Victory', 258 'achievementFootballVictory', 259 (1.3, 1.3, 2.0), 260 'Default:Pro Football', 261 15, 262 ) 263 ) 264 # 15 265 achs.append( 266 Achievement( 267 'Super Mega Punch', 268 'achievementSuperPunch', 269 (2, 1, 0.6), 270 'Default:Pro Football', 271 15, 272 ) 273 ) 274 # 20 275 achs.append( 276 Achievement( 277 'Pro Football Shutout', 278 'achievementFootballShutout', 279 (0.7, 0.7, 2.0), 280 'Default:Pro Football', 281 20, 282 hard_mode_only=True, 283 ) 284 ) 285 286 # 15 287 achs.append( 288 Achievement( 289 'Pro Runaround Victory', 290 'achievementRunaround', 291 (1, 1, 1), 292 'Default:Pro Runaround', 293 15, 294 ) 295 ) 296 # 20 297 achs.append( 298 Achievement( 299 'Precision Bombing', 300 'achievementCrossHair', 301 (1, 1, 1.3), 302 'Default:Pro Runaround', 303 20, 304 hard_mode_only=True, 305 ) 306 ) 307 # 25 308 achs.append( 309 Achievement( 310 'The Wall', 311 'achievementWall', 312 (1, 0.7, 0.7), 313 'Default:Pro Runaround', 314 25, 315 hard_mode_only=True, 316 ) 317 ) 318 319 # 30 320 achs.append( 321 Achievement( 322 'Uber Onslaught Victory', 323 'achievementOnslaught', 324 (2, 2, 1), 325 'Default:Uber Onslaught', 326 30, 327 ) 328 ) 329 # 30 330 achs.append( 331 Achievement( 332 'Gold Miner', 333 'achievementMine', 334 (2, 1.6, 0.2), 335 'Default:Uber Onslaught', 336 30, 337 hard_mode_only=True, 338 ) 339 ) 340 # 30 341 achs.append( 342 Achievement( 343 'TNT Terror', 344 'achievementTNT', 345 (2, 1.8, 0.3), 346 'Default:Uber Onslaught', 347 30, 348 hard_mode_only=True, 349 ) 350 ) 351 352 # 30 353 achs.append( 354 Achievement( 355 'Uber Football Victory', 356 'achievementFootballVictory', 357 (1.8, 1.4, 0.3), 358 'Default:Uber Football', 359 30, 360 ) 361 ) 362 # 30 363 achs.append( 364 Achievement( 365 'Got the Moves', 366 'achievementGotTheMoves', 367 (2, 1, 0), 368 'Default:Uber Football', 369 30, 370 hard_mode_only=True, 371 ) 372 ) 373 # 40 374 achs.append( 375 Achievement( 376 'Uber Football Shutout', 377 'achievementFootballShutout', 378 (2, 2, 0), 379 'Default:Uber Football', 380 40, 381 hard_mode_only=True, 382 ) 383 ) 384 385 # 30 386 achs.append( 387 Achievement( 388 'Uber Runaround Victory', 389 'achievementRunaround', 390 (1.5, 1.2, 0.2), 391 'Default:Uber Runaround', 392 30, 393 ) 394 ) 395 # 40 396 achs.append( 397 Achievement( 398 'The Great Wall', 399 'achievementWall', 400 (2, 1.7, 0.4), 401 'Default:Uber Runaround', 402 40, 403 hard_mode_only=True, 404 ) 405 ) 406 # 40 407 achs.append( 408 Achievement( 409 'Stayin\' Alive', 410 'achievementStayinAlive', 411 (2, 2, 1), 412 'Default:Uber Runaround', 413 40, 414 hard_mode_only=True, 415 ) 416 ) 417 418 # 20 419 achs.append( 420 Achievement( 421 'Last Stand Master', 422 'achievementMedalSmall', 423 (2, 1.5, 0.3), 424 'Default:The Last Stand', 425 20, 426 hard_mode_only=True, 427 ) 428 ) 429 # 40 430 achs.append( 431 Achievement( 432 'Last Stand Wizard', 433 'achievementMedalMedium', 434 (2, 1.5, 0.3), 435 'Default:The Last Stand', 436 40, 437 hard_mode_only=True, 438 ) 439 ) 440 # 60 441 achs.append( 442 Achievement( 443 'Last Stand God', 444 'achievementMedalLarge', 445 (2, 1.5, 0.3), 446 'Default:The Last Stand', 447 60, 448 hard_mode_only=True, 449 ) 450 ) 451 452 # 5 453 achs.append( 454 Achievement( 455 'Onslaught Master', 456 'achievementMedalSmall', 457 (0.7, 1, 0.7), 458 'Challenges:Infinite Onslaught', 459 5, 460 ) 461 ) 462 # 15 463 achs.append( 464 Achievement( 465 'Onslaught Wizard', 466 'achievementMedalMedium', 467 (0.7, 1.0, 0.7), 468 'Challenges:Infinite Onslaught', 469 15, 470 ) 471 ) 472 # 30 473 achs.append( 474 Achievement( 475 'Onslaught God', 476 'achievementMedalLarge', 477 (0.7, 1.0, 0.7), 478 'Challenges:Infinite Onslaught', 479 30, 480 ) 481 ) 482 483 # 5 484 achs.append( 485 Achievement( 486 'Runaround Master', 487 'achievementMedalSmall', 488 (1.0, 1.0, 1.2), 489 'Challenges:Infinite Runaround', 490 5, 491 ) 492 ) 493 # 15 494 achs.append( 495 Achievement( 496 'Runaround Wizard', 497 'achievementMedalMedium', 498 (1.0, 1.0, 1.2), 499 'Challenges:Infinite Runaround', 500 15, 501 ) 502 ) 503 # 30 504 achs.append( 505 Achievement( 506 'Runaround God', 507 'achievementMedalLarge', 508 (1.0, 1.0, 1.2), 509 'Challenges:Infinite Runaround', 510 30, 511 ) 512 ) 513 514 def award_local_achievement(self, achname: str) -> None: 515 """For non-game-based achievements such as controller-connection.""" 516 plus = babase.app.plus 517 if plus is None: 518 logging.warning('achievements require plus feature-set') 519 return 520 try: 521 ach = self.get_achievement(achname) 522 if not ach.complete: 523 # Report new achievements to the game-service. 524 plus.report_achievement(achname) 525 526 # And to our account. 527 plus.add_v1_account_transaction( 528 {'type': 'ACHIEVEMENT', 'name': achname} 529 ) 530 531 # Now attempt to show a banner. 532 self.display_achievement_banner(achname) 533 534 except Exception: 535 logging.exception('Error in award_local_achievement.') 536 537 def display_achievement_banner(self, achname: str) -> None: 538 """Display a completion banner for an achievement. 539 540 (internal) 541 542 Used for server-driven achievements. 543 """ 544 try: 545 # FIXME: Need to get these using the UI context or some other 546 # purely local context somehow instead of trying to inject these 547 # into whatever activity happens to be active 548 # (since that won't work while in client mode). 549 activity = bascenev1.get_foreground_host_activity() 550 if activity is not None: 551 with activity.context: 552 self.get_achievement(achname).announce_completion() 553 except Exception: 554 logging.exception('Error in display_achievement_banner.') 555 556 def set_completed_achievements(self, achs: Sequence[str]) -> None: 557 """Set the current state of completed achievements. 558 559 (internal) 560 561 All achievements not included here will be set incomplete. 562 """ 563 564 # Note: This gets called whenever game-center/game-circle/etc tells 565 # us which achievements we currently have. We always defer to them, 566 # even if that means we have to un-set an achievement we think we have. 567 568 cfg = babase.app.config 569 cfg['Achievements'] = {} 570 for a_name in achs: 571 self.get_achievement(a_name).set_complete(True) 572 cfg.commit() 573 574 def get_achievement(self, name: str) -> Achievement: 575 """Return an Achievement by name.""" 576 achs = [a for a in self.achievements if a.name == name] 577 assert len(achs) < 2 578 if not achs: 579 raise ValueError("Invalid achievement name: '" + name + "'") 580 return achs[0] 581 582 def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: 583 """Given a level name, return achievements available for it.""" 584 585 # For the Easy campaign we return achievements for the Default 586 # campaign too. (want the user to see what achievements are part of the 587 # level even if they can't unlock them all on easy mode). 588 return [ 589 a 590 for a in self.achievements 591 if a.level_name 592 in (level_name, level_name.replace('Easy', 'Default')) 593 ] 594 595 def _test(self) -> None: 596 """For testing achievement animations.""" 597 598 def testcall1() -> None: 599 self.achievements[0].announce_completion() 600 self.achievements[1].announce_completion() 601 self.achievements[2].announce_completion() 602 603 def testcall2() -> None: 604 self.achievements[3].announce_completion() 605 self.achievements[4].announce_completion() 606 self.achievements[5].announce_completion() 607 608 bascenev1.basetimer(3.0, testcall1) 609 bascenev1.basetimer(7.0, testcall2)
Subsystem for achievement handling.
Category: App Classes
Access the single shared instance of this class at 'ba.app.ach'.
514 def award_local_achievement(self, achname: str) -> None: 515 """For non-game-based achievements such as controller-connection.""" 516 plus = babase.app.plus 517 if plus is None: 518 logging.warning('achievements require plus feature-set') 519 return 520 try: 521 ach = self.get_achievement(achname) 522 if not ach.complete: 523 # Report new achievements to the game-service. 524 plus.report_achievement(achname) 525 526 # And to our account. 527 plus.add_v1_account_transaction( 528 {'type': 'ACHIEVEMENT', 'name': achname} 529 ) 530 531 # Now attempt to show a banner. 532 self.display_achievement_banner(achname) 533 534 except Exception: 535 logging.exception('Error in award_local_achievement.')
For non-game-based achievements such as controller-connection.
574 def get_achievement(self, name: str) -> Achievement: 575 """Return an Achievement by name.""" 576 achs = [a for a in self.achievements if a.name == name] 577 assert len(achs) < 2 578 if not achs: 579 raise ValueError("Invalid achievement name: '" + name + "'") 580 return achs[0]
Return an Achievement by name.
582 def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: 583 """Given a level name, return achievements available for it.""" 584 585 # For the Easy campaign we return achievements for the Default 586 # campaign too. (want the user to see what achievements are part of the 587 # level even if they can't unlock them all on easy mode). 588 return [ 589 a 590 for a in self.achievements 591 if a.level_name 592 in (level_name, level_name.replace('Easy', 'Default')) 593 ]
Given a level name, return achievements available for it.