vh-bombsquad-modded-server-.../dist/ba_data/python/bastd/ui/store/browser.py

1399 lines
54 KiB
Python
Raw Normal View History

2024-02-26 00:17:10 +05:30
# Released under the MIT License. See LICENSE for details.
#
"""UI for browsing the store."""
# pylint: disable=too-many-lines
from __future__ import annotations
import time
import copy
import math
import logging
import weakref
from enum import Enum
from threading import Thread
from typing import TYPE_CHECKING
from efro.error import CommunicationError
import bacommon.cloud
import ba
import ba.internal
if TYPE_CHECKING:
from typing import Any, Callable, Sequence
MERCH_LINK_KEY = 'Merch Link'
class StoreBrowserWindow(ba.Window):
"""Window for browsing the store."""
class TabID(Enum):
"""Our available tab types."""
EXTRAS = 'extras'
MAPS = 'maps'
MINIGAMES = 'minigames'
CHARACTERS = 'characters'
ICONS = 'icons'
def __init__(
self,
transition: str = 'in_right',
modal: bool = False,
show_tab: StoreBrowserWindow.TabID | None = None,
on_close_call: Callable[[], Any] | None = None,
back_location: str | None = None,
origin_widget: ba.Widget | None = None,
):
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
from bastd.ui.tabs import TabRow
from ba import SpecialChar
app = ba.app
uiscale = app.ui.uiscale
ba.set_analytics_screen('Store Window')
scale_origin: tuple[float, float] | None
# If they provided an origin-widget, scale up from that.
if origin_widget is not None:
self._transition_out = 'out_scale'
scale_origin = origin_widget.get_screen_space_center()
transition = 'in_scale'
else:
self._transition_out = 'out_right'
scale_origin = None
self.button_infos: dict[str, dict[str, Any]] | None = None
self.update_buttons_timer: ba.Timer | None = None
self._status_textwidget_update_timer = None
self._back_location = back_location
self._on_close_call = on_close_call
self._show_tab = show_tab
self._modal = modal
self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
self._height = (
578
if uiscale is ba.UIScale.SMALL
else 645
if uiscale is ba.UIScale.MEDIUM
else 800
)
self._current_tab: StoreBrowserWindow.TabID | None = None
extra_top = 30 if uiscale is ba.UIScale.SMALL else 0
self._request: Any = None
self._r = 'store'
self._last_buy_time: float | None = None
super().__init__(
root_widget=ba.containerwidget(
size=(self._width, self._height + extra_top),
transition=transition,
toolbar_visibility='menu_full',
scale=(
1.3
if uiscale is ba.UIScale.SMALL
else 0.9
if uiscale is ba.UIScale.MEDIUM
else 0.8
),
scale_origin_stack_offset=scale_origin,
stack_offset=(
(0, -5)
if uiscale is ba.UIScale.SMALL
else (0, 0)
if uiscale is ba.UIScale.MEDIUM
else (0, 0)
),
)
)
self._back_button = btn = ba.buttonwidget(
parent=self._root_widget,
position=(70 + x_inset, self._height - 74),
size=(140, 60),
scale=1.1,
autoselect=True,
label=ba.Lstr(resource='doneText' if self._modal else 'backText'),
button_type=None if self._modal else 'back',
on_activate_call=self._back,
)
ba.containerwidget(edit=self._root_widget, cancel_button=btn)
self._ticket_count_text: ba.Widget | None = None
self._get_tickets_button: ba.Widget | None = None
if ba.app.allow_ticket_purchases:
self._get_tickets_button = ba.buttonwidget(
parent=self._root_widget,
size=(210, 65),
on_activate_call=self._on_get_more_tickets_press,
autoselect=True,
scale=0.9,
text_scale=1.4,
left_widget=self._back_button,
color=(0.7, 0.5, 0.85),
textcolor=(0.2, 1.0, 0.2),
label=ba.Lstr(resource='getTicketsWindow.titleText'),
)
else:
self._ticket_count_text = ba.textwidget(
parent=self._root_widget,
size=(210, 64),
color=(0.2, 1.0, 0.2),
h_align='center',
v_align='center',
)
# Move this dynamically to keep it out of the way of the party icon.
self._update_get_tickets_button_pos()
self._get_ticket_pos_update_timer = ba.Timer(
1.0,
ba.WeakCall(self._update_get_tickets_button_pos),
repeat=True,
timetype=ba.TimeType.REAL,
)
if self._get_tickets_button:
ba.widget(
edit=self._back_button, right_widget=self._get_tickets_button
)
self._ticket_text_update_timer = ba.Timer(
1.0,
ba.WeakCall(self._update_tickets_text),
timetype=ba.TimeType.REAL,
repeat=True,
)
self._update_tickets_text()
app = ba.app
if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore':
ba.buttonwidget(
parent=self._root_widget,
position=(self._width * 0.5 - 70, 16),
size=(230, 50),
scale=0.65,
on_activate_call=ba.WeakCall(self._restore_purchases),
color=(0.35, 0.3, 0.4),
selectable=False,
textcolor=(0.55, 0.5, 0.6),
label=ba.Lstr(resource='getTicketsWindow.restorePurchasesText'),
)
ba.textwidget(
parent=self._root_widget,
position=(self._width * 0.5, self._height - 44),
size=(0, 0),
color=app.ui.title_color,
scale=1.5,
h_align='center',
v_align='center',
text=ba.Lstr(resource='storeText'),
maxwidth=420,
)
if not self._modal:
ba.buttonwidget(
edit=self._back_button,
button_type='backSmall',
size=(60, 60),
label=ba.charstr(SpecialChar.BACK),
)
scroll_buffer_h = 130 + 2 * x_inset
tab_buffer_h = 250 + 2 * x_inset
tabs_def = [
(self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')),
(self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')),
(
self.TabID.MINIGAMES,
ba.Lstr(resource=self._r + '.miniGamesText'),
),
(
self.TabID.CHARACTERS,
ba.Lstr(resource=self._r + '.charactersText'),
),
(self.TabID.ICONS, ba.Lstr(resource=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 = ba.imagewidget(
parent=self._root_widget,
position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
size=(rad * 2.2, rad * 2.2),
texture=ba.gettexture('circleShadow'),
color=(1, 0, 0),
)
txt = ba.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 = ba.imagewidget(
parent=self._root_widget,
position=(center[0] - rad, center[1] - rad),
size=(rad * 2, rad * 2),
draw_controller=button,
texture=ba.gettexture('circleZigZag'),
color=(0.5, 0, 1.0),
)
sale_title_text = ba.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 = ba.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 = ba.Timer(
1.0,
ba.WeakCall(self._update_tabs),
timetype=ba.TimeType.REAL,
repeat=True,
)
self._update_tabs()
if self._get_tickets_button:
last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
ba.widget(
edit=self._get_tickets_button, down_widget=last_tab_button
)
ba.widget(
edit=last_tab_button,
up_widget=self._get_tickets_button,
right_widget=self._get_tickets_button,
)
self._scroll_width = self._width - scroll_buffer_h
self._scroll_height = self._height - 180
self._scrollwidget: ba.Widget | None = None
self._status_textwidget: ba.Widget | None = None
self._restore_state()
def _update_get_tickets_button_pos(self) -> None:
uiscale = ba.app.ui.uiscale
pos = (
self._width
- 252
- (
self._x_inset
+ (
47
if uiscale is ba.UIScale.SMALL
and ba.internal.is_party_icon_visible()
else 0
)
),
self._height - 70,
)
if self._get_tickets_button:
ba.buttonwidget(edit=self._get_tickets_button, position=pos)
if self._ticket_count_text:
ba.textwidget(edit=self._ticket_count_text, position=pos)
def _restore_purchases(self) -> None:
from bastd.ui import account
if ba.internal.get_v1_account_state() != 'signed_in':
account.show_sign_in_prompt()
else:
ba.internal.restore_purchases()
def _update_tabs(self) -> None:
from ba.internal import (
get_available_sale_time,
get_available_purchase_count,
)
if not self._root_widget:
return
for tab_id, tab_data in list(self._purchasable_count_widgets.items()):
sale_time = get_available_sale_time(tab_id.value)
if sale_time is not None:
ba.textwidget(
edit=tab_data['sale_title_text'],
text=ba.Lstr(resource='store.saleText'),
)
ba.textwidget(
edit=tab_data['sale_time_text'],
text=ba.timestring(
sale_time,
centi=False,
timeformat=ba.TimeFormat.MILLISECONDS,
),
)
ba.imagewidget(edit=tab_data['sale_img'], opacity=1.0)
count = 0
else:
ba.textwidget(edit=tab_data['sale_title_text'], text='')
ba.textwidget(edit=tab_data['sale_time_text'], text='')
ba.imagewidget(edit=tab_data['sale_img'], opacity=0.0)
count = get_available_purchase_count(tab_id.value)
if count > 0:
ba.textwidget(edit=tab_data['text'], text=str(count))
ba.imagewidget(edit=tab_data['img'], opacity=1.0)
else:
ba.textwidget(edit=tab_data['text'], text='')
ba.imagewidget(edit=tab_data['img'], opacity=0.0)
def _update_tickets_text(self) -> None:
from ba import SpecialChar
if not self._root_widget:
return
sval: str | ba.Lstr
if ba.internal.get_v1_account_state() == 'signed_in':
sval = ba.charstr(SpecialChar.TICKET) + str(
ba.internal.get_v1_account_ticket_count()
)
else:
sval = ba.Lstr(resource='getTicketsWindow.titleText')
if self._get_tickets_button:
ba.buttonwidget(edit=self._get_tickets_button, label=sval)
if self._ticket_count_text:
ba.textwidget(edit=self._ticket_count_text, text=sval)
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 = ba.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 = ba.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,
claims_tab=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 = ba.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=ba.Lstr(resource=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}
ba.timer(
0.1,
ba.WeakCall(self._on_response, data),
timetype=ba.TimeType.REAL,
)
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
# noinspection PyProtectedMember
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:
if result is None:
ba.playsound(ba.getsound('error'))
ba.screenmessage(
ba.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0),
)
else:
if is_ticket_purchase:
if result['allow']:
price = ba.internal.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,
)
ba.playsound(ba.getsound('error'))
else:
ba.playsound(ba.getsound('click01'))
ba.internal.in_game_purchase(item, price)
else:
if result['reason'] == 'versionTooOld':
ba.playsound(ba.getsound('error'))
ba.screenmessage(
ba.Lstr(
resource='getTicketsWindow.versionTooOldText'
),
color=(1, 0, 0),
)
else:
ba.playsound(ba.getsound('error'))
ba.screenmessage(
ba.Lstr(
resource='getTicketsWindow.unavailableText'
),
color=(1, 0, 0),
)
# Real in-app purchase.
else:
if result['allow']:
ba.internal.purchase(item)
else:
if result['reason'] == 'versionTooOld':
ba.playsound(ba.getsound('error'))
ba.screenmessage(
ba.Lstr(
resource='getTicketsWindow.versionTooOldText'
),
color=(1, 0, 0),
)
else:
ba.playsound(ba.getsound('error'))
ba.screenmessage(
ba.Lstr(
resource='getTicketsWindow.unavailableText'
),
color=(1, 0, 0),
)
def _do_purchase_check(
self, item: str, is_ticket_purchase: bool = False
) -> None:
from ba.internal import master_server_get
# 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 = ba.app
master_server_get(
'bsAccountPurchaseCheck',
{
'item': item,
'platform': app.platform,
'subplatform': app.subplatform,
'version': app.version,
'buildNumber': app.build_number,
'purchaseType': 'ticket' if is_ticket_purchase else 'real',
},
callback=ba.WeakCall(
self._purchase_check_result, item, is_ticket_purchase
),
)
def buy(self, item: str) -> None:
"""Attempt to purchase the provided item."""
from ba.internal import (
get_available_sale_time,
get_store_item_name_translated,
)
from bastd.ui import account
from bastd.ui.confirm import ConfirmWindow
from bastd.ui import getcurrency
# Prevent pressing buy within a few seconds of the last press
# (gives the buttons time to disable themselves and whatnot).
curtime = ba.time(ba.TimeType.REAL)
if (
self._last_buy_time is not None
and (curtime - self._last_buy_time) < 2.0
):
ba.playsound(ba.getsound('error'))
else:
if ba.internal.get_v1_account_state() != 'signed_in':
account.show_sign_in_prompt()
else:
self._last_buy_time = curtime
# Merch is a special case - just a link.
if item == 'merch':
url = ba.app.config.get('Merch Link')
if isinstance(url, str):
ba.open_url(url)
# Pro is an actual IAP, and the rest are ticket purchases.
elif item == 'pro':
ba.playsound(ba.getsound('click01'))
# Purchase either pro or pro_sale depending on whether
# there is a sale going on.
self._do_purchase_check(
'pro'
if get_available_sale_time('extras') is None
else 'pro_sale'
)
else:
price = ba.internal.get_v1_account_misc_read_val(
'price.' + item, None
)
our_tickets = ba.internal.get_v1_account_ticket_count()
if price is not None and our_tickets < price:
ba.playsound(ba.getsound('error'))
getcurrency.show_get_tickets_prompt()
else:
def do_it() -> None:
self._do_purchase_check(
item, is_ticket_purchase=True
)
ba.playsound(ba.getsound('swish'))
ConfirmWindow(
ba.Lstr(
resource='store.purchaseConfirmText',
subs=[
(
'${ITEM}',
get_store_item_name_translated(item),
)
],
),
width=400,
height=120,
action=do_it,
ok_text=ba.Lstr(
resource='store.purchaseText',
fallback_resource='okText',
),
)
def _print_already_own(self, charname: str) -> None:
ba.screenmessage(
ba.Lstr(
resource=self._r + '.alreadyOwnText',
subs=[('${NAME}', charname)],
),
color=(1, 0, 0),
)
ba.playsound(ba.getsound('error'))
def update_buttons(self) -> None:
"""Update our buttons."""
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
from ba.internal import get_available_sale_time
from ba import SpecialChar
if not self._root_widget:
return
import datetime
sales_raw = ba.internal.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.utcfromtimestamp(sale_info['e'])
- datetime.datetime.utcnow()
).total_seconds()
if to_end > 0:
sales[sale_item] = {
'to_end': to_end,
'original_price': sale_info['op'],
}
except Exception:
ba.print_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']:
purchased = ba.app.accounts_v1.have_pro()
else:
purchased = ba.internal.get_purchased(b_type)
sale_opacity = 0.0
sale_title_text: str | ba.Lstr = ''
sale_time_text: str | ba.Lstr = ''
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 = ba.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 = get_available_sale_time('extras')
if sale_time is not None:
priceraw = ba.internal.get_price('pro')
price_text_left = (
priceraw if priceraw is not None else '?'
)
priceraw = ba.internal.get_price('pro_sale')
price_text_right = (
priceraw if priceraw is not None else '?'
)
sale_opacity = 1.0
price_text = ''
sale_title_text = ba.Lstr(resource='store.saleText')
sale_time_text = ba.timestring(
sale_time,
centi=False,
timeformat=ba.TimeFormat.MILLISECONDS,
)
else:
priceraw = ba.internal.get_price('pro')
price_text = priceraw if priceraw is not None else '?'
price_text_left = ''
price_text_right = ''
else:
price = ba.internal.get_v1_account_misc_read_val(
'price.' + b_type, 0
)
# Color the button differently if we cant afford this.
if ba.internal.get_v1_account_state() == 'signed_in':
if ba.internal.get_v1_account_ticket_count() < price:
color = (0.6, 0.61, 0.6)
price_text = ba.charstr(ba.SpecialChar.TICKET) + str(
ba.internal.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 = ba.charstr(SpecialChar.TICKET) + str(
sales[b_type]['original_price']
)
price_text_right = price_text
price_text = ''
sale_title_text = ba.Lstr(resource='store.saleText')
sale_time_text = ba.timestring(
int(sales[b_type]['to_end'] * 1000),
centi=False,
timeformat=ba.TimeFormat.MILLISECONDS,
)
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:
ba.textwidget(edit=b_info['title_text'], color=title_color)
if 'purchase_check' in b_info:
ba.imagewidget(
edit=b_info['purchase_check'],
opacity=1.0 if show_purchase_check else 0.0,
)
if 'price_widget' in b_info:
ba.textwidget(
edit=b_info['price_widget'],
text=price_text,
color=price_color,
)
if 'price_widget_left' in b_info:
ba.textwidget(
edit=b_info['price_widget_left'], text=price_text_left
)
if 'price_widget_right' in b_info:
ba.textwidget(
edit=b_info['price_widget_right'], text=price_text_right
)
if 'price_slash_widget' in b_info:
ba.imagewidget(
edit=b_info['price_slash_widget'], opacity=sale_opacity
)
if 'sale_bg_widget' in b_info:
ba.imagewidget(
edit=b_info['sale_bg_widget'], opacity=sale_opacity
)
if 'sale_title_widget' in b_info:
ba.textwidget(
edit=b_info['sale_title_widget'], text=sale_title_text
)
if 'sale_time_widget' in b_info:
ba.textwidget(
edit=b_info['sale_time_widget'], text=sale_time_text
)
if 'button' in b_info:
ba.buttonwidget(
edit=b_info['button'], color=color, on_activate_call=call
)
if 'extra_backings' in b_info:
for bck in b_info['extra_backings']:
ba.imagewidget(
edit=bck, color=color, opacity=extra_image_opacity
)
if 'extra_images' in b_info:
for img in b_info['extra_images']:
ba.imagewidget(edit=img, opacity=extra_image_opacity)
if 'extra_texts' in b_info:
for etxt in b_info['extra_texts']:
ba.textwidget(edit=etxt, color=description_color)
if 'extra_texts_2' in b_info:
for etxt in b_info['extra_texts_2']:
ba.textwidget(edit=etxt, color=description_color2)
if 'descriptionText' in b_info:
ba.textwidget(
edit=b_info['descriptionText'], color=description_color
)
def _on_response(self, data: dict[str, Any] | None) -> None:
# pylint: disable=too-many-statements
# clear status text..
if self._status_textwidget:
self._status_textwidget.delete()
self._status_textwidget_update_timer = None
if data is None:
self._status_textwidget = ba.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=ba.Lstr(resource=self._r + '.loadErrorText'),
maxwidth=self._scroll_width * 0.9,
)
else:
class _Store:
def __init__(
self,
store_window: StoreBrowserWindow,
sdata: dict[str, Any],
width: float,
):
from ba.internal import (
get_store_item_display_size,
get_store_layout,
)
self._store_window = store_window
self._width = width
store_data = get_store_layout()
self._tab = sdata['tab']
self._sections = copy.deepcopy(store_data[sdata['tab']])
self._height: float | None = None
uiscale = ba.app.ui.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'] = get_store_item_display_size(
dummy_name
)
section['v_spacing'] = (
-25
if (
self._tab == 'extras'
and uiscale is ba.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 ba.UIScale.SMALL
and ba.app.config.get('Merch Link')
)
else 55
if (
self._tab == 'extras'
and uiscale is ba.UIScale.SMALL
)
else -20
if self._tab == 'icons'
else 0
)
def instantiate(
self, scrollwidget: ba.Widget, tab_button: ba.Widget
) -> None:
"""Create the store."""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks
from bastd.ui.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 = ba.containerwidget(
parent=scrollwidget,
scale=1.0,
size=(self._width, self._height),
background=False,
claims_left_right=True,
claims_tab=True,
selection_loops_to_parent=True,
)
v = self._height - 20
if self._tab == 'characters':
txt = ba.Lstr(
resource='store.howToSwitchCharactersText',
subs=[
(
'${SETTINGS}',
ba.Lstr(
resource=(
'accountSettingsWindow.titleText'
)
),
),
(
'${PLAYER_PROFILES}',
ba.Lstr(
resource=(
'playerProfilesWindow.titleText'
)
),
),
],
)
ba.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 = ba.Lstr(
resource='store.howToUseIconsText',
subs=[
(
'${SETTINGS}',
ba.Lstr(resource='mainMenu.settingsText'),
),
(
'${PLAYER_PROFILES}',
ba.Lstr(
resource=(
'playerProfilesWindow.titleText'
)
),
),
],
)
ba.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 = ba.Lstr(resource='store.howToUseMapsText')
ba.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'] != '':
ba.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=ba.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'] = ba.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:
ba.widget(
edit=btn,
up_widget=prev_row_buttons[col],
)
ba.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 :
]:
ba.widget(
edit=b_prev, down_widget=btn
)
else:
ba.widget(
edit=btn, up_widget=prev_row_buttons[-1]
)
else:
ba.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 = ba.Timer(
0.5,
ba.WeakCall(self._store_window.update_buttons),
repeat=True,
timetype=ba.TimeType.REAL,
)
# 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 = ba.containerwidget(
parent=self._scrollwidget,
scale=1.0,
size=(self._scroll_width, self._scroll_height * 0.95),
background=False,
claims_left_right=True,
claims_tab=True,
selection_loops_to_parent=True,
)
self._status_textwidget = ba.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=ba.Lstr(resource=self._r + '.comingSoonText'),
maxwidth=self._scroll_width * 0.9,
)
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._get_tickets_button:
sel_name = 'GetTickets'
elif 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}\'')
ba.app.ui.window_states[type(self)] = {
'sel_name': sel_name,
}
except Exception:
ba.print_exception(f'Error saving state for {self}.')
def _restore_state(self) -> None:
from efro.util import enum_by_value
try:
sel: ba.Widget | None
sel_name = ba.app.ui.window_states.get(type(self), {}).get(
'sel_name'
)
assert isinstance(sel_name, (str, type(None)))
try:
current_tab = enum_by_value(
self.TabID, ba.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 == 'GetTickets' and self._get_tickets_button:
sel = self._get_tickets_button
elif 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 = enum_by_value(
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:
ba.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception:
ba.print_exception(f'Error restoring state for {self}.')
def _on_get_more_tickets_press(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.account import show_sign_in_prompt
from bastd.ui.getcurrency import GetCurrencyWindow
if ba.internal.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return
self._save_state()
ba.containerwidget(edit=self._root_widget, transition='out_left')
window = GetCurrencyWindow(
from_modal_store=self._modal,
store_back_location=self._back_location,
).get_root_widget()
if not self._modal:
ba.app.ui.set_main_menu_window(window)
def _back(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.coop.browser import CoopBrowserWindow
from bastd.ui.mainmenu import MainMenuWindow
self._save_state()
ba.containerwidget(
edit=self._root_widget, transition=self._transition_out
)
if not self._modal:
if self._back_location == 'CoopBrowserWindow':
ba.app.ui.set_main_menu_window(
CoopBrowserWindow(transition='in_left').get_root_widget()
)
else:
ba.app.ui.set_main_menu_window(
MainMenuWindow(transition='in_left').get_root_widget()
)
if self._on_close_call is not None:
self._on_close_call()
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.
for _i in range(15):
try:
if ba.app.cloud.is_connected():
response = ba.app.cloud.send_message(
bacommon.cloud.MerchAvailabilityMessage()
)
def _store_in_logic_thread() -> None:
cfg = ba.app.config
current: str | None = 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.
ba.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.
Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()