# 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