# 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
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',
}
class AchievementSubsystem:
"""Subsystem for achievement handling.
Category: **App Classes**
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."""
achs = self.achievements
# 5
achs.append(
Achievement('In Control', 'achievementInControl', (1, 1, 1), '', 5)
)
# 15
achs.append(
Achievement(
'Sharing is Caring',
'achievementSharingIsCaring',
(1, 1, 1),
'',
15,
)
)
# 10
achs.append(
Achievement(
'Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', 10
)
)
# 10
achs.append(
Achievement(
'Free Loader', 'achievementFreeLoader', (1, 1, 1), '', 10
)
)
# 20
achs.append(
Achievement(
'Team Player', 'achievementTeamPlayer', (1, 1, 1), '', 20
)
)
# 5
achs.append(
Achievement(
'Onslaught Training Victory',
'achievementOnslaught',
(1, 1, 1),
'Default:Onslaught Training',
5,
)
)
# 5
achs.append(
Achievement(
'Off You Go Then',
'achievementOffYouGo',
(1, 1.1, 1.3),
'Default:Onslaught Training',
5,
)
)
# 10
achs.append(
Achievement(
'Boxer',
'achievementBoxer',
(1, 0.6, 0.6),
'Default:Onslaught Training',
10,
hard_mode_only=True,
)
)
# 10
achs.append(
Achievement(
'Rookie Onslaught Victory',
'achievementOnslaught',
(0.5, 1.4, 0.6),
'Default:Rookie Onslaught',
10,
)
)
# 10
achs.append(
Achievement(
'Mine Games',
'achievementMine',
(1, 1, 1.4),
'Default:Rookie Onslaught',
10,
)
)
# 15
achs.append(
Achievement(
'Flawless Victory',
'achievementFlawlessVictory',
(1, 1, 1),
'Default:Rookie Onslaught',
15,
hard_mode_only=True,
)
)
# 10
achs.append(
Achievement(
'Rookie Football Victory',
'achievementFootballVictory',
(1.0, 1, 0.6),
'Default:Rookie Football',
10,
)
)
# 10
achs.append(
Achievement(
'Super Punch',
'achievementSuperPunch',
(1, 1, 1.8),
'Default:Rookie Football',
10,
)
)
# 15
achs.append(
Achievement(
'Rookie Football Shutout',
'achievementFootballShutout',
(1, 1, 1),
'Default:Rookie Football',
15,
hard_mode_only=True,
)
)
# 15
achs.append(
Achievement(
'Pro Onslaught Victory',
'achievementOnslaught',
(0.3, 1, 2.0),
'Default:Pro Onslaught',
15,
)
)
# 15
achs.append(
Achievement(
'Boom Goes the Dynamite',
'achievementTNT',
(1.4, 1.2, 0.8),
'Default:Pro Onslaught',
15,
)
)
# 20
achs.append(
Achievement(
'Pro Boxer',
'achievementBoxer',
(2, 2, 0),
'Default:Pro Onslaught',
20,
hard_mode_only=True,
)
)
# 15
achs.append(
Achievement(
'Pro Football Victory',
'achievementFootballVictory',
(1.3, 1.3, 2.0),
'Default:Pro Football',
15,
)
)
# 15
achs.append(
Achievement(
'Super Mega Punch',
'achievementSuperPunch',
(2, 1, 0.6),
'Default:Pro Football',
15,
)
)
# 20
achs.append(
Achievement(
'Pro Football Shutout',
'achievementFootballShutout',
(0.7, 0.7, 2.0),
'Default:Pro Football',
20,
hard_mode_only=True,
)
)
# 15
achs.append(
Achievement(
'Pro Runaround Victory',
'achievementRunaround',
(1, 1, 1),
'Default:Pro Runaround',
15,
)
)
# 20
achs.append(
Achievement(
'Precision Bombing',
'achievementCrossHair',
(1, 1, 1.3),
'Default:Pro Runaround',
20,
hard_mode_only=True,
)
)
# 25
achs.append(
Achievement(
'The Wall',
'achievementWall',
(1, 0.7, 0.7),
'Default:Pro Runaround',
25,
hard_mode_only=True,
)
)
# 30
achs.append(
Achievement(
'Uber Onslaught Victory',
'achievementOnslaught',
(2, 2, 1),
'Default:Uber Onslaught',
30,
)
)
# 30
achs.append(
Achievement(
'Gold Miner',
'achievementMine',
(2, 1.6, 0.2),
'Default:Uber Onslaught',
30,
hard_mode_only=True,
)
)
# 30
achs.append(
Achievement(
'TNT Terror',
'achievementTNT',
(2, 1.8, 0.3),
'Default:Uber Onslaught',
30,
hard_mode_only=True,
)
)
# 30
achs.append(
Achievement(
'Uber Football Victory',
'achievementFootballVictory',
(1.8, 1.4, 0.3),
'Default:Uber Football',
30,
)
)
# 30
achs.append(
Achievement(
'Got the Moves',
'achievementGotTheMoves',
(2, 1, 0),
'Default:Uber Football',
30,
hard_mode_only=True,
)
)
# 40
achs.append(
Achievement(
'Uber Football Shutout',
'achievementFootballShutout',
(2, 2, 0),
'Default:Uber Football',
40,
hard_mode_only=True,
)
)
# 30
achs.append(
Achievement(
'Uber Runaround Victory',
'achievementRunaround',
(1.5, 1.2, 0.2),
'Default:Uber Runaround',
30,
)
)
# 40
achs.append(
Achievement(
'The Great Wall',
'achievementWall',
(2, 1.7, 0.4),
'Default:Uber Runaround',
40,
hard_mode_only=True,
)
)
# 40
achs.append(
Achievement(
'Stayin\' Alive',
'achievementStayinAlive',
(2, 2, 1),
'Default:Uber Runaround',
40,
hard_mode_only=True,
)
)
# 20
achs.append(
Achievement(
'Last Stand Master',
'achievementMedalSmall',
(2, 1.5, 0.3),
'Default:The Last Stand',
20,
hard_mode_only=True,
)
)
# 40
achs.append(
Achievement(
'Last Stand Wizard',
'achievementMedalMedium',
(2, 1.5, 0.3),
'Default:The Last Stand',
40,
hard_mode_only=True,
)
)
# 60
achs.append(
Achievement(
'Last Stand God',
'achievementMedalLarge',
(2, 1.5, 0.3),
'Default:The Last Stand',
60,
hard_mode_only=True,
)
)
# 5
achs.append(
Achievement(
'Onslaught Master',
'achievementMedalSmall',
(0.7, 1, 0.7),
'Challenges:Infinite Onslaught',
5,
)
)
# 15
achs.append(
Achievement(
'Onslaught Wizard',
'achievementMedalMedium',
(0.7, 1.0, 0.7),
'Challenges:Infinite Onslaught',
15,
)
)
# 30
achs.append(
Achievement(
'Onslaught God',
'achievementMedalLarge',
(0.7, 1.0, 0.7),
'Challenges:Infinite Onslaught',
30,
)
)
# 5
achs.append(
Achievement(
'Runaround Master',
'achievementMedalSmall',
(1.0, 1.0, 1.2),
'Challenges:Infinite Runaround',
5,
)
)
# 15
achs.append(
Achievement(
'Runaround Wizard',
'achievementMedalMedium',
(1.0, 1.0, 1.2),
'Challenges:Infinite Runaround',
15,
)
)
# 30
achs.append(
Achievement(
'Runaround God',
'achievementMedalLarge',
(1.0, 1.0, 1.2),
'Challenges:Infinite Runaround',
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
class Achievement:
"""Represents attributes and state for an individual achievement.
Category: **App Classes**
"""
def __init__(
self,
name: str,
icon_name: str,
icon_color: Sequence[float],
level_name: str,
award: int,
hard_mode_only: bool = False,
):
# pylint: disable=too-many-positional-arguments
self._name = name
self._icon_name = icon_name
self._icon_color: Sequence[float] = list(icon_color) + [1]
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_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)
# Ticket-award.
award_x = -100
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()
)
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
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()),
)