Source code for bauiv1lib.store.browser

# Released under the MIT License. See LICENSE for details.
#
"""UI for browsing the store."""
# pylint: disable=too-many-lines
from __future__ import annotations

import os
import time
import copy
import math
import logging
import weakref
import datetime
from enum import Enum
from threading import Thread
from typing import TYPE_CHECKING, override

from efro.util import utc_now
from efro.error import CommunicationError
import bacommon.cloud
import bauiv1 as bui

if TYPE_CHECKING:
    from typing import Any, Callable, Sequence

MERCH_LINK_KEY = 'Merch Link'


[docs] class StoreBrowserWindow(bui.MainWindow): """Window for browsing the store."""
[docs] class TabID(Enum): """Our available tab types.""" # EXTRAS = 'extras' MAPS = 'maps' MINIGAMES = 'minigames' CHARACTERS = 'characters' ICONS = 'icons'
def __init__( self, transition: str | None = 'in_right', origin_widget: bui.Widget | None = None, show_tab: StoreBrowserWindow.TabID | None = None, minimal_toolbars: bool = False, ): # pylint: disable=too-many-statements # pylint: disable=too-many-locals from bauiv1lib.tabs import TabRow from bauiv1 import SpecialChar app = bui.app assert app.classic is not None uiscale = app.ui_v1.uiscale bui.set_analytics_screen('Store Window') self.button_infos: dict[str, dict[str, Any]] | None = None self.update_buttons_timer: bui.AppTimer | None = None self._status_textwidget_update_timer = None self._show_tab = show_tab self._width = ( 1800 if uiscale is bui.UIScale.SMALL else 1000 if uiscale is bui.UIScale.MEDIUM else 1120 ) self._height = ( 1200 if uiscale is bui.UIScale.SMALL else 700 if uiscale is bui.UIScale.MEDIUM else 800 ) self._current_tab: StoreBrowserWindow.TabID | None = None # extra_top = 30 if uiscale is bui.UIScale.SMALL else 0 self.request: Any = None self._r = 'store' self._last_buy_time: float | None = None # Do some fancy math to fill all available screen area up to the # size of our backing container. This lets us fit to the exact # screen shape at small ui scale. screensize = bui.get_virtual_screen_size() scale = ( 1.5 if uiscale is bui.UIScale.SMALL else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8 ) # Calc screen size in our local container space and clamp to a # bit smaller than our container size. target_width = min(self._width - 120, screensize[0] / scale) target_height = min(self._height - 140, screensize[1] / scale) # To get top/left coords, go to the center of our window and # offset by half the width/height of our target area. yoffs = 0.5 * self._height + 0.5 * target_height + 30.0 self._scroll_width = target_width self._scroll_height = target_height - 59 self._scroll_bottom = yoffs - 87 - self._scroll_height super().__init__( root_widget=bui.containerwidget( size=(self._width, self._height), toolbar_visibility=( 'menu_store' if (uiscale is bui.UIScale.SMALL or minimal_toolbars) else 'menu_full' ), scale=scale, ), transition=transition, origin_widget=origin_widget, # We're affected by screen size only at small ui-scale. refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL, ) self._back_button = btn = bui.buttonwidget( parent=self._root_widget, position=(70, yoffs - 37), size=(60, 60), scale=1.1, autoselect=True, label=bui.charstr(SpecialChar.BACK), button_type='backSmall', on_activate_call=self.main_window_back, ) if uiscale is bui.UIScale.SMALL: self._back_button.delete() bui.containerwidget( edit=self._root_widget, on_cancel_call=self.main_window_back ) else: bui.containerwidget(edit=self._root_widget, cancel_button=btn) if ( app.classic.platform in ['mac', 'ios'] and app.classic.subplatform == 'appstore' ): bui.buttonwidget( parent=self._root_widget, position=(self._width * 0.5 - 70, 16), size=(230, 50), scale=0.65, on_activate_call=bui.WeakCall(self._restore_purchases), color=(0.35, 0.3, 0.4), selectable=False, textcolor=(0.55, 0.5, 0.6), label=bui.Lstr( resource='getTicketsWindow.restorePurchasesText' ), ) bui.textwidget( parent=self._root_widget, position=( ( self._width * 0.5 + ( (self._scroll_width * -0.5 + 90.0) if uiscale is bui.UIScale.SMALL else 0.0 ) ), yoffs - (62 if uiscale is bui.UIScale.SMALL else -3.0), ), size=(0, 0), color=app.ui_v1.title_color, scale=1.1 if uiscale is bui.UIScale.SMALL else 1.3, h_align='left' if uiscale is bui.UIScale.SMALL else 'center', v_align='center', text=bui.Lstr(resource='storeText'), maxwidth=100 if uiscale is bui.UIScale.SMALL else 290, ) tabs_def = [ # (self.TabID.EXTRAS, bui.Lstr(resource=f'{self._r}.extrasText')), (self.TabID.MAPS, bui.Lstr(resource=f'{self._r}.mapsText')), ( self.TabID.MINIGAMES, bui.Lstr(resource=f'{self._r}.miniGamesText'), ), ( self.TabID.CHARACTERS, bui.Lstr(resource=f'{self._r}.charactersText'), ), (self.TabID.ICONS, bui.Lstr(resource=f'{self._r}.iconsText')), ] tab_inset = 200 if uiscale is bui.UIScale.SMALL else 100 self._tab_row = TabRow( self._root_widget, tabs_def, size=(self._scroll_width - 2.0 * tab_inset, 50), pos=( self._width * 0.5 - self._scroll_width * 0.5 + tab_inset, self._scroll_bottom + self._scroll_height - 4.0, ), on_select_call=self._set_tab, ) self._purchasable_count_widgets: dict[ StoreBrowserWindow.TabID, dict[str, Any] ] = {} # Create our purchasable-items tags and have them update over time. for tab_id, tab in self._tab_row.tabs.items(): pos = tab.position size = tab.size button = tab.button rad = 10 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) img = bui.imagewidget( parent=self._root_widget, position=(center[0] - rad * 1.1, center[1] - rad * 1.2), size=(rad * 2.4, rad * 2.4), texture=bui.gettexture('circleShadow'), color=(1, 0, 0), ) txt = bui.textwidget( parent=self._root_widget, position=center, size=(0, 0), h_align='center', v_align='center', maxwidth=1.4 * rad, scale=0.6, shadow=1.0, flatness=1.0, ) rad = 20 sale_img = bui.imagewidget( parent=self._root_widget, position=(center[0] - rad, center[1] - rad), size=(rad * 2, rad * 2), draw_controller=button, texture=bui.gettexture('circleZigZag'), color=(0.5, 0, 1.0), ) sale_title_text = bui.textwidget( parent=self._root_widget, position=(center[0], center[1] + 0.24 * rad), size=(0, 0), h_align='center', v_align='center', draw_controller=button, maxwidth=1.4 * rad, scale=0.6, shadow=0.0, flatness=1.0, color=(0, 1, 0), ) sale_time_text = bui.textwidget( parent=self._root_widget, position=(center[0], center[1] - 0.29 * rad), size=(0, 0), h_align='center', v_align='center', draw_controller=button, maxwidth=1.4 * rad, scale=0.4, shadow=0.0, flatness=1.0, color=(0, 1, 0), ) self._purchasable_count_widgets[tab_id] = { 'img': img, 'text': txt, 'sale_img': sale_img, 'sale_title_text': sale_title_text, 'sale_time_text': sale_time_text, } self._tab_update_timer = bui.AppTimer( 1.0, bui.WeakCall(self._update_tabs), repeat=True ) self._update_tabs() if uiscale is bui.UIScale.SMALL: first_tab_button = self._tab_row.tabs[tabs_def[0][0]].button last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button bui.widget( edit=first_tab_button, left_widget=bui.get_special_widget('back_button'), up_widget=bui.get_special_widget('back_button'), ) bui.widget( edit=last_tab_button, up_widget=bui.get_special_widget('tickets_meter'), right_widget=bui.get_special_widget('tickets_meter'), ) # self._scroll_width = self._width - scroll_buffer_h # self._scroll_height = self._height - 180 self._scrollwidget: bui.Widget | None = None self._status_textwidget: bui.Widget | None = None self._restore_state() def _restore_purchases(self) -> None: from bauiv1lib.account.signin import show_sign_in_prompt plus = bui.app.plus assert plus is not None if plus.accounts.primary is None: show_sign_in_prompt() else: plus.restore_purchases() def _update_tabs(self) -> None: assert bui.app.classic is not None store = bui.app.classic.store if not self._root_widget: return for tab_id, tab_data in list(self._purchasable_count_widgets.items()): sale_time = store.get_available_sale_time(tab_id.value) if sale_time is not None: bui.textwidget( edit=tab_data['sale_title_text'], text=bui.Lstr(resource='store.saleText'), ) bui.textwidget( edit=tab_data['sale_time_text'], text=bui.timestring(sale_time / 1000.0, centi=False), ) bui.imagewidget(edit=tab_data['sale_img'], opacity=1.0) count = 0 else: bui.textwidget(edit=tab_data['sale_title_text'], text='') bui.textwidget(edit=tab_data['sale_time_text'], text='') bui.imagewidget(edit=tab_data['sale_img'], opacity=0.0) count = store.get_available_purchase_count(tab_id.value) if count > 0: bui.textwidget(edit=tab_data['text'], text=str(count)) bui.imagewidget(edit=tab_data['img'], opacity=1.0) else: bui.textwidget(edit=tab_data['text'], text='') bui.imagewidget(edit=tab_data['img'], opacity=0.0) def _set_tab(self, tab_id: TabID) -> None: if self._current_tab is tab_id: return self._current_tab = tab_id # We wanna preserve our current tab between runs. cfg = bui.app.config cfg['Store Tab'] = tab_id.value cfg.commit() # Update tab colors based on which is selected. self._tab_row.update_appearance(tab_id) # (Re)create scroll widget. if self._scrollwidget: self._scrollwidget.delete() self._scrollwidget = bui.scrollwidget( parent=self._root_widget, highlight=False, size=(self._scroll_width, self._scroll_height), position=( self._width * 0.5 - self._scroll_width * 0.5, self._scroll_bottom, ), claims_left_right=True, selection_loops_to_parent=True, border_opacity=0.4, ) # NOTE: this stuff is modified by the _Store class. # Should maybe clean that up. self.button_infos = {} self.update_buttons_timer = None # Show status over top. if self._status_textwidget: self._status_textwidget.delete() self._status_textwidget = bui.textwidget( parent=self._root_widget, position=(self._width * 0.5, self._height * 0.5), size=(0, 0), color=(1, 0.7, 1, 0.5), h_align='center', v_align='center', text=bui.Lstr(resource=f'{self._r}.loadingText'), maxwidth=self._scroll_width * 0.9, ) class _Request: def __init__(self, window: StoreBrowserWindow): self._window = weakref.ref(window) data = {'tab': tab_id.value} bui.apptimer(0.1, bui.WeakCall(self._on_response, data)) def _on_response(self, data: dict[str, Any] | None) -> None: # FIXME: clean this up. # pylint: disable=protected-access window = self._window() if window is not None and (window.request is self): window.request = None window._on_response(data) # Kick off a server request. self.request = _Request(self) # Actually start the purchase locally. def _purchase_check_result( self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None ) -> None: plus = bui.app.plus assert plus is not None if result is None: bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource='internal.unavailableNoConnectionText'), color=(1, 0, 0), ) else: if is_ticket_purchase: if result['allow']: price = plus.get_v1_account_misc_read_val( 'price.' + item, None ) if ( price is None or not isinstance(price, int) or price <= 0 ): print( 'Error; got invalid local price of', price, 'for item', item, ) bui.getsound('error').play() else: bui.getsound('click01').play() plus.in_game_purchase(item, price) else: if result['reason'] == 'versionTooOld': bui.getsound('error').play() bui.screenmessage( bui.Lstr( resource='getTicketsWindow.versionTooOldText' ), color=(1, 0, 0), ) else: bui.getsound('error').play() bui.screenmessage( bui.Lstr( resource='getTicketsWindow.unavailableText' ), color=(1, 0, 0), ) # Real in-app purchase. else: if result['allow']: plus.purchase(item) else: if result['reason'] == 'versionTooOld': bui.getsound('error').play() bui.screenmessage( bui.Lstr( resource='getTicketsWindow.versionTooOldText' ), color=(1, 0, 0), ) else: bui.getsound('error').play() bui.screenmessage( bui.Lstr( resource='getTicketsWindow.unavailableText' ), color=(1, 0, 0), ) def _do_purchase_check( self, item: str, is_ticket_purchase: bool = False ) -> None: app = bui.app if app.classic is None: logging.warning('_do_purchase_check() requires classic.') return # Here we ping the server to ask if it's valid for us to # purchase this. Better to fail now than after we've # paid locally. app.classic.master_server_v1_get( 'bsAccountPurchaseCheck', { 'item': item, 'platform': app.classic.platform, 'subplatform': app.classic.subplatform, 'version': app.env.engine_version, 'buildNumber': app.env.engine_build_number, 'purchaseType': 'ticket' if is_ticket_purchase else 'real', }, callback=bui.WeakCall( self._purchase_check_result, item, is_ticket_purchase ), )
[docs] def buy(self, item: str) -> None: """Attempt to purchase the provided item.""" from bauiv1lib.account.signin import show_sign_in_prompt from bauiv1lib.confirm import ConfirmWindow assert bui.app.classic is not None store = bui.app.classic.store plus = bui.app.plus assert plus is not None # Prevent pressing buy within a few seconds of the last press # (gives the buttons time to disable themselves and whatnot). curtime = bui.apptime() if ( self._last_buy_time is not None and (curtime - self._last_buy_time) < 2.0 ): bui.getsound('error').play() else: if plus.get_v1_account_state() != 'signed_in': show_sign_in_prompt() else: self._last_buy_time = curtime # Merch is a special case - just a link. if item == 'merch': url = bui.app.config.get('Merch Link') if isinstance(url, str): bui.open_url(url) # Pro is an actual IAP, and the rest are ticket purchases. elif item == 'pro': bui.getsound('click01').play() # Purchase either pro or pro_sale depending on whether # there is a sale going on. self._do_purchase_check( 'pro' if store.get_available_sale_time('extras') is None else 'pro_sale' ) else: price = plus.get_v1_account_misc_read_val( 'price.' + item, None ) our_tickets = plus.get_v1_account_ticket_count() if price is not None and our_tickets < price: bui.getsound('error').play() bui.screenmessage( bui.Lstr(resource='notEnoughTicketsText'), color=(1, 0, 0), ) # gettickets.show_get_tickets_prompt() else: def do_it() -> None: self._do_purchase_check( item, is_ticket_purchase=True ) bui.getsound('swish').play() ConfirmWindow( bui.Lstr( resource='store.purchaseConfirmText', subs=[ ( '${ITEM}', store.get_store_item_name_translated( item ), ) ], ), width=400, height=120, action=do_it, ok_text=bui.Lstr( resource='store.purchaseText', fallback_resource='okText', ), )
def _print_already_own(self, charname: str) -> None: bui.screenmessage( bui.Lstr( resource=f'{self._r}.alreadyOwnText', subs=[('${NAME}', charname)], ), color=(1, 0, 0), ) bui.getsound('error').play()
[docs] def update_buttons(self) -> None: """Update our buttons.""" # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals from bauiv1 import SpecialChar assert bui.app.classic is not None store = bui.app.classic.store plus = bui.app.plus assert plus is not None if not self._root_widget: return sales_raw = plus.get_v1_account_misc_read_val('sales', {}) sales = {} try: # Look at the current set of sales; filter any with time remaining. for sale_item, sale_info in list(sales_raw.items()): to_end = ( datetime.datetime.fromtimestamp( sale_info['e'], datetime.UTC ) - utc_now() ).total_seconds() if to_end > 0: sales[sale_item] = { 'to_end': to_end, 'original_price': sale_info['op'], } except Exception: logging.exception('Error parsing sales.') assert self.button_infos is not None for b_type, b_info in self.button_infos.items(): if b_type == 'merch': purchased = False elif b_type in ['upgrades.pro', 'pro']: assert bui.app.classic is not None purchased = bui.app.classic.accounts.have_pro() else: purchased = plus.get_v1_account_product_purchased(b_type) sale_opacity = 0.0 sale_title_text: str | bui.Lstr = '' sale_time_text: str | bui.Lstr = '' call: Callable | None if purchased: title_color = (0.8, 0.7, 0.9, 1.0) color = (0.63, 0.55, 0.78) extra_image_opacity = 0.5 call = bui.WeakCall(self._print_already_own, b_info['name']) price_text = '' price_text_left = '' price_text_right = '' show_purchase_check = True description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) price_color = (0.5, 1, 0.5, 0.3) else: title_color = (0.7, 0.9, 0.7, 1.0) color = (0.4, 0.8, 0.1) extra_image_opacity = 1.0 call = b_info['call'] if 'call' in b_info else None if b_type == 'merch': price_text = '' price_text_left = '' price_text_right = '' elif b_type in ['upgrades.pro', 'pro']: sale_time = store.get_available_sale_time('extras') if sale_time is not None: priceraw = plus.get_price('pro') price_text_left = ( priceraw if priceraw is not None else '?' ) priceraw = plus.get_price('pro_sale') price_text_right = ( priceraw if priceraw is not None else '?' ) sale_opacity = 1.0 price_text = '' sale_title_text = bui.Lstr(resource='store.saleText') sale_time_text = bui.timestring( sale_time / 1000.0, centi=False ) else: priceraw = plus.get_price('pro') price_text = priceraw if priceraw is not None else '?' price_text_left = '' price_text_right = '' else: price = plus.get_v1_account_misc_read_val( 'price.' + b_type, 0 ) # Color the button differently if we cant afford this. if plus.get_v1_account_state() == 'signed_in': if plus.get_v1_account_ticket_count() < price: color = (0.6, 0.61, 0.6) price_text = bui.charstr(bui.SpecialChar.TICKET) + str( plus.get_v1_account_misc_read_val( 'price.' + b_type, '?' ) ) price_text_left = '' price_text_right = '' # TESTING: if b_type in sales: sale_opacity = 1.0 price_text_left = bui.charstr(SpecialChar.TICKET) + str( sales[b_type]['original_price'] ) price_text_right = price_text price_text = '' sale_title_text = bui.Lstr(resource='store.saleText') sale_time_text = bui.timestring( sales[b_type]['to_end'], centi=False ) description_color = (0.5, 1.0, 0.5) description_color2 = (0.3, 1.0, 1.0) price_color = (0.2, 1, 0.2, 1.0) show_purchase_check = False if 'title_text' in b_info: bui.textwidget(edit=b_info['title_text'], color=title_color) if 'purchase_check' in b_info: bui.imagewidget( edit=b_info['purchase_check'], opacity=1.0 if show_purchase_check else 0.0, ) if 'price_widget' in b_info: bui.textwidget( edit=b_info['price_widget'], text=price_text, color=price_color, ) if 'price_widget_left' in b_info: bui.textwidget( edit=b_info['price_widget_left'], text=price_text_left ) if 'price_widget_right' in b_info: bui.textwidget( edit=b_info['price_widget_right'], text=price_text_right ) if 'price_slash_widget' in b_info: bui.imagewidget( edit=b_info['price_slash_widget'], opacity=sale_opacity ) if 'sale_bg_widget' in b_info: bui.imagewidget( edit=b_info['sale_bg_widget'], opacity=sale_opacity ) if 'sale_title_widget' in b_info: bui.textwidget( edit=b_info['sale_title_widget'], text=sale_title_text ) if 'sale_time_widget' in b_info: bui.textwidget( edit=b_info['sale_time_widget'], text=sale_time_text ) if 'button' in b_info: bui.buttonwidget( edit=b_info['button'], color=color, on_activate_call=call ) if 'extra_backings' in b_info: for bck in b_info['extra_backings']: bui.imagewidget( edit=bck, color=color, opacity=extra_image_opacity ) if 'extra_images' in b_info: for img in b_info['extra_images']: bui.imagewidget(edit=img, opacity=extra_image_opacity) if 'extra_texts' in b_info: for etxt in b_info['extra_texts']: bui.textwidget(edit=etxt, color=description_color) if 'extra_texts_2' in b_info: for etxt in b_info['extra_texts_2']: bui.textwidget(edit=etxt, color=description_color2) if 'descriptionText' in b_info: bui.textwidget( edit=b_info['descriptionText'], color=description_color )
def _on_response(self, data: dict[str, Any] | None) -> None: # pylint: disable=too-many-statements assert bui.app.classic is not None cstore = bui.app.classic.store # clear status text.. if self._status_textwidget: self._status_textwidget.delete() self._status_textwidget_update_timer = None if data is None: self._status_textwidget = bui.textwidget( parent=self._root_widget, position=(self._width * 0.5, self._height * 0.5), size=(0, 0), scale=1.3, transition_delay=0.1, color=(1, 0.3, 0.3, 1.0), h_align='center', v_align='center', text=bui.Lstr(resource=f'{self._r}.loadErrorText'), maxwidth=self._scroll_width * 0.9, ) else: class _Store: def __init__( self, store_window: StoreBrowserWindow, sdata: dict[str, Any], width: float, ): self._store_window = store_window self._width = width store_data = cstore.get_store_layout() self._tab = sdata['tab'] self._sections = copy.deepcopy(store_data[sdata['tab']]) self._height: float | None = None assert bui.app.classic is not None uiscale = bui.app.ui_v1.uiscale # Pre-calc a few things and add them to store-data. for section in self._sections: if self._tab == 'characters': dummy_name = 'characters.foo' elif self._tab == 'extras': dummy_name = 'pro' elif self._tab == 'maps': dummy_name = 'maps.foo' elif self._tab == 'icons': dummy_name = 'icons.foo' else: dummy_name = '' section['button_size'] = ( cstore.get_store_item_display_size(dummy_name) ) section['v_spacing'] = ( -25 if ( self._tab == 'extras' and uiscale is bui.UIScale.SMALL ) else -17 if self._tab == 'characters' else 0 ) if 'title' not in section: section['title'] = '' section['x_offs'] = 0.0 # section['x_offs'] = ( # 130 # if self._tab == 'extras' # else 270 if self._tab == 'maps' else 0 # ) section['y_offs'] = ( 20 if ( self._tab == 'extras' and uiscale is bui.UIScale.SMALL and bui.app.config.get('Merch Link') ) else ( 55 if ( self._tab == 'extras' and uiscale is bui.UIScale.SMALL ) else -20 if self._tab == 'icons' else 0 ) ) def instantiate( self, scrollwidget: bui.Widget, tab_button: bui.Widget ) -> None: """Create the store.""" # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-nested-blocks from bauiv1lib.store.item import ( instantiate_store_item_display, ) title_spacing = 40 button_border = 20 button_spacing = 4 boffs_h = 0.0 self._height = 80.0 # Calc total height. for i, section in enumerate(self._sections): if section['title'] != '': assert self._height is not None self._height += title_spacing b_width, b_height = section['button_size'] b_count = len(section['items']) b_column_count = min( b_count, int( math.floor( self._width / (b_width + button_spacing) ) ), ) b_row_count = int(math.ceil(b_count / b_column_count)) b_height_total = ( 2 * button_border + b_row_count * b_height + (b_row_count - 1) * section['v_spacing'] ) self._height += b_height_total assert self._height is not None cnt2 = bui.containerwidget( parent=scrollwidget, scale=1.0, size=(self._width, self._height), background=False, claims_left_right=True, selection_loops_to_parent=True, ) v = self._height - 20 if self._tab == 'characters': txt = bui.Lstr( resource='store.howToSwitchCharactersText', subs=[ ( '${SETTINGS}', bui.Lstr(resource='inventoryText'), ), ( '${PLAYER_PROFILES}', bui.Lstr( resource=( 'playerProfilesWindow.titleText' ) ), ), ], ) bui.textwidget( parent=cnt2, text=txt, size=(0, 0), position=(self._width * 0.5, self._height - 28), h_align='center', v_align='center', color=(0.7, 1, 0.7, 0.4), scale=0.7, shadow=0, flatness=1.0, maxwidth=700, transition_delay=0.4, ) elif self._tab == 'icons': txt = bui.Lstr( resource='store.howToUseIconsText', subs=[ ( '${SETTINGS}', bui.Lstr(resource='mainMenu.settingsText'), ), ( '${PLAYER_PROFILES}', bui.Lstr( resource=( 'playerProfilesWindow.titleText' ) ), ), ], ) bui.textwidget( parent=cnt2, text=txt, size=(0, 0), position=(self._width * 0.5, self._height - 28), h_align='center', v_align='center', color=(0.7, 1, 0.7, 0.4), scale=0.7, shadow=0, flatness=1.0, maxwidth=700, transition_delay=0.4, ) elif self._tab == 'maps': assert self._width is not None assert self._height is not None txt = bui.Lstr(resource='store.howToUseMapsText') bui.textwidget( parent=cnt2, text=txt, size=(0, 0), position=(self._width * 0.5, self._height - 28), h_align='center', v_align='center', color=(0.7, 1, 0.7, 0.4), scale=0.7, shadow=0, flatness=1.0, maxwidth=700, transition_delay=0.4, ) prev_row_buttons: list | None = None this_row_buttons = [] delay = 0.3 for section in self._sections: if section['title'] != '': bui.textwidget( parent=cnt2, position=( self._width * 0.5, v - title_spacing * 0.8, ), size=(0, 0), scale=1.0, transition_delay=delay, color=(0.7, 0.9, 0.7, 1), h_align='center', v_align='center', text=bui.Lstr(resource=section['title']), maxwidth=self._width * 0.7, ) v -= title_spacing delay = max(0.100, delay - 0.100) v -= button_border b_width, b_height = section['button_size'] b_count = len(section['items']) b_column_count = min( b_count, int( math.floor( self._width / (b_width + button_spacing) ) ), ) col = 0 item: dict[str, Any] assert self._store_window.button_infos is not None for i, item_name in enumerate(section['items']): item = self._store_window.button_infos[ item_name ] = {} item['call'] = bui.WeakCall( self._store_window.buy, item_name ) boffs_h2 = section.get('x_offs', 0.0) boffs_v2 = section.get('y_offs', 0.0) # Calc the diff between the space we use and # the space available and nudge us right by # half that to center things. boffs_h2 += 0.5 * ( self._width - ((b_width + button_spacing) * b_column_count) ) b_pos = ( boffs_h + boffs_h2 + (b_width + button_spacing) * col, v - b_height + boffs_v2, ) instantiate_store_item_display( item_name, item, parent_widget=cnt2, b_pos=b_pos, boffs_h=boffs_h, b_width=b_width, b_height=b_height, boffs_h2=boffs_h2, boffs_v2=boffs_v2, delay=delay, ) btn = item['button'] delay = max(0.1, delay - 0.1) this_row_buttons.append(btn) # Wire this button to the equivalent in the # previous row. if prev_row_buttons is not None: if len(prev_row_buttons) > col: bui.widget( edit=btn, up_widget=prev_row_buttons[col], ) bui.widget( edit=prev_row_buttons[col], down_widget=btn, ) # If we're the last button in our row, # wire any in the previous row past # our position to go to us if down is # pressed. if ( col + 1 == b_column_count or i == b_count - 1 ): for b_prev in prev_row_buttons[ col + 1 : ]: bui.widget( edit=b_prev, down_widget=btn ) else: bui.widget( edit=btn, up_widget=prev_row_buttons[-1] ) else: bui.widget(edit=btn, up_widget=tab_button) col += 1 if col == b_column_count or i == b_count - 1: prev_row_buttons = this_row_buttons this_row_buttons = [] col = 0 v -= b_height if i < b_count - 1: v -= section['v_spacing'] v -= button_border # Set a timer to update these buttons periodically # as long as we're alive (so if we buy one it will # grey out, etc). self._store_window.update_buttons_timer = bui.AppTimer( 0.5, bui.WeakCall(self._store_window.update_buttons), repeat=True, ) # Also update them immediately. self._store_window.update_buttons() if self._current_tab in ( # self.TabID.EXTRAS, self.TabID.MINIGAMES, self.TabID.CHARACTERS, self.TabID.MAPS, self.TabID.ICONS, ): store = _Store(self, data, self._scroll_width) assert self._scrollwidget is not None store.instantiate( scrollwidget=self._scrollwidget, tab_button=self._tab_row.tabs[self._current_tab].button, ) else: cnt = bui.containerwidget( parent=self._scrollwidget, scale=1.0, size=(self._scroll_width, self._scroll_height * 0.95), background=False, claims_left_right=True, selection_loops_to_parent=True, ) self._status_textwidget = bui.textwidget( parent=cnt, position=( self._scroll_width * 0.5, self._scroll_height * 0.5, ), size=(0, 0), scale=1.3, transition_delay=0.1, color=(1, 1, 0.3, 1.0), h_align='center', v_align='center', text=bui.Lstr(resource=f'{self._r}.comingSoonText'), maxwidth=self._scroll_width * 0.9, )
[docs] @override def get_main_window_state(self) -> bui.MainWindowState: # Support recreating our window for back/refresh purposes. cls = type(self) return bui.BasicMainWindowState( create_call=lambda transition, origin_widget: cls( transition=transition, origin_widget=origin_widget ) )
[docs] @override def on_main_window_close(self) -> None: self._save_state()
def _save_state(self) -> None: try: sel = self._root_widget.get_selected_child() selected_tab_ids = [ tab_id for tab_id, tab in self._tab_row.tabs.items() if sel == tab.button ] if sel == self._scrollwidget: sel_name = 'Scroll' elif sel == self._back_button: sel_name = 'Back' elif selected_tab_ids: assert len(selected_tab_ids) == 1 sel_name = f'Tab:{selected_tab_ids[0].value}' else: raise ValueError(f'unrecognized selection \'{sel}\'') assert bui.app.classic is not None bui.app.ui_v1.window_states[type(self)] = { 'sel_name': sel_name, } except Exception: logging.exception('Error saving state for %s.', self) def _restore_state(self) -> None: try: sel: bui.Widget | None assert bui.app.classic is not None sel_name = bui.app.ui_v1.window_states.get(type(self), {}).get( 'sel_name' ) assert isinstance(sel_name, (str, type(None))) try: current_tab = self.TabID(bui.app.config.get('Store Tab')) except ValueError: current_tab = self.TabID.CHARACTERS if self._show_tab is not None: current_tab = self._show_tab if sel_name == 'Back': sel = self._back_button elif sel_name == 'Scroll': sel = self._scrollwidget elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): try: sel_tab_id = self.TabID(sel_name.split(':')[-1]) except ValueError: sel_tab_id = self.TabID.CHARACTERS sel = self._tab_row.tabs[sel_tab_id].button else: sel = self._tab_row.tabs[current_tab].button # If we were requested to show a tab, select it too. if ( self._show_tab is not None and self._show_tab in self._tab_row.tabs ): sel = self._tab_row.tabs[self._show_tab].button self._set_tab(current_tab) if sel is not None: bui.containerwidget(edit=self._root_widget, selected_child=sel) except Exception: logging.exception('Error restoring state for %s.', self)
def _check_merch_availability_in_bg_thread() -> None: # pylint: disable=cell-var-from-loop # Merch is available from some countries only. Make a reasonable # check to ask the master-server about this at launch and store the # results. plus = bui.app.plus assert plus is not None for _i in range(15): try: if plus.cloud.is_connected(): response = plus.cloud.send_message( bacommon.cloud.MerchAvailabilityMessage() ) def _store_in_logic_thread() -> None: cfg = bui.app.config current = cfg.get(MERCH_LINK_KEY) if not isinstance(current, str | None): current = None if current != response.url: cfg[MERCH_LINK_KEY] = response.url cfg.commit() # If we successfully get a response, kick it over to the # logic thread to store and we're done. bui.pushcall(_store_in_logic_thread, from_other_thread=True) return except CommunicationError: pass except Exception: logging.warning( 'Unexpected error in merch-availability-check.', exc_info=True ) time.sleep(1.1934) # A bit randomized to avoid aliasing. # Slight hack; start checking merch availability in the bg (but only if # it looks like we've been imported for use in a running app; don't want # to do this during docs generation/etc.) # TODO: Should wire this up explicitly to app bootstrapping; not good to # be kicking off work at module import time. if ( os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') != '1' and bui.app.state is not bui.AppState.NOT_STARTED ): Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start() # 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