Source code for baclassic._achievement

# Released under the MIT License. See LICENSE for details.
#
"""Various functionality related to achievements."""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from bacommon.bs import ClassicChestAppearance
from baclassic._chest import (
    CHEST_APPEARANCE_DISPLAY_INFOS,
    CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
import babase
import bascenev1
import bauiv1

if TYPE_CHECKING:
    from typing import Any, Sequence

    import baclassic

# This could use some cleanup.
# We wear the cone of shame.
# pylint: disable=too-many-lines
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches

# FIXME: We should probably point achievements
#  at coop levels instead of hard-coding names.
#  (so level name substitution works right and whatnot).
ACH_LEVEL_NAMES = {
    'Boom Goes the Dynamite': 'Pro Onslaught',
    'Boxer': 'Onslaught Training',
    'Flawless Victory': 'Rookie Onslaught',
    'Gold Miner': 'Uber Onslaught',
    'Got the Moves': 'Uber Football',
    'Last Stand God': 'The Last Stand',
    'Last Stand Master': 'The Last Stand',
    'Last Stand Wizard': 'The Last Stand',
    'Mine Games': 'Rookie Onslaught',
    'Off You Go Then': 'Onslaught Training',
    'Onslaught God': 'Infinite Onslaught',
    'Onslaught Master': 'Infinite Onslaught',
    'Onslaught Training Victory': 'Onslaught Training',
    'Onslaught Wizard': 'Infinite Onslaught',
    'Precision Bombing': 'Pro Runaround',
    'Pro Boxer': 'Pro Onslaught',
    'Pro Football Shutout': 'Pro Football',
    'Pro Football Victory': 'Pro Football',
    'Pro Onslaught Victory': 'Pro Onslaught',
    'Pro Runaround Victory': 'Pro Runaround',
    'Rookie Football Shutout': 'Rookie Football',
    'Rookie Football Victory': 'Rookie Football',
    'Rookie Onslaught Victory': 'Rookie Onslaught',
    'Runaround God': 'Infinite Runaround',
    'Runaround Master': 'Infinite Runaround',
    'Runaround Wizard': 'Infinite Runaround',
    'Stayin\' Alive': 'Uber Runaround',
    'Super Mega Punch': 'Pro Football',
    'Super Punch': 'Rookie Football',
    'TNT Terror': 'Uber Onslaught',
    'The Great Wall': 'Uber Runaround',
    'The Wall': 'Pro Runaround',
    'Uber Football Shutout': 'Uber Football',
    'Uber Football Victory': 'Uber Football',
    'Uber Onslaught Victory': 'Uber Onslaught',
    'Uber Runaround Victory': 'Uber Runaround',
}


[docs] class AchievementSubsystem: """Subsystem for achievement handling. Access the single shared instance of this class at 'ba.app.ach'. """ def __init__(self) -> None: self.achievements: list[Achievement] = [] self.achievements_to_display: list[ tuple[baclassic.Achievement, bool] ] = [] self.achievement_display_timer: bascenev1.BaseTimer | None = None self.last_achievement_display_time: float = 0.0 self.achievement_completion_banner_slots: set[int] = set() self._init_achievements() def _init_achievements(self) -> None: """Fill in available achievements.""" self.achievements += [ Achievement( 'In Control', 'achievementInControl', (1, 1, 1), '', award=5, ), Achievement( 'Sharing is Caring', 'achievementSharingIsCaring', (1, 1, 1), '', award=15, ), Achievement( 'Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', award=10, ), Achievement( 'Free Loader', 'achievementFreeLoader', (1, 1, 1), '', award=10, ), Achievement( 'Team Player', 'achievementTeamPlayer', (1, 1, 1), '', award=20, ), Achievement( 'Onslaught Training Victory', 'achievementOnslaught', (1, 1, 1), 'Default:Onslaught Training', award=5, ), Achievement( 'Off You Go Then', 'achievementOffYouGo', (1, 1.1, 1.3), 'Default:Onslaught Training', award=5, ), Achievement( 'Boxer', 'achievementBoxer', (1, 0.6, 0.6), 'Default:Onslaught Training', award=10, hard_mode_only=True, ), Achievement( 'Rookie Onslaught Victory', 'achievementOnslaught', (0.5, 1.4, 0.6), 'Default:Rookie Onslaught', award=10, ), Achievement( 'Mine Games', 'achievementMine', (1, 1, 1.4), 'Default:Rookie Onslaught', award=10, ), Achievement( 'Flawless Victory', 'achievementFlawlessVictory', (1, 1, 1), 'Default:Rookie Onslaught', award=15, hard_mode_only=True, ), Achievement( 'Rookie Football Victory', 'achievementFootballVictory', (1.0, 1, 0.6), 'Default:Rookie Football', award=10, ), Achievement( 'Super Punch', 'achievementSuperPunch', (1, 1, 1.8), 'Default:Rookie Football', award=10, ), Achievement( 'Rookie Football Shutout', 'achievementFootballShutout', (1, 1, 1), 'Default:Rookie Football', award=15, hard_mode_only=True, ), Achievement( 'Pro Onslaught Victory', 'achievementOnslaught', (0.3, 1, 2.0), 'Default:Pro Onslaught', award=15, ), Achievement( 'Boom Goes the Dynamite', 'achievementTNT', (1.4, 1.2, 0.8), 'Default:Pro Onslaught', award=15, ), Achievement( 'Pro Boxer', 'achievementBoxer', (2, 2, 0), 'Default:Pro Onslaught', award=20, hard_mode_only=True, ), Achievement( 'Pro Football Victory', 'achievementFootballVictory', (1.3, 1.3, 2.0), 'Default:Pro Football', award=15, ), Achievement( 'Super Mega Punch', 'achievementSuperPunch', (2, 1, 0.6), 'Default:Pro Football', award=15, ), Achievement( 'Pro Football Shutout', 'achievementFootballShutout', (0.7, 0.7, 2.0), 'Default:Pro Football', award=20, hard_mode_only=True, ), Achievement( 'Pro Runaround Victory', 'achievementRunaround', (1, 1, 1), 'Default:Pro Runaround', award=15, ), Achievement( 'Precision Bombing', 'achievementCrossHair', (1, 1, 1.3), 'Default:Pro Runaround', award=20, hard_mode_only=True, ), Achievement( 'The Wall', 'achievementWall', (1, 0.7, 0.7), 'Default:Pro Runaround', award=25, hard_mode_only=True, ), Achievement( 'Uber Onslaught Victory', 'achievementOnslaught', (2, 2, 1), 'Default:Uber Onslaught', award=30, ), Achievement( 'Gold Miner', 'achievementMine', (2, 1.6, 0.2), 'Default:Uber Onslaught', award=30, hard_mode_only=True, ), Achievement( 'TNT Terror', 'achievementTNT', (2, 1.8, 0.3), 'Default:Uber Onslaught', award=30, hard_mode_only=True, ), Achievement( 'Uber Football Victory', 'achievementFootballVictory', (1.8, 1.4, 0.3), 'Default:Uber Football', award=30, ), Achievement( 'Got the Moves', 'achievementGotTheMoves', (2, 1, 0), 'Default:Uber Football', award=30, hard_mode_only=True, ), Achievement( 'Uber Football Shutout', 'achievementFootballShutout', (2, 2, 0), 'Default:Uber Football', award=40, hard_mode_only=True, ), Achievement( 'Uber Runaround Victory', 'achievementRunaround', (1.5, 1.2, 0.2), 'Default:Uber Runaround', award=30, ), Achievement( 'The Great Wall', 'achievementWall', (2, 1.7, 0.4), 'Default:Uber Runaround', award=40, hard_mode_only=True, ), Achievement( 'Stayin\' Alive', 'achievementStayinAlive', (2, 2, 1), 'Default:Uber Runaround', award=40, hard_mode_only=True, ), Achievement( 'Last Stand Master', 'achievementMedalSmall', (2, 1.5, 0.3), 'Default:The Last Stand', award=20, hard_mode_only=True, ), Achievement( 'Last Stand Wizard', 'achievementMedalMedium', (2, 1.5, 0.3), 'Default:The Last Stand', award=40, hard_mode_only=True, ), Achievement( 'Last Stand God', 'achievementMedalLarge', (2, 1.5, 0.3), 'Default:The Last Stand', award=60, hard_mode_only=True, ), Achievement( 'Onslaught Master', 'achievementMedalSmall', (0.7, 1, 0.7), 'Challenges:Infinite Onslaught', award=5, ), Achievement( 'Onslaught Wizard', 'achievementMedalMedium', (0.7, 1.0, 0.7), 'Challenges:Infinite Onslaught', award=15, ), Achievement( 'Onslaught God', 'achievementMedalLarge', (0.7, 1.0, 0.7), 'Challenges:Infinite Onslaught', award=30, ), Achievement( 'Runaround Master', 'achievementMedalSmall', (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', award=5, ), Achievement( 'Runaround Wizard', 'achievementMedalMedium', (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', award=15, ), Achievement( 'Runaround God', 'achievementMedalLarge', (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', award=30, ), ]
[docs] def award_local_achievement(self, achname: str) -> None: """For non-game-based achievements such as controller-connection.""" plus = babase.app.plus if plus is None: logging.warning('achievements require plus feature-set') return try: ach = self.get_achievement(achname) if not ach.complete: # Report new achievements to the game-service. plus.report_achievement(achname) # And to our account. plus.add_v1_account_transaction( {'type': 'ACHIEVEMENT', 'name': achname} ) # Now attempt to show a banner. self.display_achievement_banner(achname) except Exception: logging.exception('Error in award_local_achievement.')
[docs] def display_achievement_banner(self, achname: str) -> None: """Display a completion banner for an achievement. (internal) Used for server-driven achievements. """ try: # FIXME: Need to get these using the UI context or some other # purely local context somehow instead of trying to inject these # into whatever activity happens to be active # (since that won't work while in client mode). activity = bascenev1.get_foreground_host_activity() if activity is not None: with activity.context: self.get_achievement(achname).announce_completion() except Exception: logging.exception('Error in display_achievement_banner.')
[docs] def set_completed_achievements(self, achs: Sequence[str]) -> None: """Set the current state of completed achievements. (internal) All achievements not included here will be set incomplete. """ # Note: This gets called whenever game-center/game-circle/etc tells # us which achievements we currently have. We always defer to them, # even if that means we have to un-set an achievement we think we have. cfg = babase.app.config cfg['Achievements'] = {} for a_name in achs: self.get_achievement(a_name).set_complete(True) cfg.commit()
[docs] def get_achievement(self, name: str) -> Achievement: """Return an Achievement by name.""" achs = [a for a in self.achievements if a.name == name] assert len(achs) < 2 if not achs: raise ValueError("Invalid achievement name: '" + name + "'") return achs[0]
[docs] def achievements_for_coop_level(self, level_name: str) -> list[Achievement]: """Given a level name, return achievements available for it.""" # For the Easy campaign we return achievements for the Default # campaign too. (want the user to see what achievements are part of the # level even if they can't unlock them all on easy mode). return [ a for a in self.achievements if a.level_name in (level_name, level_name.replace('Easy', 'Default')) ]
def _test(self) -> None: """For testing achievement animations.""" def testcall1() -> None: self.achievements[0].announce_completion() self.achievements[1].announce_completion() self.achievements[2].announce_completion() def testcall2() -> None: self.achievements[3].announce_completion() self.achievements[4].announce_completion() self.achievements[5].announce_completion() bascenev1.basetimer(3.0, testcall1) bascenev1.basetimer(7.0, testcall2)
def _get_ach_mult(include_pro_bonus: bool = False) -> int: """Return the multiplier for achievement pts. (just for display; changing this here won't affect actual rewards) """ plus = babase.app.plus classic = babase.app.classic if plus is None or classic is None: return 5 val: int = plus.get_v1_account_misc_read_val('achAwardMult', 5) assert isinstance(val, int) if include_pro_bonus and classic.accounts.have_pro(): val *= 2 return val def _display_next_achievement() -> None: # Pull the first achievement off the list and display it, or kill the # display-timer if the list is empty. app = babase.app assert app.classic is not None ach_ss = app.classic.ach if app.classic.ach.achievements_to_display: try: ach, sound = ach_ss.achievements_to_display.pop(0) ach.show_completion_banner(sound) except Exception: logging.exception('Error in _display_next_achievement.') ach_ss.achievements_to_display = [] ach_ss.achievement_display_timer = None else: ach_ss.achievement_display_timer = None
[docs] class Achievement: """Represents attributes and state for an individual achievement.""" def __init__( self, name: str, icon_name: str, icon_color: tuple[float, float, float], level_name: str, *, award: int, hard_mode_only: bool = False, ): self._name = name self._icon_name = icon_name assert len(icon_color) == 3 self._icon_color = icon_color + (1.0,) self._level_name = level_name self._completion_banner_slot: int | None = None self._award = award self._hard_mode_only = hard_mode_only @property def name(self) -> str: """The name of this achievement.""" return self._name @property def level_name(self) -> str: """The name of the level this achievement applies to.""" return self._level_name
[docs] def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture: """Return the icon texture to display for this achievement""" return bauiv1.gettexture( self._icon_name if complete else 'achievementEmpty' )
[docs] def get_icon_texture(self, complete: bool) -> bascenev1.Texture: """Return the icon texture to display for this achievement""" return bascenev1.gettexture( self._icon_name if complete else 'achievementEmpty' )
[docs] def get_icon_color(self, complete: bool) -> Sequence[float]: """Return the color tint for this Achievement's icon.""" if complete: return self._icon_color return 1.0, 1.0, 1.0, 0.6
@property def hard_mode_only(self) -> bool: """Whether this Achievement is only unlockable in hard-mode.""" return self._hard_mode_only @property def complete(self) -> bool: """Whether this Achievement is currently complete.""" val: bool = self._getconfig()['Complete'] assert isinstance(val, bool) return val
[docs] def announce_completion(self, sound: bool = True) -> None: """Kick off an announcement for this achievement's completion.""" app = babase.app plus = app.plus classic = app.classic if plus is None or classic is None: logging.warning('ach account_completion not available.') return ach_ss = classic.ach # Even though there are technically achievements when we're not # signed in, lets not show them (otherwise we tend to get # confusing 'controller connected' achievements popping up while # waiting to sign in which can be confusing). if plus.get_v1_account_state() != 'signed_in': return # If we're being freshly complete, display/report it and whatnot. if (self, sound) not in ach_ss.achievements_to_display: ach_ss.achievements_to_display.append((self, sound)) # If there's no achievement display timer going, kick one off # (if one's already running it will pick this up before it dies). # Need to check last time too; its possible our timer wasn't able to # clear itself if an activity died and took it down with it. if ( ach_ss.achievement_display_timer is None or babase.apptime() - ach_ss.last_achievement_display_time > 2.0 ) and bascenev1.getactivity(doraise=False) is not None: ach_ss.achievement_display_timer = bascenev1.BaseTimer( 1.0, _display_next_achievement, repeat=True ) # Show the first immediately. _display_next_achievement()
[docs] def set_complete(self, complete: bool = True) -> None: """Set an achievement's completed state. note this only sets local state; use a transaction to actually award achievements. """ config = self._getconfig() if complete != config['Complete']: config['Complete'] = complete
@property def display_name(self) -> babase.Lstr: """Return a babase.Lstr for this Achievement's name.""" name: babase.Lstr | str try: if self._level_name != '': campaignname, campaign_level = self._level_name.split(':') classic = babase.app.classic assert classic is not None name = ( classic.getcampaign(campaignname) .getlevel(campaign_level) .displayname ) else: name = '' except Exception: name = '' logging.exception('Error calcing achievement display-name.') return babase.Lstr( resource='achievements.' + self._name + '.name', subs=[('${LEVEL}', name)], ) @property def description(self) -> babase.Lstr: """Get a babase.Lstr for the Achievement's brief description.""" if ( 'description' in babase.app.lang.get_resource('achievements')[self._name] ): return babase.Lstr( resource='achievements.' + self._name + '.description' ) return babase.Lstr( resource='achievements.' + self._name + '.descriptionFull' ) @property def description_complete(self) -> babase.Lstr: """Get a babase.Lstr for the Achievement's description when complete.""" if ( 'descriptionComplete' in babase.app.lang.get_resource('achievements')[self._name] ): return babase.Lstr( resource='achievements.' + self._name + '.descriptionComplete' ) return babase.Lstr( resource='achievements.' + self._name + '.descriptionFullComplete' ) @property def description_full(self) -> babase.Lstr: """Get a babase.Lstr for the Achievement's full description.""" return babase.Lstr( resource='achievements.' + self._name + '.descriptionFull', subs=[ ( '${LEVEL}', babase.Lstr( translate=( 'coopLevelNames', ACH_LEVEL_NAMES.get(self._name, '?'), ) ), ) ], ) @property def description_full_complete(self) -> babase.Lstr: """Get a babase.Lstr for the Achievement's full desc. when completed.""" return babase.Lstr( resource='achievements.' + self._name + '.descriptionFullComplete', subs=[ ( '${LEVEL}', babase.Lstr( translate=( 'coopLevelNames', ACH_LEVEL_NAMES.get(self._name, '?'), ) ), ) ], )
[docs] def get_award_chest_type(self) -> ClassicChestAppearance: """Return the type of chest given for this achievement.""" # For now just map our old ticket values to chest types. # Can add distinct values if need be later. plus = babase.app.plus assert plus is not None t = plus.get_v1_account_misc_read_val( f'achAward.{self.name}', self._award ) return ( ClassicChestAppearance.L6 if t >= 30 else ( ClassicChestAppearance.L5 if t >= 25 else ( ClassicChestAppearance.L4 if t >= 20 else ( ClassicChestAppearance.L3 if t >= 15 else ( ClassicChestAppearance.L2 if t >= 10 else ClassicChestAppearance.L1 ) ) ) ) )
# def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: # """Get the ticket award value for this achievement.""" # plus = babase.app.plus # if plus is None: # return 0 # val: int = plus.get_v1_account_misc_read_val( # 'achAward.' + self._name, self._award # ) * _get_ach_mult(include_pro_bonus) # assert isinstance(val, int) # return val @property def power_ranking_value(self) -> int: """Get the power-ranking award value for this achievement.""" plus = babase.app.plus if plus is None: return 0 val: int = plus.get_v1_account_misc_read_val( 'achLeaguePoints.' + self._name, self._award ) assert isinstance(val, int) return val
[docs] def create_display( self, x: float, y: float, delay: float, *, outdelay: float | None = None, color: Sequence[float] | None = None, style: str = 'post_game', ) -> list[bascenev1.Actor]: """Create a display for the Achievement. Shows the Achievement icon, name, and description. """ # pylint: disable=cyclic-import from bascenev1 import CoopSession from bascenev1lib.actor.image import Image from bascenev1lib.actor.text import Text # Yeah this needs cleaning up. if style == 'post_game': in_game_colors = False in_main_menu = False h_attach = Text.HAttach.CENTER v_attach = Text.VAttach.CENTER attach = Image.Attach.CENTER elif style == 'in_game': in_game_colors = True in_main_menu = False h_attach = Text.HAttach.LEFT v_attach = Text.VAttach.TOP attach = Image.Attach.TOP_LEFT elif style == 'news': in_game_colors = True in_main_menu = True h_attach = Text.HAttach.CENTER v_attach = Text.VAttach.TOP attach = Image.Attach.TOP_CENTER else: raise ValueError('invalid style "' + style + '"') # Attempt to determine what campaign we're in # (so we know whether to show "hard mode only"). if in_main_menu: hmo = False else: try: session = bascenev1.getsession() if isinstance(session, CoopSession): campaign = session.campaign assert campaign is not None hmo = self._hard_mode_only and campaign.name == 'Easy' else: hmo = False except Exception: logging.exception('Error determining campaign.') hmo = False objs: list[bascenev1.Actor] if in_game_colors: objs = [] out_delay_fin = (delay + outdelay) if outdelay is not None else None if color is not None: cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3]) cl2 = color else: cl1 = (1.5, 1.5, 2, 1.0) cl2 = (0.8, 0.8, 1.0, 1.0) if hmo: cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6) cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2) objs.append( Image( self.get_icon_texture(False), host_only=True, color=cl1, position=(x - 25, y + 5), attach=attach, transition=Image.Transition.FADE_IN, transition_delay=delay, vr_depth=4, transition_out_delay=out_delay_fin, scale=(40, 40), ).autoretain() ) txt = self.display_name txt_s = 0.85 txt_max_w = 300 objs.append( Text( txt, host_only=True, maxwidth=txt_max_w, position=(x, y + 2), transition=Text.Transition.FADE_IN, scale=txt_s, flatness=0.6, shadow=0.5, h_attach=h_attach, v_attach=v_attach, color=cl2, transition_delay=delay + 0.05, transition_out_delay=out_delay_fin, ).autoretain() ) txt2_s = 0.62 txt2_max_w = 400 objs.append( Text( self.description_full if in_main_menu else self.description, host_only=True, maxwidth=txt2_max_w, position=(x, y - 14), transition=Text.Transition.FADE_IN, vr_depth=-5, h_attach=h_attach, v_attach=v_attach, scale=txt2_s, flatness=1.0, shadow=0.5, color=cl2, transition_delay=delay + 0.1, transition_out_delay=out_delay_fin, ).autoretain() ) if hmo: txtactor = Text( babase.Lstr(resource='difficultyHardOnlyText'), host_only=True, maxwidth=txt2_max_w * 0.7, position=(x + 60, y + 5), transition=Text.Transition.FADE_IN, vr_depth=-5, h_attach=h_attach, v_attach=v_attach, h_align=Text.HAlign.CENTER, v_align=Text.VAlign.CENTER, scale=txt_s * 0.8, flatness=1.0, shadow=0.5, color=(1, 1, 0.6, 1), transition_delay=delay + 0.1, transition_out_delay=out_delay_fin, ).autoretain() txtactor.node.rotate = 10 objs.append(txtactor) # Chest award. award_x = -100 chesttype = self.get_award_chest_type() chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT ) objs.append( Image( # Provide magical extended dict version of texture # that Image actor supports. texture={ 'texture': bascenev1.gettexture( chestdisplayinfo.texclosed ), 'tint_texture': bascenev1.gettexture( chestdisplayinfo.texclosedtint ), 'tint_color': chestdisplayinfo.tint, 'tint2_color': chestdisplayinfo.tint2, 'mask_texture': None, }, color=chestdisplayinfo.color + (0.5 if hmo else 1.0,), position=(x + award_x + 37, y + 12), scale=(32.0, 32.0), transition=Image.Transition.FADE_IN, transition_delay=delay + 0.05, transition_out_delay=out_delay_fin, host_only=True, attach=Image.Attach.TOP_LEFT, ).autoretain() ) # objs.append( # Text( # babase.charstr(babase.SpecialChar.TICKET), # host_only=True, # position=(x + award_x + 33, y + 7), # transition=Text.Transition.FADE_IN, # scale=1.5, # h_attach=h_attach, # v_attach=v_attach, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # color=(1, 1, 1, 0.2 if hmo else 0.4), # transition_delay=delay + 0.05, # transition_out_delay=out_delay_fin, # ).autoretain() # ) # objs.append( # Text( # '+' + str(self.get_award_ticket_value()), # host_only=True, # position=(x + award_x + 28, y + 16), # transition=Text.Transition.FADE_IN, # scale=0.7, # flatness=1, # h_attach=h_attach, # v_attach=v_attach, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # color=cl2, # transition_delay=delay + 0.05, # transition_out_delay=out_delay_fin, # ).autoretain() # ) else: complete = self.complete objs = [] c_icon = self.get_icon_color(complete) if hmo and not complete: c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3) objs.append( Image( self.get_icon_texture(complete), host_only=True, color=c_icon, position=(x - 25, y + 5), attach=attach, vr_depth=4, transition=Image.Transition.IN_RIGHT, transition_delay=delay, transition_out_delay=None, scale=(40, 40), ).autoretain() ) if complete: objs.append( Image( bascenev1.gettexture('achievementOutline'), host_only=True, mesh_transparent=bascenev1.getmesh( 'achievementOutline' ), color=(2, 1.4, 0.4, 1), vr_depth=8, position=(x - 25, y + 5), attach=attach, transition=Image.Transition.IN_RIGHT, transition_delay=delay, transition_out_delay=None, scale=(40, 40), ).autoretain() ) else: if not complete: award_x = -100 # objs.append( # Text( # babase.charstr(babase.SpecialChar.TICKET), # host_only=True, # position=(x + award_x + 33, y + 7), # transition=Text.Transition.IN_RIGHT, # scale=1.5, # h_attach=h_attach, # v_attach=v_attach, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # color=(1, 1, 1, (0.1 if hmo else 0.2)), # transition_delay=delay + 0.05, # transition_out_delay=None, # ).autoretain() # ) chesttype = self.get_award_chest_type() chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get( chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT ) objs.append( Image( # Provide magical extended dict version of texture # that Image actor supports. texture={ 'texture': bascenev1.gettexture( chestdisplayinfo.texclosed ), 'tint_texture': bascenev1.gettexture( chestdisplayinfo.texclosedtint ), 'tint_color': chestdisplayinfo.tint, 'tint2_color': chestdisplayinfo.tint2, 'mask_texture': None, }, color=chestdisplayinfo.color + (0.5 if hmo else 1.0,), position=(x + award_x + 38, y + 14), scale=(32.0, 32.0), transition=Image.Transition.IN_RIGHT, transition_delay=delay + 0.05, transition_out_delay=None, host_only=True, attach=attach, ).autoretain() ) # objs.append( # Text( # '+' + str(self.get_award_ticket_value()), # host_only=True, # position=(x + award_x + 28, y + 16), # transition=Text.Transition.IN_RIGHT, # scale=0.7, # flatness=1, # h_attach=h_attach, # v_attach=v_attach, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)), # transition_delay=delay + 0.05, # transition_out_delay=None, # ).autoretain() # ) # Show 'hard-mode-only' only over incomplete achievements # when that's the case. if hmo: txtactor = Text( babase.Lstr(resource='difficultyHardOnlyText'), host_only=True, maxwidth=300 * 0.7, position=(x + 60, y + 5), transition=Text.Transition.FADE_IN, vr_depth=-5, h_attach=h_attach, v_attach=v_attach, h_align=Text.HAlign.CENTER, v_align=Text.VAlign.CENTER, scale=0.85 * 0.8, flatness=1.0, shadow=0.5, color=(1, 1, 0.6, 1), transition_delay=delay + 0.05, transition_out_delay=None, ).autoretain() assert txtactor.node txtactor.node.rotate = 10 objs.append(txtactor) objs.append( Text( self.display_name, host_only=True, maxwidth=300, position=(x, y + 2), transition=Text.Transition.IN_RIGHT, scale=0.85, flatness=0.6, h_attach=h_attach, v_attach=v_attach, color=( (0.8, 0.93, 0.8, 1.0) if complete else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) ), transition_delay=delay + 0.05, transition_out_delay=None, ).autoretain() ) objs.append( Text( self.description_complete if complete else self.description, host_only=True, maxwidth=400, position=(x, y - 14), transition=Text.Transition.IN_RIGHT, vr_depth=-5, h_attach=h_attach, v_attach=v_attach, scale=0.62, flatness=1.0, color=( (0.6, 0.6, 0.6, 1.0) if complete else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4)) ), transition_delay=delay + 0.1, transition_out_delay=None, ).autoretain() ) return objs
def _getconfig(self) -> dict[str, Any]: """ Return the sub-dict in settings where this achievement's state is stored, creating it if need be. """ val: dict[str, Any] = babase.app.config.setdefault( 'Achievements', {} ).setdefault(self._name, {'Complete': False}) assert isinstance(val, dict) return val def _remove_banner_slot(self) -> None: classic = babase.app.classic assert classic is not None assert self._completion_banner_slot is not None classic.ach.achievement_completion_banner_slots.remove( self._completion_banner_slot ) self._completion_banner_slot = None
[docs] def show_completion_banner(self, sound: bool = True) -> None: """Create the banner/sound for an acquired achievement announcement.""" from bascenev1lib.actor.text import Text from bascenev1lib.actor.image import Image app = babase.app assert app.classic is not None app.classic.ach.last_achievement_display_time = babase.apptime() # Just piggy-back onto any current activity # (should we use the session instead?..) activity = bascenev1.getactivity(doraise=False) # If this gets called while this achievement is occupying a slot # already, ignore it. (probably should never happen in real # life but whatevs). if self._completion_banner_slot is not None: return if activity is None: print('show_completion_banner() called with no current activity!') return if sound: bascenev1.getsound('achievement').play(host_only=True) else: bascenev1.timer( 0.5, lambda: bascenev1.getsound('ding').play(host_only=True) ) in_time = 0.300 out_time = 3.5 base_vr_depth = 200 # Find the first free slot. i = 0 while True: if i not in app.classic.ach.achievement_completion_banner_slots: app.classic.ach.achievement_completion_banner_slots.add(i) self._completion_banner_slot = i # Remove us from that slot when we close. # Use an app-timer in an empty context so the removal # runs even if our activity/session dies. with babase.ContextRef.empty(): babase.apptimer( in_time + out_time, self._remove_banner_slot ) break i += 1 assert self._completion_banner_slot is not None y_offs = 110 * self._completion_banner_slot objs: list[bascenev1.Actor] = [] obj = Image( bascenev1.gettexture('shadow'), position=(-30, 30 + y_offs), front=True, attach=Image.Attach.BOTTOM_CENTER, transition=Image.Transition.IN_BOTTOM, vr_depth=base_vr_depth - 100, transition_delay=in_time, transition_out_delay=out_time, color=(0.0, 0.1, 0, 1), scale=(1000, 300), ).autoretain() objs.append(obj) assert obj.node obj.node.host_only = True obj = Image( bascenev1.gettexture('light'), position=(-180, 60 + y_offs), front=True, attach=Image.Attach.BOTTOM_CENTER, vr_depth=base_vr_depth, transition=Image.Transition.IN_BOTTOM, transition_delay=in_time, transition_out_delay=out_time, color=(1.8, 1.8, 1.0, 0.0), scale=(40, 300), ).autoretain() objs.append(obj) assert obj.node obj.node.host_only = True obj.node.premultiplied = True combine = bascenev1.newnode( 'combine', owner=obj.node, attrs={'size': 2} ) bascenev1.animate( combine, 'input0', { in_time: 0, in_time + 0.4: 30, in_time + 0.5: 40, in_time + 0.6: 30, in_time + 2.0: 0, }, ) bascenev1.animate( combine, 'input1', { in_time: 0, in_time + 0.4: 200, in_time + 0.5: 500, in_time + 0.6: 200, in_time + 2.0: 0, }, ) combine.connectattr('output', obj.node, 'scale') bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True) obj = Image( self.get_icon_texture(True), position=(-180, 60 + y_offs), attach=Image.Attach.BOTTOM_CENTER, front=True, vr_depth=base_vr_depth - 10, transition=Image.Transition.IN_BOTTOM, transition_delay=in_time, transition_out_delay=out_time, scale=(100, 100), ).autoretain() objs.append(obj) assert obj.node obj.node.host_only = True # Flash. color = self.get_icon_color(True) combine = bascenev1.newnode( 'combine', owner=obj.node, attrs={'size': 3} ) keys = { in_time: 1.0 * color[0], in_time + 0.4: 1.5 * color[0], in_time + 0.5: 6.0 * color[0], in_time + 0.6: 1.5 * color[0], in_time + 2.0: 1.0 * color[0], } bascenev1.animate(combine, 'input0', keys) keys = { in_time: 1.0 * color[1], in_time + 0.4: 1.5 * color[1], in_time + 0.5: 6.0 * color[1], in_time + 0.6: 1.5 * color[1], in_time + 2.0: 1.0 * color[1], } bascenev1.animate(combine, 'input1', keys) keys = { in_time: 1.0 * color[2], in_time + 0.4: 1.5 * color[2], in_time + 0.5: 6.0 * color[2], in_time + 0.6: 1.5 * color[2], in_time + 2.0: 1.0 * color[2], } bascenev1.animate(combine, 'input2', keys) combine.connectattr('output', obj.node, 'color') obj = Image( bascenev1.gettexture('achievementOutline'), mesh_transparent=bascenev1.getmesh('achievementOutline'), position=(-180, 60 + y_offs), front=True, attach=Image.Attach.BOTTOM_CENTER, vr_depth=base_vr_depth, transition=Image.Transition.IN_BOTTOM, transition_delay=in_time, transition_out_delay=out_time, scale=(100, 100), ).autoretain() assert obj.node obj.node.host_only = True # Flash. color = (2, 1.4, 0.4, 1) combine = bascenev1.newnode( 'combine', owner=obj.node, attrs={'size': 3} ) keys = { in_time: 1.0 * color[0], in_time + 0.4: 1.5 * color[0], in_time + 0.5: 6.0 * color[0], in_time + 0.6: 1.5 * color[0], in_time + 2.0: 1.0 * color[0], } bascenev1.animate(combine, 'input0', keys) keys = { in_time: 1.0 * color[1], in_time + 0.4: 1.5 * color[1], in_time + 0.5: 6.0 * color[1], in_time + 0.6: 1.5 * color[1], in_time + 2.0: 1.0 * color[1], } bascenev1.animate(combine, 'input1', keys) keys = { in_time: 1.0 * color[2], in_time + 0.4: 1.5 * color[2], in_time + 0.5: 6.0 * color[2], in_time + 0.6: 1.5 * color[2], in_time + 2.0: 1.0 * color[2], } bascenev1.animate(combine, 'input2', keys) combine.connectattr('output', obj.node, 'color') objs.append(obj) objt = Text( babase.Lstr( value='${A}:', subs=[('${A}', babase.Lstr(resource='achievementText'))], ), position=(-120, 91 + y_offs), front=True, v_attach=Text.VAttach.BOTTOM, vr_depth=base_vr_depth - 10, transition=Text.Transition.IN_BOTTOM, flatness=0.5, transition_delay=in_time, transition_out_delay=out_time, color=(1, 1, 1, 0.8), scale=0.65, ).autoretain() objs.append(objt) assert objt.node objt.node.host_only = True objt = Text( self.display_name, position=(-120, 50 + y_offs), front=True, v_attach=Text.VAttach.BOTTOM, transition=Text.Transition.IN_BOTTOM, vr_depth=base_vr_depth, flatness=0.5, transition_delay=in_time, transition_out_delay=out_time, flash=True, color=(1, 0.8, 0, 1.0), scale=1.5, ).autoretain() objs.append(objt) assert objt.node objt.node.host_only = True # objt = Text( # babase.charstr(babase.SpecialChar.TICKET), # position=(-120 - 170 + 5, 75 + y_offs - 20), # front=True, # v_attach=Text.VAttach.BOTTOM, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # transition=Text.Transition.IN_BOTTOM, # vr_depth=base_vr_depth, # transition_delay=in_time, # transition_out_delay=out_time, # flash=True, # color=(0.5, 0.5, 0.5, 1), # scale=3.0, # ).autoretain() # objs.append(objt) # assert objt.node # objt.node.host_only = True # print('FIXME SHOW ACH CHEST3') # objt = Text( # '+' + str(self.get_award_ticket_value()), # position=(-120 - 180 + 5, 80 + y_offs - 20), # v_attach=Text.VAttach.BOTTOM, # front=True, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # transition=Text.Transition.IN_BOTTOM, # vr_depth=base_vr_depth, # flatness=0.5, # shadow=1.0, # transition_delay=in_time, # transition_out_delay=out_time, # flash=True, # color=(0, 1, 0, 1), # scale=1.5, # ).autoretain() # objs.append(objt) assert objt.node objt.node.host_only = True # Add the 'x 2' if we've got pro. # if app.classic.accounts.have_pro(): # objt = Text( # 'x 2', # position=(-120 - 180 + 45, 80 + y_offs - 50), # v_attach=Text.VAttach.BOTTOM, # front=True, # h_align=Text.HAlign.CENTER, # v_align=Text.VAlign.CENTER, # transition=Text.Transition.IN_BOTTOM, # vr_depth=base_vr_depth, # flatness=0.5, # shadow=1.0, # transition_delay=in_time, # transition_out_delay=out_time, # flash=True, # color=(0.4, 0, 1, 1), # scale=0.9, # ).autoretain() # objs.append(objt) # assert objt.node # objt.node.host_only = True objt = Text( self.description_complete, position=(-120, 30 + y_offs), front=True, v_attach=Text.VAttach.BOTTOM, transition=Text.Transition.IN_BOTTOM, vr_depth=base_vr_depth - 10, flatness=0.5, transition_delay=in_time, transition_out_delay=out_time, color=(1.0, 0.7, 0.5, 1.0), scale=0.8, ).autoretain() objs.append(objt) assert objt.node objt.node.host_only = True for actor in objs: bascenev1.timer( out_time + 1.000, babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()), )
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum