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_reset_timer: babase.AppTimer | None = None 74 self.value_test_defaults: dict = {} 75 self.special_offer: dict | None = None 76 self.ping_thread_count = 0 77 self.allow_ticket_purchases: bool = True 78 79 # Main Menu. 80 self.main_menu_did_initial_transition = False 81 self.main_menu_last_news_fetch_time: float | None = None 82 83 # Spaz. 84 self.spaz_appearances: dict[str, spazappearance.Appearance] = {} 85 self.last_spaz_turbo_warn_time = babase.AppTime(-99999.0) 86 87 # Server Mode. 88 self.server: ServerController | None = None 89 90 self.log_have_new = False 91 self.log_upload_timer_started = False 92 self.printed_live_object_warning = False 93 94 # We include this extra hash with shared input-mapping names so 95 # that we don't share mappings between differently-configured 96 # systems. For instance, different android devices may give different 97 # key values for the same controller type so we keep their mappings 98 # distinct. 99 self.input_map_hash: str | None = None 100 101 # Maps. 102 self.maps: dict[str, type[bascenev1.Map]] = {} 103 104 # Gameplay. 105 self.teams_series_length = 7 106 self.ffa_series_length = 24 107 self.coop_session_args: dict = {} 108 109 # UI. 110 self.first_main_menu = True # FIXME: Move to mainmenu class. 111 self.did_menu_intro = False # FIXME: Move to mainmenu class. 112 self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu. 113 self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any. 114 self.delegate: AppDelegate | None = None 115 self.party_window: weakref.ref[PartyWindow] | None = None 116 117 # Store. 118 self.store_layout: dict[str, list[dict[str, Any]]] | None = None 119 self.store_items: dict[str, dict] | None = None 120 self.pro_sale_start_time: int | None = None 121 self.pro_sale_start_val: int | None = None 122 123 @property 124 def platform(self) -> str: 125 """Name of the current platform. 126 127 Examples are: 'mac', 'windows', android'. 128 """ 129 assert isinstance(self._env['platform'], str) 130 return self._env['platform'] 131 132 def scene_v1_protocol_version(self) -> int: 133 """(internal)""" 134 return bascenev1.protocol_version() 135 136 @property 137 def subplatform(self) -> str: 138 """String for subplatform. 139 140 Can be empty. For the 'android' platform, subplatform may 141 be 'google', 'amazon', etc. 142 """ 143 assert isinstance(self._env['subplatform'], str) 144 return self._env['subplatform'] 145 146 @property 147 def legacy_user_agent_string(self) -> str: 148 """String containing various bits of info about OS/device/etc.""" 149 assert isinstance(self._env['legacy_user_agent_string'], str) 150 return self._env['legacy_user_agent_string'] 151 152 def on_app_loading(self) -> None: 153 from bascenev1lib.actor import spazappearance 154 from bascenev1lib import maps as stdmaps 155 156 from baclassic._appdelegate import AppDelegate 157 158 plus = babase.app.plus 159 assert plus is not None 160 161 env = babase.app.env 162 cfg = babase.app.config 163 164 self.music.on_app_loading() 165 166 self.delegate = AppDelegate() 167 168 # Non-test, non-debug builds should generally be blessed; warn if not. 169 # (so I don't accidentally release a build that can't play tourneys) 170 if not env.debug and not env.test and not plus.is_blessed(): 171 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 172 173 # FIXME: This should not be hard-coded. 174 for maptype in [ 175 stdmaps.HockeyStadium, 176 stdmaps.FootballStadium, 177 stdmaps.Bridgit, 178 stdmaps.BigG, 179 stdmaps.Roundabout, 180 stdmaps.MonkeyFace, 181 stdmaps.ZigZag, 182 stdmaps.ThePad, 183 stdmaps.DoomShroom, 184 stdmaps.LakeFrigid, 185 stdmaps.TipTop, 186 stdmaps.CragCastle, 187 stdmaps.TowerD, 188 stdmaps.HappyThoughts, 189 stdmaps.StepRightUp, 190 stdmaps.Courtyard, 191 stdmaps.Rampage, 192 ]: 193 bascenev1.register_map(maptype) 194 195 spazappearance.register_appearances() 196 bascenev1.init_campaigns() 197 198 launch_count = cfg.get('launchCount', 0) 199 launch_count += 1 200 201 # So we know how many times we've run the game at various 202 # version milestones. 203 for key in ('lc14173', 'lc14292'): 204 cfg.setdefault(key, launch_count) 205 206 cfg['launchCount'] = launch_count 207 cfg.commit() 208 209 # Run a test in a few seconds to see if we should pop up an existing 210 # pending special offer. 211 def check_special_offer() -> None: 212 assert plus is not None 213 214 from bauiv1lib.specialoffer import show_offer 215 216 if ( 217 'pendingSpecialOffer' in cfg 218 and plus.get_v1_account_public_login_id() 219 == cfg['pendingSpecialOffer']['a'] 220 ): 221 self.special_offer = cfg['pendingSpecialOffer']['o'] 222 show_offer() 223 224 if babase.app.env.gui: 225 babase.apptimer(3.0, check_special_offer) 226 227 # If there's a leftover log file, attempt to upload it to the 228 # master-server and/or get rid of it. 229 babase.handle_leftover_v1_cloud_log_file() 230 231 self.accounts.on_app_loading() 232 233 def on_app_pause(self) -> None: 234 self.accounts.on_app_pause() 235 236 def on_app_resume(self) -> None: 237 self.accounts.on_app_resume() 238 self.music.on_app_resume() 239 240 def on_app_shutdown(self) -> None: 241 self.music.on_app_shutdown() 242 243 def pause(self) -> None: 244 """Pause the game due to a user request or menu popping up. 245 246 If there's a foreground host-activity that says it's pausable, tell it 247 to pause. Note: we now no longer pause if there are connected clients. 248 """ 249 activity: bascenev1.Activity | None = ( 250 bascenev1.get_foreground_host_activity() 251 ) 252 if ( 253 activity is not None 254 and activity.allow_pausing 255 and not bascenev1.have_connected_clients() 256 ): 257 from babase import Lstr 258 from bascenev1 import NodeActor 259 260 # FIXME: Shouldn't be touching scene stuff here; 261 # should just pass the request on to the host-session. 262 with activity.context: 263 globs = activity.globalsnode 264 if not globs.paused: 265 bascenev1.getsound('refWhistle').play() 266 globs.paused = True 267 268 # FIXME: This should not be an attr on Actor. 269 activity.paused_text = NodeActor( 270 bascenev1.newnode( 271 'text', 272 attrs={ 273 'text': Lstr(resource='pausedByHostText'), 274 'client_only': True, 275 'flatness': 1.0, 276 'h_align': 'center', 277 }, 278 ) 279 ) 280 281 def resume(self) -> None: 282 """Resume the game due to a user request or menu closing. 283 284 If there's a foreground host-activity that's currently paused, tell it 285 to resume. 286 """ 287 288 # FIXME: Shouldn't be touching scene stuff here; 289 # should just pass the request on to the host-session. 290 activity = bascenev1.get_foreground_host_activity() 291 if activity is not None: 292 with activity.context: 293 globs = activity.globalsnode 294 if globs.paused: 295 bascenev1.getsound('refWhistle').play() 296 globs.paused = False 297 298 # FIXME: This should not be an actor attr. 299 activity.paused_text = None 300 301 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 302 """Adds an individual level to the 'practice' section in Co-op.""" 303 304 # Assign this level to our catch-all campaign. 305 self.campaigns['Challenges'].addlevel(level) 306 307 # Make note to add it to our challenges UI. 308 self.custom_coop_practice_games.append(f'Challenges:{level.name}') 309 310 def launch_coop_game( 311 self, game: str, force: bool = False, args: dict | None = None 312 ) -> bool: 313 """High level way to launch a local co-op session.""" 314 # pylint: disable=cyclic-import 315 from bauiv1lib.coop.level import CoopLevelLockedWindow 316 317 assert babase.app.classic is not None 318 319 if args is None: 320 args = {} 321 if game == '': 322 raise ValueError('empty game name') 323 campaignname, levelname = game.split(':') 324 campaign = babase.app.classic.getcampaign(campaignname) 325 326 # If this campaign is sequential, make sure we've completed the 327 # one before this. 328 if campaign.sequential and not force: 329 for level in campaign.levels: 330 if level.name == levelname: 331 break 332 if not level.complete: 333 CoopLevelLockedWindow( 334 campaign.getlevel(levelname).displayname, 335 campaign.getlevel(level.name).displayname, 336 ) 337 return False 338 339 # Ok, we're good to go. 340 self.coop_session_args = { 341 'campaign': campaignname, 342 'level': levelname, 343 } 344 for arg_name, arg_val in list(args.items()): 345 self.coop_session_args[arg_name] = arg_val 346 347 def _fade_end() -> None: 348 from bascenev1 import CoopSession 349 350 try: 351 bascenev1.new_host_session(CoopSession) 352 except Exception: 353 logging.exception('Error creating coopsession after fade end.') 354 from bascenev1lib.mainmenu import MainMenuSession 355 356 bascenev1.new_host_session(MainMenuSession) 357 358 babase.fade_screen(False, endcall=_fade_end) 359 return True 360 361 def return_to_main_menu_session_gracefully( 362 self, reset_ui: bool = True 363 ) -> None: 364 """Attempt to cleanly get back to the main menu.""" 365 # pylint: disable=cyclic-import 366 from baclassic import _benchmark 367 from bascenev1lib.mainmenu import MainMenuSession 368 369 plus = babase.app.plus 370 assert plus is not None 371 372 if reset_ui: 373 babase.app.ui_v1.clear_main_menu_window() 374 375 if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession): 376 # It may be possible we're on the main menu but the screen is faded 377 # so fade back in. 378 babase.fade_screen(True) 379 return 380 381 _benchmark.stop_stress_test() # Stop stress-test if in progress. 382 383 # If we're in a host-session, tell them to end. 384 # This lets them tear themselves down gracefully. 385 host_session: bascenev1.Session | None = ( 386 bascenev1.get_foreground_host_session() 387 ) 388 if host_session is not None: 389 # Kick off a little transaction so we'll hopefully have all the 390 # latest account state when we get back to the menu. 391 plus.add_v1_account_transaction( 392 {'type': 'END_SESSION', 'sType': str(type(host_session))} 393 ) 394 plus.run_v1_account_transactions() 395 396 host_session.end() 397 398 # Otherwise just force the issue. 399 else: 400 babase.pushcall( 401 babase.Call(bascenev1.new_host_session, MainMenuSession) 402 ) 403 404 def getmaps(self, playtype: str) -> list[str]: 405 """Return a list of bascenev1.Map types supporting a playtype str. 406 407 Category: **Asset Functions** 408 409 Maps supporting a given playtype must provide a particular set of 410 features and lend themselves to a certain style of play. 411 412 Play Types: 413 414 'melee' 415 General fighting map. 416 Has one or more 'spawn' locations. 417 418 'team_flag' 419 For games such as Capture The Flag where each team spawns by a flag. 420 Has two or more 'spawn' locations, each with a corresponding 'flag' 421 location (based on index). 422 423 'single_flag' 424 For games such as King of the Hill or Keep Away where multiple teams 425 are fighting over a single flag. 426 Has two or more 'spawn' locations and 1 'flag_default' location. 427 428 'conquest' 429 For games such as Conquest where flags are spread throughout the map 430 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 431 432 'king_of_the_hill' - has 2+ 'spawn' locations, 433 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 434 435 'hockey' 436 For hockey games. 437 Has two 'goal' locations, corresponding 'spawn' locations, and one 438 'flag_default' location (for where puck spawns) 439 440 'football' 441 For football games. 442 Has two 'goal' locations, corresponding 'spawn' locations, and one 443 'flag_default' location (for where flag/ball/etc. spawns) 444 445 'race' 446 For racing games where players much touch each region in order. 447 Has two or more 'race_point' locations. 448 """ 449 return sorted( 450 key 451 for key, val in self.maps.items() 452 if playtype in val.get_play_types() 453 ) 454 455 def show_online_score_ui( 456 self, 457 show: str = 'general', 458 game: str | None = None, 459 game_version: str | None = None, 460 ) -> None: 461 """(internal)""" 462 bauiv1.show_online_score_ui(show, game, game_version) 463 464 def game_begin_analytics(self) -> None: 465 """(internal)""" 466 from baclassic import _analytics 467 468 _analytics.game_begin_analytics() 469 470 @classmethod 471 def json_prep(cls, data: Any) -> Any: 472 """Return a json-friendly version of the provided data. 473 474 This converts any tuples to lists and any bytes to strings 475 (interpreted as utf-8, ignoring errors). Logs errors (just once) 476 if any data is modified/discarded/unsupported. 477 """ 478 479 if isinstance(data, dict): 480 return dict( 481 (cls.json_prep(key), cls.json_prep(value)) 482 for key, value in list(data.items()) 483 ) 484 if isinstance(data, list): 485 return [cls.json_prep(element) for element in data] 486 if isinstance(data, tuple): 487 logging.exception('json_prep encountered tuple') 488 return [cls.json_prep(element) for element in data] 489 if isinstance(data, bytes): 490 try: 491 return data.decode(errors='ignore') 492 except Exception: 493 logging.exception('json_prep encountered utf-8 decode error') 494 return data.decode(errors='ignore') 495 if not isinstance(data, (str, float, bool, type(None), int)): 496 logging.exception( 497 'got unsupported type in json_prep: %s', type(data) 498 ) 499 return data 500 501 def master_server_v1_get( 502 self, 503 request: str, 504 data: dict[str, Any], 505 callback: MasterServerCallback | None = None, 506 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 507 ) -> None: 508 """Make a call to the master server via a http GET.""" 509 510 MasterServerV1CallThread( 511 request, 'get', data, callback, response_type 512 ).start() 513 514 def master_server_v1_post( 515 self, 516 request: str, 517 data: dict[str, Any], 518 callback: MasterServerCallback | None = None, 519 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 520 ) -> None: 521 """Make a call to the master server via a http POST.""" 522 MasterServerV1CallThread( 523 request, 'post', data, callback, response_type 524 ).start() 525 526 def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: 527 """Given a tournament entry, return strings for its prize levels.""" 528 from baclassic import _tournament 529 530 return _tournament.get_tournament_prize_strings(entry) 531 532 def getcampaign(self, name: str) -> bascenev1.Campaign: 533 """Return a campaign by name.""" 534 return self.campaigns[name] 535 536 def get_next_tip(self) -> str: 537 """Returns the next tip to be displayed.""" 538 if not self.tips: 539 for tip in get_all_tips(): 540 self.tips.insert(random.randint(0, len(self.tips)), tip) 541 tip = self.tips.pop() 542 return tip 543 544 def run_gpu_benchmark(self) -> None: 545 """Kick off a benchmark to test gpu speeds.""" 546 from baclassic._benchmark import run_gpu_benchmark as run 547 548 run() 549 550 def run_cpu_benchmark(self) -> None: 551 """Kick off a benchmark to test cpu speeds.""" 552 from baclassic._benchmark import run_cpu_benchmark as run 553 554 run() 555 556 def run_media_reload_benchmark(self) -> None: 557 """Kick off a benchmark to test media reloading speeds.""" 558 from baclassic._benchmark import run_media_reload_benchmark as run 559 560 run() 561 562 def run_stress_test( 563 self, 564 playlist_type: str = 'Random', 565 playlist_name: str = '__default__', 566 player_count: int = 8, 567 round_duration: int = 30, 568 ) -> None: 569 """Run a stress test.""" 570 from baclassic._benchmark import run_stress_test as run 571 572 run(playlist_type, playlist_name, player_count, round_duration) 573 574 def get_input_device_mapped_value( 575 self, device: bascenev1.InputDevice, name: str 576 ) -> Any: 577 """Returns a mapped value for an input device. 578 579 This checks the user config and falls back to default values 580 where available. 581 """ 582 return _input.get_input_device_mapped_value( 583 device.name, device.unique_identifier, name 584 ) 585 586 def get_input_device_map_hash( 587 self, inputdevice: bascenev1.InputDevice 588 ) -> str: 589 """Given an input device, return hash based on its raw input values.""" 590 del inputdevice # unused currently 591 return _input.get_input_device_map_hash() 592 593 def get_input_device_config( 594 self, inputdevice: bascenev1.InputDevice, default: bool 595 ) -> tuple[dict, str]: 596 """Given an input device, return its config dict in the app config. 597 598 The dict will be created if it does not exist. 599 """ 600 return _input.get_input_device_config( 601 inputdevice.name, inputdevice.unique_identifier, default 602 ) 603 604 def get_player_colors(self) -> list[tuple[float, float, float]]: 605 """Return user-selectable player colors.""" 606 return bascenev1.get_player_colors() 607 608 def get_player_profile_icon(self, profilename: str) -> str: 609 """Given a profile name, returns an icon string for it. 610 611 (non-account profiles only) 612 """ 613 return bascenev1.get_player_profile_icon(profilename) 614 615 def get_player_profile_colors( 616 self, 617 profilename: str | None, 618 profiles: dict[str, dict[str, Any]] | None = None, 619 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 620 """Given a profile, return colors for them.""" 621 return bascenev1.get_player_profile_colors(profilename, profiles) 622 623 def get_foreground_host_session(self) -> bascenev1.Session | None: 624 """(internal)""" 625 return bascenev1.get_foreground_host_session() 626 627 def get_foreground_host_activity(self) -> bascenev1.Activity | None: 628 """(internal)""" 629 return bascenev1.get_foreground_host_activity() 630 631 def show_config_error_window(self) -> bool: 632 """(internal)""" 633 if self.platform in ('mac', 'linux', 'windows'): 634 from bauiv1lib.configerror import ConfigErrorWindow 635 636 babase.pushcall(ConfigErrorWindow) 637 return True 638 return False 639 640 def value_test( 641 self, 642 arg: str, 643 change: float | None = None, 644 absolute: float | None = None, 645 ) -> float: 646 """(internal)""" 647 return _baclassic.value_test(arg, change, absolute) 648 649 def set_master_server_source(self, source: int) -> None: 650 """(internal)""" 651 bascenev1.set_master_server_source(source) 652 653 def get_game_port(self) -> int: 654 """(internal)""" 655 return bascenev1.get_game_port() 656 657 def v2_upgrade_window(self, login_name: str, code: str) -> None: 658 """(internal)""" 659 660 from bauiv1lib.v2upgrade import V2UpgradeWindow 661 662 V2UpgradeWindow(login_name, code) 663 664 def account_link_code_window(self, data: dict[str, Any]) -> None: 665 """(internal)""" 666 from bauiv1lib.account.link import AccountLinkCodeWindow 667 668 AccountLinkCodeWindow(data) 669 670 def server_dialog(self, delay: float, data: dict[str, Any]) -> None: 671 """(internal)""" 672 from bauiv1lib.serverdialog import ( 673 ServerDialogData, 674 ServerDialogWindow, 675 ) 676 677 try: 678 sddata = dataclass_from_dict(ServerDialogData, data) 679 except Exception: 680 sddata = None 681 logging.warning( 682 'Got malformatted ServerDialogData: %s', 683 data, 684 ) 685 if sddata is not None: 686 babase.apptimer( 687 delay, 688 babase.Call(ServerDialogWindow, sddata), 689 ) 690 691 def ticket_icon_press(self) -> None: 692 """(internal)""" 693 from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow 694 695 ResourceTypeInfoWindow( 696 origin_widget=bauiv1.get_special_widget('tickets_info_button') 697 ) 698 699 def show_url_window(self, address: str) -> None: 700 """(internal)""" 701 from bauiv1lib.url import ShowURLWindow 702 703 ShowURLWindow(address) 704 705 def quit_window(self) -> None: 706 """(internal)""" 707 from bauiv1lib.confirm import QuitWindow 708 709 QuitWindow() 710 711 def tournament_entry_window( 712 self, 713 tournament_id: str, 714 tournament_activity: bascenev1.Activity | None = None, 715 position: tuple[float, float] = (0.0, 0.0), 716 delegate: Any = None, 717 scale: float | None = None, 718 offset: tuple[float, float] = (0.0, 0.0), 719 on_close_call: Callable[[], Any] | None = None, 720 ) -> None: 721 """(internal)""" 722 from bauiv1lib.tournamententry import TournamentEntryWindow 723 724 TournamentEntryWindow( 725 tournament_id, 726 tournament_activity, 727 position, 728 delegate, 729 scale, 730 offset, 731 on_close_call, 732 ) 733 734 def get_main_menu_session(self) -> type[bascenev1.Session]: 735 """(internal)""" 736 from bascenev1lib.mainmenu import MainMenuSession 737 738 return MainMenuSession 739 740 def continues_window( 741 self, 742 activity: bascenev1.Activity, 743 cost: int, 744 continue_call: Callable[[], Any], 745 cancel_call: Callable[[], Any], 746 ) -> None: 747 """(internal)""" 748 from bauiv1lib.continues import ContinuesWindow 749 750 ContinuesWindow(activity, cost, continue_call, cancel_call) 751 752 def profile_browser_window( 753 self, 754 transition: str = 'in_right', 755 in_main_menu: bool = True, 756 selected_profile: str | None = None, 757 origin_widget: bauiv1.Widget | None = None, 758 ) -> None: 759 """(internal)""" 760 from bauiv1lib.profile.browser import ProfileBrowserWindow 761 762 ProfileBrowserWindow( 763 transition, in_main_menu, selected_profile, origin_widget 764 ) 765 766 def preload_map_preview_media(self) -> None: 767 """Preload media needed for map preview UIs. 768 769 Category: **Asset Functions** 770 """ 771 try: 772 bauiv1.getmesh('level_select_button_opaque') 773 bauiv1.getmesh('level_select_button_transparent') 774 for maptype in list(self.maps.values()): 775 map_tex_name = maptype.get_preview_texture_name() 776 if map_tex_name is not None: 777 bauiv1.gettexture(map_tex_name) 778 except Exception: 779 logging.exception('Error preloading map preview media.') 780 781 def party_icon_activate(self, origin: Sequence[float]) -> None: 782 """(internal)""" 783 from bauiv1lib.party import PartyWindow 784 from babase import app 785 786 assert app.env.gui 787 788 bauiv1.getsound('swish').play() 789 790 # If it exists, dismiss it; otherwise make a new one. 791 party_window = ( 792 None if self.party_window is None else self.party_window() 793 ) 794 if party_window is not None: 795 party_window.close() 796 else: 797 self.party_window = weakref.ref(PartyWindow(origin=origin)) 798 799 def device_menu_press(self, device_id: int | None) -> None: 800 """(internal)""" 801 from bauiv1lib.mainmenu import MainMenuWindow 802 from bauiv1 import set_ui_input_device 803 804 assert babase.app is not None 805 in_main_menu = babase.app.ui_v1.has_main_menu_window() 806 if not in_main_menu: 807 set_ui_input_device(device_id) 808 809 if babase.app.env.gui: 810 bauiv1.getsound('swish').play() 811 812 babase.app.ui_v1.set_main_menu_window( 813 MainMenuWindow().get_root_widget() 814 )
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.
String for subplatform.
Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.
152 def on_app_loading(self) -> None: 153 from bascenev1lib.actor import spazappearance 154 from bascenev1lib import maps as stdmaps 155 156 from baclassic._appdelegate import AppDelegate 157 158 plus = babase.app.plus 159 assert plus is not None 160 161 env = babase.app.env 162 cfg = babase.app.config 163 164 self.music.on_app_loading() 165 166 self.delegate = AppDelegate() 167 168 # Non-test, non-debug builds should generally be blessed; warn if not. 169 # (so I don't accidentally release a build that can't play tourneys) 170 if not env.debug and not env.test and not plus.is_blessed(): 171 babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) 172 173 # FIXME: This should not be hard-coded. 174 for maptype in [ 175 stdmaps.HockeyStadium, 176 stdmaps.FootballStadium, 177 stdmaps.Bridgit, 178 stdmaps.BigG, 179 stdmaps.Roundabout, 180 stdmaps.MonkeyFace, 181 stdmaps.ZigZag, 182 stdmaps.ThePad, 183 stdmaps.DoomShroom, 184 stdmaps.LakeFrigid, 185 stdmaps.TipTop, 186 stdmaps.CragCastle, 187 stdmaps.TowerD, 188 stdmaps.HappyThoughts, 189 stdmaps.StepRightUp, 190 stdmaps.Courtyard, 191 stdmaps.Rampage, 192 ]: 193 bascenev1.register_map(maptype) 194 195 spazappearance.register_appearances() 196 bascenev1.init_campaigns() 197 198 launch_count = cfg.get('launchCount', 0) 199 launch_count += 1 200 201 # So we know how many times we've run the game at various 202 # version milestones. 203 for key in ('lc14173', 'lc14292'): 204 cfg.setdefault(key, launch_count) 205 206 cfg['launchCount'] = launch_count 207 cfg.commit() 208 209 # Run a test in a few seconds to see if we should pop up an existing 210 # pending special offer. 211 def check_special_offer() -> None: 212 assert plus is not None 213 214 from bauiv1lib.specialoffer import show_offer 215 216 if ( 217 'pendingSpecialOffer' in cfg 218 and plus.get_v1_account_public_login_id() 219 == cfg['pendingSpecialOffer']['a'] 220 ): 221 self.special_offer = cfg['pendingSpecialOffer']['o'] 222 show_offer() 223 224 if babase.app.env.gui: 225 babase.apptimer(3.0, check_special_offer) 226 227 # If there's a leftover log file, attempt to upload it to the 228 # master-server and/or get rid of it. 229 babase.handle_leftover_v1_cloud_log_file() 230 231 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.
236 def on_app_resume(self) -> None: 237 self.accounts.on_app_resume() 238 self.music.on_app_resume()
Called when the app exits the paused state.
243 def pause(self) -> None: 244 """Pause the game due to a user request or menu popping up. 245 246 If there's a foreground host-activity that says it's pausable, tell it 247 to pause. Note: we now no longer pause if there are connected clients. 248 """ 249 activity: bascenev1.Activity | None = ( 250 bascenev1.get_foreground_host_activity() 251 ) 252 if ( 253 activity is not None 254 and activity.allow_pausing 255 and not bascenev1.have_connected_clients() 256 ): 257 from babase import Lstr 258 from bascenev1 import NodeActor 259 260 # FIXME: Shouldn't be touching scene stuff here; 261 # should just pass the request on to the host-session. 262 with activity.context: 263 globs = activity.globalsnode 264 if not globs.paused: 265 bascenev1.getsound('refWhistle').play() 266 globs.paused = True 267 268 # FIXME: This should not be an attr on Actor. 269 activity.paused_text = NodeActor( 270 bascenev1.newnode( 271 'text', 272 attrs={ 273 'text': Lstr(resource='pausedByHostText'), 274 'client_only': True, 275 'flatness': 1.0, 276 'h_align': 'center', 277 }, 278 ) 279 )
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.
281 def resume(self) -> None: 282 """Resume the game due to a user request or menu closing. 283 284 If there's a foreground host-activity that's currently paused, tell it 285 to resume. 286 """ 287 288 # FIXME: Shouldn't be touching scene stuff here; 289 # should just pass the request on to the host-session. 290 activity = bascenev1.get_foreground_host_activity() 291 if activity is not None: 292 with activity.context: 293 globs = activity.globalsnode 294 if globs.paused: 295 bascenev1.getsound('refWhistle').play() 296 globs.paused = False 297 298 # FIXME: This should not be an actor attr. 299 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.
301 def add_coop_practice_level(self, level: bascenev1.Level) -> None: 302 """Adds an individual level to the 'practice' section in Co-op.""" 303 304 # Assign this level to our catch-all campaign. 305 self.campaigns['Challenges'].addlevel(level) 306 307 # Make note to add it to our challenges UI. 308 self.custom_coop_practice_games.append(f'Challenges:{level.name}')
Adds an individual level to the 'practice' section in Co-op.
310 def launch_coop_game( 311 self, game: str, force: bool = False, args: dict | None = None 312 ) -> bool: 313 """High level way to launch a local co-op session.""" 314 # pylint: disable=cyclic-import 315 from bauiv1lib.coop.level import CoopLevelLockedWindow 316 317 assert babase.app.classic is not None 318 319 if args is None: 320 args = {} 321 if game == '': 322 raise ValueError('empty game name') 323 campaignname, levelname = game.split(':') 324 campaign = babase.app.classic.getcampaign(campaignname) 325 326 # If this campaign is sequential, make sure we've completed the 327 # one before this. 328 if campaign.sequential and not force: 329 for level in campaign.levels: 330 if level.name == levelname: 331 break 332 if not level.complete: 333 CoopLevelLockedWindow( 334 campaign.getlevel(levelname).displayname, 335 campaign.getlevel(level.name).displayname, 336 ) 337 return False 338 339 # Ok, we're good to go. 340 self.coop_session_args = { 341 'campaign': campaignname, 342 'level': levelname, 343 } 344 for arg_name, arg_val in list(args.items()): 345 self.coop_session_args[arg_name] = arg_val 346 347 def _fade_end() -> None: 348 from bascenev1 import CoopSession 349 350 try: 351 bascenev1.new_host_session(CoopSession) 352 except Exception: 353 logging.exception('Error creating coopsession after fade end.') 354 from bascenev1lib.mainmenu import MainMenuSession 355 356 bascenev1.new_host_session(MainMenuSession) 357 358 babase.fade_screen(False, endcall=_fade_end) 359 return True
High level way to launch a local co-op session.
404 def getmaps(self, playtype: str) -> list[str]: 405 """Return a list of bascenev1.Map types supporting a playtype str. 406 407 Category: **Asset Functions** 408 409 Maps supporting a given playtype must provide a particular set of 410 features and lend themselves to a certain style of play. 411 412 Play Types: 413 414 'melee' 415 General fighting map. 416 Has one or more 'spawn' locations. 417 418 'team_flag' 419 For games such as Capture The Flag where each team spawns by a flag. 420 Has two or more 'spawn' locations, each with a corresponding 'flag' 421 location (based on index). 422 423 'single_flag' 424 For games such as King of the Hill or Keep Away where multiple teams 425 are fighting over a single flag. 426 Has two or more 'spawn' locations and 1 'flag_default' location. 427 428 'conquest' 429 For games such as Conquest where flags are spread throughout the map 430 - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations. 431 432 'king_of_the_hill' - has 2+ 'spawn' locations, 433 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations 434 435 'hockey' 436 For hockey games. 437 Has two 'goal' locations, corresponding 'spawn' locations, and one 438 'flag_default' location (for where puck spawns) 439 440 'football' 441 For football games. 442 Has two 'goal' locations, corresponding 'spawn' locations, and one 443 'flag_default' location (for where flag/ball/etc. spawns) 444 445 'race' 446 For racing games where players much touch each region in order. 447 Has two or more 'race_point' locations. 448 """ 449 return sorted( 450 key 451 for key, val in self.maps.items() 452 if playtype in val.get_play_types() 453 )
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.
470 @classmethod 471 def json_prep(cls, data: Any) -> Any: 472 """Return a json-friendly version of the provided data. 473 474 This converts any tuples to lists and any bytes to strings 475 (interpreted as utf-8, ignoring errors). Logs errors (just once) 476 if any data is modified/discarded/unsupported. 477 """ 478 479 if isinstance(data, dict): 480 return dict( 481 (cls.json_prep(key), cls.json_prep(value)) 482 for key, value in list(data.items()) 483 ) 484 if isinstance(data, list): 485 return [cls.json_prep(element) for element in data] 486 if isinstance(data, tuple): 487 logging.exception('json_prep encountered tuple') 488 return [cls.json_prep(element) for element in data] 489 if isinstance(data, bytes): 490 try: 491 return data.decode(errors='ignore') 492 except Exception: 493 logging.exception('json_prep encountered utf-8 decode error') 494 return data.decode(errors='ignore') 495 if not isinstance(data, (str, float, bool, type(None), int)): 496 logging.exception( 497 'got unsupported type in json_prep: %s', type(data) 498 ) 499 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.
501 def master_server_v1_get( 502 self, 503 request: str, 504 data: dict[str, Any], 505 callback: MasterServerCallback | None = None, 506 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 507 ) -> None: 508 """Make a call to the master server via a http GET.""" 509 510 MasterServerV1CallThread( 511 request, 'get', data, callback, response_type 512 ).start()
Make a call to the master server via a http GET.
514 def master_server_v1_post( 515 self, 516 request: str, 517 data: dict[str, Any], 518 callback: MasterServerCallback | None = None, 519 response_type: MasterServerResponseType = MasterServerResponseType.JSON, 520 ) -> None: 521 """Make a call to the master server via a http POST.""" 522 MasterServerV1CallThread( 523 request, 'post', data, callback, response_type 524 ).start()
Make a call to the master server via a http POST.
526 def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: 527 """Given a tournament entry, return strings for its prize levels.""" 528 from baclassic import _tournament 529 530 return _tournament.get_tournament_prize_strings(entry)
Given a tournament entry, return strings for its prize levels.
532 def getcampaign(self, name: str) -> bascenev1.Campaign: 533 """Return a campaign by name.""" 534 return self.campaigns[name]
Return a campaign by name.
536 def get_next_tip(self) -> str: 537 """Returns the next tip to be displayed.""" 538 if not self.tips: 539 for tip in get_all_tips(): 540 self.tips.insert(random.randint(0, len(self.tips)), tip) 541 tip = self.tips.pop() 542 return tip
Returns the next tip to be displayed.
544 def run_gpu_benchmark(self) -> None: 545 """Kick off a benchmark to test gpu speeds.""" 546 from baclassic._benchmark import run_gpu_benchmark as run 547 548 run()
Kick off a benchmark to test gpu speeds.
550 def run_cpu_benchmark(self) -> None: 551 """Kick off a benchmark to test cpu speeds.""" 552 from baclassic._benchmark import run_cpu_benchmark as run 553 554 run()
Kick off a benchmark to test cpu speeds.
556 def run_media_reload_benchmark(self) -> None: 557 """Kick off a benchmark to test media reloading speeds.""" 558 from baclassic._benchmark import run_media_reload_benchmark as run 559 560 run()
Kick off a benchmark to test media reloading speeds.
562 def run_stress_test( 563 self, 564 playlist_type: str = 'Random', 565 playlist_name: str = '__default__', 566 player_count: int = 8, 567 round_duration: int = 30, 568 ) -> None: 569 """Run a stress test.""" 570 from baclassic._benchmark import run_stress_test as run 571 572 run(playlist_type, playlist_name, player_count, round_duration)
Run a stress test.
574 def get_input_device_mapped_value( 575 self, device: bascenev1.InputDevice, name: str 576 ) -> Any: 577 """Returns a mapped value for an input device. 578 579 This checks the user config and falls back to default values 580 where available. 581 """ 582 return _input.get_input_device_mapped_value( 583 device.name, device.unique_identifier, name 584 )
Returns a mapped value for an input device.
This checks the user config and falls back to default values where available.
586 def get_input_device_map_hash( 587 self, inputdevice: bascenev1.InputDevice 588 ) -> str: 589 """Given an input device, return hash based on its raw input values.""" 590 del inputdevice # unused currently 591 return _input.get_input_device_map_hash()
Given an input device, return hash based on its raw input values.
593 def get_input_device_config( 594 self, inputdevice: bascenev1.InputDevice, default: bool 595 ) -> tuple[dict, str]: 596 """Given an input device, return its config dict in the app config. 597 598 The dict will be created if it does not exist. 599 """ 600 return _input.get_input_device_config( 601 inputdevice.name, inputdevice.unique_identifier, default 602 )
Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
604 def get_player_colors(self) -> list[tuple[float, float, float]]: 605 """Return user-selectable player colors.""" 606 return bascenev1.get_player_colors()
Return user-selectable player colors.
608 def get_player_profile_icon(self, profilename: str) -> str: 609 """Given a profile name, returns an icon string for it. 610 611 (non-account profiles only) 612 """ 613 return bascenev1.get_player_profile_icon(profilename)
Given a profile name, returns an icon string for it.
(non-account profiles only)
615 def get_player_profile_colors( 616 self, 617 profilename: str | None, 618 profiles: dict[str, dict[str, Any]] | None = None, 619 ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 620 """Given a profile, return colors for them.""" 621 return bascenev1.get_player_profile_colors(profilename, profiles)
Given a profile, return colors for them.
766 def preload_map_preview_media(self) -> None: 767 """Preload media needed for map preview UIs. 768 769 Category: **Asset Functions** 770 """ 771 try: 772 bauiv1.getmesh('level_select_button_opaque') 773 bauiv1.getmesh('level_select_button_transparent') 774 for maptype in list(self.maps.values()): 775 map_tex_name = maptype.get_preview_texture_name() 776 if map_tex_name is not None: 777 bauiv1.gettexture(map_tex_name) 778 except Exception: 779 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
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.
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.
Get a babase.Lstr for the Achievement's description when complete.
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.
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: ( 80 list[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.