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 = 1670 if uiscale is bui.UIScale.SMALL else 1040
self._x_inset = x_inset = 310 if uiscale is bui.UIScale.SMALL else 0
self._height = (
538
if uiscale is bui.UIScale.SMALL
else 645 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
super().__init__(
root_widget=bui.containerwidget(
size=(self._width, self._height + extra_top),
toolbar_visibility=(
'menu_store'
if (uiscale is bui.UIScale.SMALL or minimal_toolbars)
else 'menu_full'
),
scale=(
1.3
if uiscale is bui.UIScale.SMALL
else 0.9 if uiscale is bui.UIScale.MEDIUM else 0.8
),
stack_offset=(
(0, 10)
if uiscale is bui.UIScale.SMALL
else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
),
),
transition=transition,
origin_widget=origin_widget,
)
self._back_button = btn = bui.buttonwidget(
parent=self._root_widget,
position=(70 + x_inset, self._height - 74),
size=(140, 60),
scale=1.1,
autoselect=True,
label=bui.Lstr(resource='backText'),
button_type='back',
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
)
backbuttonspecial = True
else:
bui.containerwidget(edit=self._root_widget, cancel_button=btn)
backbuttonspecial = False
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._height - (53 if uiscale is bui.UIScale.SMALL else 44),
),
size=(0, 0),
color=app.ui_v1.title_color,
scale=1.5,
h_align='center',
v_align='center',
text=bui.Lstr(resource='storeText'),
maxwidth=290,
)
if not backbuttonspecial:
bui.buttonwidget(
edit=self._back_button,
button_type='backSmall',
size=(60, 60),
label=bui.charstr(SpecialChar.BACK),
)
scroll_buffer_h = 130 + 2 * x_inset
tab_buffer_h = 250 + 2 * x_inset
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')),
]
self._tab_row = TabRow(
self._root_widget,
tabs_def,
pos=(tab_buffer_h * 0.5, self._height - 130),
size=(self._width - tab_buffer_h, 50),
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.04, center[1] - rad * 1.15),
size=(rad * 2.2, rad * 2.2),
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'),
)
bui.widget(
edit=last_tab_button,
right_widget=bui.get_special_widget('squad_button'),
)
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,
position=(
(self._width - self._scroll_width) * 0.5,
self._height - self._scroll_height - 79 - 48,
),
size=(self._scroll_width, self._scroll_height),
claims_left_right=True,
selection_loops_to_parent=True,
)
# 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()
print('FIXME - show not-enough-tickets info.')
# 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'] = (
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 = 40
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_column_count = int(
math.floor(
(self._width - boffs_h - 20)
/ (b_width + button_spacing)
)
)
b_row_count = int(
math.ceil(
float(len(section['items'])) / 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=(
'accountSettingsWindow.titleText'
)
),
),
(
'${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=(60, v - title_spacing * 0.8),
size=(0, 0),
scale=1.0,
transition_delay=delay,
color=(0.7, 0.9, 0.7, 1),
h_align='left',
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 = int(
math.floor(
(self._width - boffs_h - 20)
/ (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
)
if 'x_offs' in section:
boffs_h2 = section['x_offs']
else:
boffs_h2 = 0
if 'y_offs' in section:
boffs_v2 = section['y_offs']
else:
boffs_v2 = 0
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
)
)
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.app.State.NOT_STARTED
):
Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()