bastd.ui.store.browser
UI for browsing the store.
1# Released under the MIT License. See LICENSE for details. 2# 3"""UI for browsing the store.""" 4# pylint: disable=too-many-lines 5from __future__ import annotations 6 7import copy 8import math 9import weakref 10from enum import Enum 11from typing import TYPE_CHECKING 12 13import _ba 14import ba 15 16if TYPE_CHECKING: 17 from typing import Any, Callable, Sequence 18 19 20class StoreBrowserWindow(ba.Window): 21 """Window for browsing the store.""" 22 23 class TabID(Enum): 24 """Our available tab types.""" 25 EXTRAS = 'extras' 26 MAPS = 'maps' 27 MINIGAMES = 'minigames' 28 CHARACTERS = 'characters' 29 ICONS = 'icons' 30 31 def __init__(self, 32 transition: str = 'in_right', 33 modal: bool = False, 34 show_tab: StoreBrowserWindow.TabID | None = None, 35 on_close_call: Callable[[], Any] | None = None, 36 back_location: str | None = None, 37 origin_widget: ba.Widget | None = None): 38 # pylint: disable=too-many-statements 39 # pylint: disable=too-many-locals 40 from bastd.ui.tabs import TabRow 41 from ba import SpecialChar 42 43 app = ba.app 44 uiscale = app.ui.uiscale 45 46 ba.set_analytics_screen('Store Window') 47 48 scale_origin: tuple[float, float] | None 49 50 # If they provided an origin-widget, scale up from that. 51 if origin_widget is not None: 52 self._transition_out = 'out_scale' 53 scale_origin = origin_widget.get_screen_space_center() 54 transition = 'in_scale' 55 else: 56 self._transition_out = 'out_right' 57 scale_origin = None 58 59 self.button_infos: dict[str, dict[str, Any]] | None = None 60 self.update_buttons_timer: ba.Timer | None = None 61 self._status_textwidget_update_timer = None 62 63 self._back_location = back_location 64 self._on_close_call = on_close_call 65 self._show_tab = show_tab 66 self._modal = modal 67 self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040 68 self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0 69 self._height = (578 if uiscale is ba.UIScale.SMALL else 70 645 if uiscale is ba.UIScale.MEDIUM else 800) 71 self._current_tab: StoreBrowserWindow.TabID | None = None 72 extra_top = 30 if uiscale is ba.UIScale.SMALL else 0 73 74 self._request: Any = None 75 self._r = 'store' 76 self._last_buy_time: float | None = None 77 78 super().__init__(root_widget=ba.containerwidget( 79 size=(self._width, self._height + extra_top), 80 transition=transition, 81 toolbar_visibility='menu_full', 82 scale=(1.3 if uiscale is ba.UIScale.SMALL else 83 0.9 if uiscale is ba.UIScale.MEDIUM else 0.8), 84 scale_origin_stack_offset=scale_origin, 85 stack_offset=((0, -5) if uiscale is ba.UIScale.SMALL else ( 86 0, 0) if uiscale is ba.UIScale.MEDIUM else (0, 0)))) 87 88 self._back_button = btn = ba.buttonwidget( 89 parent=self._root_widget, 90 position=(70 + x_inset, self._height - 74), 91 size=(140, 60), 92 scale=1.1, 93 autoselect=True, 94 label=ba.Lstr(resource='doneText' if self._modal else 'backText'), 95 button_type=None if self._modal else 'back', 96 on_activate_call=self._back) 97 ba.containerwidget(edit=self._root_widget, cancel_button=btn) 98 99 self._ticket_count_text: ba.Widget | None = None 100 self._get_tickets_button: ba.Widget | None = None 101 102 if ba.app.allow_ticket_purchases: 103 self._get_tickets_button = ba.buttonwidget( 104 parent=self._root_widget, 105 size=(210, 65), 106 on_activate_call=self._on_get_more_tickets_press, 107 autoselect=True, 108 scale=0.9, 109 text_scale=1.4, 110 left_widget=self._back_button, 111 color=(0.7, 0.5, 0.85), 112 textcolor=(0.2, 1.0, 0.2), 113 label=ba.Lstr(resource='getTicketsWindow.titleText')) 114 else: 115 self._ticket_count_text = ba.textwidget(parent=self._root_widget, 116 size=(210, 64), 117 color=(0.2, 1.0, 0.2), 118 h_align='center', 119 v_align='center') 120 121 # Move this dynamically to keep it out of the way of the party icon. 122 self._update_get_tickets_button_pos() 123 self._get_ticket_pos_update_timer = ba.Timer( 124 1.0, 125 ba.WeakCall(self._update_get_tickets_button_pos), 126 repeat=True, 127 timetype=ba.TimeType.REAL) 128 if self._get_tickets_button: 129 ba.widget(edit=self._back_button, 130 right_widget=self._get_tickets_button) 131 self._ticket_text_update_timer = ba.Timer( 132 1.0, 133 ba.WeakCall(self._update_tickets_text), 134 timetype=ba.TimeType.REAL, 135 repeat=True) 136 self._update_tickets_text() 137 138 app = ba.app 139 if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore': 140 ba.buttonwidget( 141 parent=self._root_widget, 142 position=(self._width * 0.5 - 70, 16), 143 size=(230, 50), 144 scale=0.65, 145 on_activate_call=ba.WeakCall(self._restore_purchases), 146 color=(0.35, 0.3, 0.4), 147 selectable=False, 148 textcolor=(0.55, 0.5, 0.6), 149 label=ba.Lstr( 150 resource='getTicketsWindow.restorePurchasesText')) 151 152 ba.textwidget(parent=self._root_widget, 153 position=(self._width * 0.5, self._height - 44), 154 size=(0, 0), 155 color=app.ui.title_color, 156 scale=1.5, 157 h_align='center', 158 v_align='center', 159 text=ba.Lstr(resource='storeText'), 160 maxwidth=420) 161 162 if not self._modal: 163 ba.buttonwidget(edit=self._back_button, 164 button_type='backSmall', 165 size=(60, 60), 166 label=ba.charstr(SpecialChar.BACK)) 167 168 scroll_buffer_h = 130 + 2 * x_inset 169 tab_buffer_h = 250 + 2 * x_inset 170 171 tabs_def = [ 172 (self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')), 173 (self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')), 174 (self.TabID.MINIGAMES, 175 ba.Lstr(resource=self._r + '.miniGamesText')), 176 (self.TabID.CHARACTERS, 177 ba.Lstr(resource=self._r + '.charactersText')), 178 (self.TabID.ICONS, ba.Lstr(resource=self._r + '.iconsText')), 179 ] 180 181 self._tab_row = TabRow(self._root_widget, 182 tabs_def, 183 pos=(tab_buffer_h * 0.5, self._height - 130), 184 size=(self._width - tab_buffer_h, 50), 185 on_select_call=self._set_tab) 186 187 self._purchasable_count_widgets: dict[StoreBrowserWindow.TabID, 188 dict[str, Any]] = {} 189 190 # Create our purchasable-items tags and have them update over time. 191 for tab_id, tab in self._tab_row.tabs.items(): 192 pos = tab.position 193 size = tab.size 194 button = tab.button 195 rad = 10 196 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) 197 img = ba.imagewidget(parent=self._root_widget, 198 position=(center[0] - rad * 1.04, 199 center[1] - rad * 1.15), 200 size=(rad * 2.2, rad * 2.2), 201 texture=ba.gettexture('circleShadow'), 202 color=(1, 0, 0)) 203 txt = ba.textwidget(parent=self._root_widget, 204 position=center, 205 size=(0, 0), 206 h_align='center', 207 v_align='center', 208 maxwidth=1.4 * rad, 209 scale=0.6, 210 shadow=1.0, 211 flatness=1.0) 212 rad = 20 213 sale_img = ba.imagewidget(parent=self._root_widget, 214 position=(center[0] - rad, 215 center[1] - rad), 216 size=(rad * 2, rad * 2), 217 draw_controller=button, 218 texture=ba.gettexture('circleZigZag'), 219 color=(0.5, 0, 1.0)) 220 sale_title_text = ba.textwidget(parent=self._root_widget, 221 position=(center[0], 222 center[1] + 0.24 * rad), 223 size=(0, 0), 224 h_align='center', 225 v_align='center', 226 draw_controller=button, 227 maxwidth=1.4 * rad, 228 scale=0.6, 229 shadow=0.0, 230 flatness=1.0, 231 color=(0, 1, 0)) 232 sale_time_text = ba.textwidget(parent=self._root_widget, 233 position=(center[0], 234 center[1] - 0.29 * rad), 235 size=(0, 0), 236 h_align='center', 237 v_align='center', 238 draw_controller=button, 239 maxwidth=1.4 * rad, 240 scale=0.4, 241 shadow=0.0, 242 flatness=1.0, 243 color=(0, 1, 0)) 244 self._purchasable_count_widgets[tab_id] = { 245 'img': img, 246 'text': txt, 247 'sale_img': sale_img, 248 'sale_title_text': sale_title_text, 249 'sale_time_text': sale_time_text 250 } 251 self._tab_update_timer = ba.Timer(1.0, 252 ba.WeakCall(self._update_tabs), 253 timetype=ba.TimeType.REAL, 254 repeat=True) 255 self._update_tabs() 256 257 if self._get_tickets_button: 258 last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button 259 ba.widget(edit=self._get_tickets_button, 260 down_widget=last_tab_button) 261 ba.widget(edit=last_tab_button, 262 up_widget=self._get_tickets_button, 263 right_widget=self._get_tickets_button) 264 265 self._scroll_width = self._width - scroll_buffer_h 266 self._scroll_height = self._height - 180 267 268 self._scrollwidget: ba.Widget | None = None 269 self._status_textwidget: ba.Widget | None = None 270 self._restore_state() 271 272 def _update_get_tickets_button_pos(self) -> None: 273 uiscale = ba.app.ui.uiscale 274 pos = (self._width - 252 - (self._x_inset + 275 (47 if uiscale is ba.UIScale.SMALL 276 and _ba.is_party_icon_visible() else 0)), 277 self._height - 70) 278 if self._get_tickets_button: 279 ba.buttonwidget(edit=self._get_tickets_button, position=pos) 280 if self._ticket_count_text: 281 ba.textwidget(edit=self._ticket_count_text, position=pos) 282 283 def _restore_purchases(self) -> None: 284 from bastd.ui import account 285 if _ba.get_v1_account_state() != 'signed_in': 286 account.show_sign_in_prompt() 287 else: 288 _ba.restore_purchases() 289 290 def _update_tabs(self) -> None: 291 from ba.internal import (get_available_sale_time, 292 get_available_purchase_count) 293 if not self._root_widget: 294 return 295 for tab_id, tab_data in list(self._purchasable_count_widgets.items()): 296 sale_time = get_available_sale_time(tab_id.value) 297 298 if sale_time is not None: 299 ba.textwidget(edit=tab_data['sale_title_text'], 300 text=ba.Lstr(resource='store.saleText')) 301 ba.textwidget(edit=tab_data['sale_time_text'], 302 text=ba.timestring( 303 sale_time, 304 centi=False, 305 timeformat=ba.TimeFormat.MILLISECONDS)) 306 ba.imagewidget(edit=tab_data['sale_img'], opacity=1.0) 307 count = 0 308 else: 309 ba.textwidget(edit=tab_data['sale_title_text'], text='') 310 ba.textwidget(edit=tab_data['sale_time_text'], text='') 311 ba.imagewidget(edit=tab_data['sale_img'], opacity=0.0) 312 count = get_available_purchase_count(tab_id.value) 313 314 if count > 0: 315 ba.textwidget(edit=tab_data['text'], text=str(count)) 316 ba.imagewidget(edit=tab_data['img'], opacity=1.0) 317 else: 318 ba.textwidget(edit=tab_data['text'], text='') 319 ba.imagewidget(edit=tab_data['img'], opacity=0.0) 320 321 def _update_tickets_text(self) -> None: 322 from ba import SpecialChar 323 if not self._root_widget: 324 return 325 sval: str | ba.Lstr 326 if _ba.get_v1_account_state() == 'signed_in': 327 sval = ba.charstr(SpecialChar.TICKET) + str( 328 _ba.get_v1_account_ticket_count()) 329 else: 330 sval = ba.Lstr(resource='getTicketsWindow.titleText') 331 if self._get_tickets_button: 332 ba.buttonwidget(edit=self._get_tickets_button, label=sval) 333 if self._ticket_count_text: 334 ba.textwidget(edit=self._ticket_count_text, text=sval) 335 336 def _set_tab(self, tab_id: TabID) -> None: 337 if self._current_tab is tab_id: 338 return 339 self._current_tab = tab_id 340 341 # We wanna preserve our current tab between runs. 342 cfg = ba.app.config 343 cfg['Store Tab'] = tab_id.value 344 cfg.commit() 345 346 # Update tab colors based on which is selected. 347 self._tab_row.update_appearance(tab_id) 348 349 # (Re)create scroll widget. 350 if self._scrollwidget: 351 self._scrollwidget.delete() 352 353 self._scrollwidget = ba.scrollwidget( 354 parent=self._root_widget, 355 highlight=False, 356 position=((self._width - self._scroll_width) * 0.5, 357 self._height - self._scroll_height - 79 - 48), 358 size=(self._scroll_width, self._scroll_height), 359 claims_left_right=True, 360 claims_tab=True, 361 selection_loops_to_parent=True) 362 363 # NOTE: this stuff is modified by the _Store class. 364 # Should maybe clean that up. 365 self.button_infos = {} 366 self.update_buttons_timer = None 367 368 # Show status over top. 369 if self._status_textwidget: 370 self._status_textwidget.delete() 371 self._status_textwidget = ba.textwidget( 372 parent=self._root_widget, 373 position=(self._width * 0.5, self._height * 0.5), 374 size=(0, 0), 375 color=(1, 0.7, 1, 0.5), 376 h_align='center', 377 v_align='center', 378 text=ba.Lstr(resource=self._r + '.loadingText'), 379 maxwidth=self._scroll_width * 0.9) 380 381 class _Request: 382 383 def __init__(self, window: StoreBrowserWindow): 384 self._window = weakref.ref(window) 385 data = {'tab': tab_id.value} 386 ba.timer(0.1, 387 ba.WeakCall(self._on_response, data), 388 timetype=ba.TimeType.REAL) 389 390 def _on_response(self, data: dict[str, Any] | None) -> None: 391 # FIXME: clean this up. 392 # pylint: disable=protected-access 393 window = self._window() 394 if window is not None and (window._request is self): 395 window._request = None 396 # noinspection PyProtectedMember 397 window._on_response(data) 398 399 # Kick off a server request. 400 self._request = _Request(self) 401 402 # Actually start the purchase locally. 403 def _purchase_check_result(self, item: str, is_ticket_purchase: bool, 404 result: dict[str, Any] | None) -> None: 405 if result is None: 406 ba.playsound(ba.getsound('error')) 407 ba.screenmessage( 408 ba.Lstr(resource='internal.unavailableNoConnectionText'), 409 color=(1, 0, 0)) 410 else: 411 if is_ticket_purchase: 412 if result['allow']: 413 price = _ba.get_v1_account_misc_read_val( 414 'price.' + item, None) 415 if (price is None or not isinstance(price, int) 416 or price <= 0): 417 print('Error; got invalid local price of', price, 418 'for item', item) 419 ba.playsound(ba.getsound('error')) 420 else: 421 ba.playsound(ba.getsound('click01')) 422 _ba.in_game_purchase(item, price) 423 else: 424 if result['reason'] == 'versionTooOld': 425 ba.playsound(ba.getsound('error')) 426 ba.screenmessage(ba.Lstr( 427 resource='getTicketsWindow.versionTooOldText'), 428 color=(1, 0, 0)) 429 else: 430 ba.playsound(ba.getsound('error')) 431 ba.screenmessage(ba.Lstr( 432 resource='getTicketsWindow.unavailableText'), 433 color=(1, 0, 0)) 434 # Real in-app purchase. 435 else: 436 if result['allow']: 437 _ba.purchase(item) 438 else: 439 if result['reason'] == 'versionTooOld': 440 ba.playsound(ba.getsound('error')) 441 ba.screenmessage(ba.Lstr( 442 resource='getTicketsWindow.versionTooOldText'), 443 color=(1, 0, 0)) 444 else: 445 ba.playsound(ba.getsound('error')) 446 ba.screenmessage(ba.Lstr( 447 resource='getTicketsWindow.unavailableText'), 448 color=(1, 0, 0)) 449 450 def _do_purchase_check(self, 451 item: str, 452 is_ticket_purchase: bool = False) -> None: 453 from ba.internal import master_server_get 454 455 # Here we ping the server to ask if it's valid for us to 456 # purchase this. Better to fail now than after we've 457 # paid locally. 458 app = ba.app 459 master_server_get( 460 'bsAccountPurchaseCheck', 461 { 462 'item': item, 463 'platform': app.platform, 464 'subplatform': app.subplatform, 465 'version': app.version, 466 'buildNumber': app.build_number, 467 'purchaseType': 'ticket' if is_ticket_purchase else 'real' 468 }, 469 callback=ba.WeakCall(self._purchase_check_result, item, 470 is_ticket_purchase), 471 ) 472 473 def buy(self, item: str) -> None: 474 """Attempt to purchase the provided item.""" 475 from ba.internal import (get_available_sale_time, 476 get_store_item_name_translated) 477 from bastd.ui import account 478 from bastd.ui.confirm import ConfirmWindow 479 from bastd.ui import getcurrency 480 481 # Prevent pressing buy within a few seconds of the last press 482 # (gives the buttons time to disable themselves and whatnot). 483 curtime = ba.time(ba.TimeType.REAL) 484 if self._last_buy_time is not None and (curtime - 485 self._last_buy_time) < 2.0: 486 ba.playsound(ba.getsound('error')) 487 else: 488 if _ba.get_v1_account_state() != 'signed_in': 489 account.show_sign_in_prompt() 490 else: 491 self._last_buy_time = curtime 492 493 # Pro is an actual IAP; the rest are ticket purchases. 494 if item == 'pro': 495 ba.playsound(ba.getsound('click01')) 496 497 # Purchase either pro or pro_sale depending on whether 498 # there is a sale going on. 499 self._do_purchase_check('pro' if get_available_sale_time( 500 'extras') is None else 'pro_sale') 501 else: 502 price = _ba.get_v1_account_misc_read_val( 503 'price.' + item, None) 504 our_tickets = _ba.get_v1_account_ticket_count() 505 if price is not None and our_tickets < price: 506 ba.playsound(ba.getsound('error')) 507 getcurrency.show_get_tickets_prompt() 508 else: 509 510 def do_it() -> None: 511 self._do_purchase_check(item, 512 is_ticket_purchase=True) 513 514 ba.playsound(ba.getsound('swish')) 515 ConfirmWindow( 516 ba.Lstr(resource='store.purchaseConfirmText', 517 subs=[ 518 ('${ITEM}', 519 get_store_item_name_translated(item)) 520 ]), 521 width=400, 522 height=120, 523 action=do_it, 524 ok_text=ba.Lstr(resource='store.purchaseText', 525 fallback_resource='okText')) 526 527 def _print_already_own(self, charname: str) -> None: 528 ba.screenmessage(ba.Lstr(resource=self._r + '.alreadyOwnText', 529 subs=[('${NAME}', charname)]), 530 color=(1, 0, 0)) 531 ba.playsound(ba.getsound('error')) 532 533 def update_buttons(self) -> None: 534 """Update our buttons.""" 535 # pylint: disable=too-many-statements 536 # pylint: disable=too-many-branches 537 # pylint: disable=too-many-locals 538 from ba.internal import get_available_sale_time 539 from ba import SpecialChar 540 if not self._root_widget: 541 return 542 import datetime 543 sales_raw = _ba.get_v1_account_misc_read_val('sales', {}) 544 sales = {} 545 try: 546 # Look at the current set of sales; filter any with time remaining. 547 for sale_item, sale_info in list(sales_raw.items()): 548 to_end = (datetime.datetime.utcfromtimestamp(sale_info['e']) - 549 datetime.datetime.utcnow()).total_seconds() 550 if to_end > 0: 551 sales[sale_item] = { 552 'to_end': to_end, 553 'original_price': sale_info['op'] 554 } 555 except Exception: 556 ba.print_exception('Error parsing sales.') 557 558 assert self.button_infos is not None 559 for b_type, b_info in self.button_infos.items(): 560 561 if b_type in ['upgrades.pro', 'pro']: 562 purchased = _ba.app.accounts_v1.have_pro() 563 else: 564 purchased = _ba.get_purchased(b_type) 565 566 sale_opacity = 0.0 567 sale_title_text: str | ba.Lstr = '' 568 sale_time_text: str | ba.Lstr = '' 569 570 if purchased: 571 title_color = (0.8, 0.7, 0.9, 1.0) 572 color = (0.63, 0.55, 0.78) 573 extra_image_opacity = 0.5 574 call = ba.WeakCall(self._print_already_own, b_info['name']) 575 price_text = '' 576 price_text_left = '' 577 price_text_right = '' 578 show_purchase_check = True 579 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 580 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 581 price_color = (0.5, 1, 0.5, 0.3) 582 else: 583 title_color = (0.7, 0.9, 0.7, 1.0) 584 color = (0.4, 0.8, 0.1) 585 extra_image_opacity = 1.0 586 call = b_info['call'] if 'call' in b_info else None 587 if b_type in ['upgrades.pro', 'pro']: 588 sale_time = get_available_sale_time('extras') 589 if sale_time is not None: 590 priceraw = _ba.get_price('pro') 591 price_text_left = (priceraw 592 if priceraw is not None else '?') 593 priceraw = _ba.get_price('pro_sale') 594 price_text_right = (priceraw 595 if priceraw is not None else '?') 596 sale_opacity = 1.0 597 price_text = '' 598 sale_title_text = ba.Lstr(resource='store.saleText') 599 sale_time_text = ba.timestring( 600 sale_time, 601 centi=False, 602 timeformat=ba.TimeFormat.MILLISECONDS) 603 else: 604 priceraw = _ba.get_price('pro') 605 price_text = priceraw if priceraw is not None else '?' 606 price_text_left = '' 607 price_text_right = '' 608 else: 609 price = _ba.get_v1_account_misc_read_val( 610 'price.' + b_type, 0) 611 612 # Color the button differently if we cant afford this. 613 if _ba.get_v1_account_state() == 'signed_in': 614 if _ba.get_v1_account_ticket_count() < price: 615 color = (0.6, 0.61, 0.6) 616 price_text = ba.charstr(ba.SpecialChar.TICKET) + str( 617 _ba.get_v1_account_misc_read_val( 618 'price.' + b_type, '?')) 619 price_text_left = '' 620 price_text_right = '' 621 622 # TESTING: 623 if b_type in sales: 624 sale_opacity = 1.0 625 price_text_left = ba.charstr(SpecialChar.TICKET) + str( 626 sales[b_type]['original_price']) 627 price_text_right = price_text 628 price_text = '' 629 sale_title_text = ba.Lstr(resource='store.saleText') 630 sale_time_text = ba.timestring( 631 int(sales[b_type]['to_end'] * 1000), 632 centi=False, 633 timeformat=ba.TimeFormat.MILLISECONDS) 634 635 description_color = (0.5, 1.0, 0.5) 636 description_color2 = (0.3, 1.0, 1.0) 637 price_color = (0.2, 1, 0.2, 1.0) 638 show_purchase_check = False 639 640 if 'title_text' in b_info: 641 ba.textwidget(edit=b_info['title_text'], color=title_color) 642 if 'purchase_check' in b_info: 643 ba.imagewidget(edit=b_info['purchase_check'], 644 opacity=1.0 if show_purchase_check else 0.0) 645 if 'price_widget' in b_info: 646 ba.textwidget(edit=b_info['price_widget'], 647 text=price_text, 648 color=price_color) 649 if 'price_widget_left' in b_info: 650 ba.textwidget(edit=b_info['price_widget_left'], 651 text=price_text_left) 652 if 'price_widget_right' in b_info: 653 ba.textwidget(edit=b_info['price_widget_right'], 654 text=price_text_right) 655 if 'price_slash_widget' in b_info: 656 ba.imagewidget(edit=b_info['price_slash_widget'], 657 opacity=sale_opacity) 658 if 'sale_bg_widget' in b_info: 659 ba.imagewidget(edit=b_info['sale_bg_widget'], 660 opacity=sale_opacity) 661 if 'sale_title_widget' in b_info: 662 ba.textwidget(edit=b_info['sale_title_widget'], 663 text=sale_title_text) 664 if 'sale_time_widget' in b_info: 665 ba.textwidget(edit=b_info['sale_time_widget'], 666 text=sale_time_text) 667 if 'button' in b_info: 668 ba.buttonwidget(edit=b_info['button'], 669 color=color, 670 on_activate_call=call) 671 if 'extra_backings' in b_info: 672 for bck in b_info['extra_backings']: 673 ba.imagewidget(edit=bck, 674 color=color, 675 opacity=extra_image_opacity) 676 if 'extra_images' in b_info: 677 for img in b_info['extra_images']: 678 ba.imagewidget(edit=img, opacity=extra_image_opacity) 679 if 'extra_texts' in b_info: 680 for etxt in b_info['extra_texts']: 681 ba.textwidget(edit=etxt, color=description_color) 682 if 'extra_texts_2' in b_info: 683 for etxt in b_info['extra_texts_2']: 684 ba.textwidget(edit=etxt, color=description_color2) 685 if 'descriptionText' in b_info: 686 ba.textwidget(edit=b_info['descriptionText'], 687 color=description_color) 688 689 def _on_response(self, data: dict[str, Any] | None) -> None: 690 # pylint: disable=too-many-statements 691 692 # clear status text.. 693 if self._status_textwidget: 694 self._status_textwidget.delete() 695 self._status_textwidget_update_timer = None 696 697 if data is None: 698 self._status_textwidget = ba.textwidget( 699 parent=self._root_widget, 700 position=(self._width * 0.5, self._height * 0.5), 701 size=(0, 0), 702 scale=1.3, 703 transition_delay=0.1, 704 color=(1, 0.3, 0.3, 1.0), 705 h_align='center', 706 v_align='center', 707 text=ba.Lstr(resource=self._r + '.loadErrorText'), 708 maxwidth=self._scroll_width * 0.9) 709 else: 710 711 class _Store: 712 713 def __init__(self, store_window: StoreBrowserWindow, 714 sdata: dict[str, Any], width: float): 715 from ba.internal import (get_store_item_display_size, 716 get_store_layout) 717 self._store_window = store_window 718 self._width = width 719 store_data = get_store_layout() 720 self._tab = sdata['tab'] 721 self._sections = copy.deepcopy(store_data[sdata['tab']]) 722 self._height: float | None = None 723 724 uiscale = ba.app.ui.uiscale 725 726 # Pre-calc a few things and add them to store-data. 727 for section in self._sections: 728 if self._tab == 'characters': 729 dummy_name = 'characters.foo' 730 elif self._tab == 'extras': 731 dummy_name = 'pro' 732 elif self._tab == 'maps': 733 dummy_name = 'maps.foo' 734 elif self._tab == 'icons': 735 dummy_name = 'icons.foo' 736 else: 737 dummy_name = '' 738 section['button_size'] = get_store_item_display_size( 739 dummy_name) 740 section['v_spacing'] = (-17 if self._tab 741 == 'characters' else 0) 742 if 'title' not in section: 743 section['title'] = '' 744 section['x_offs'] = (130 if self._tab == 'extras' else 745 270 if self._tab == 'maps' else 0) 746 section['y_offs'] = ( 747 55 if (self._tab == 'extras' 748 and uiscale is ba.UIScale.SMALL) else 749 -20 if self._tab == 'icons' else 0) 750 751 def instantiate(self, scrollwidget: ba.Widget, 752 tab_button: ba.Widget) -> None: 753 """Create the store.""" 754 # pylint: disable=too-many-locals 755 # pylint: disable=too-many-branches 756 # pylint: disable=too-many-nested-blocks 757 from bastd.ui.store import item as storeitemui 758 title_spacing = 40 759 button_border = 20 760 button_spacing = 4 761 boffs_h = 40 762 self._height = 80.0 763 764 # Calc total height. 765 for i, section in enumerate(self._sections): 766 if section['title'] != '': 767 assert self._height is not None 768 self._height += title_spacing 769 b_width, b_height = section['button_size'] 770 b_column_count = int( 771 math.floor((self._width - boffs_h - 20) / 772 (b_width + button_spacing))) 773 b_row_count = int( 774 math.ceil( 775 float(len(section['items'])) / b_column_count)) 776 b_height_total = ( 777 2 * button_border + b_row_count * b_height + 778 (b_row_count - 1) * section['v_spacing']) 779 self._height += b_height_total 780 781 assert self._height is not None 782 cnt2 = ba.containerwidget(parent=scrollwidget, 783 scale=1.0, 784 size=(self._width, self._height), 785 background=False, 786 claims_left_right=True, 787 claims_tab=True, 788 selection_loops_to_parent=True) 789 v = self._height - 20 790 791 if self._tab == 'characters': 792 txt = ba.Lstr( 793 resource='store.howToSwitchCharactersText', 794 subs=[ 795 ('${SETTINGS}', 796 ba.Lstr( 797 resource='accountSettingsWindow.titleText' 798 )), 799 ('${PLAYER_PROFILES}', 800 ba.Lstr( 801 resource='playerProfilesWindow.titleText') 802 ) 803 ]) 804 ba.textwidget(parent=cnt2, 805 text=txt, 806 size=(0, 0), 807 position=(self._width * 0.5, 808 self._height - 28), 809 h_align='center', 810 v_align='center', 811 color=(0.7, 1, 0.7, 0.4), 812 scale=0.7, 813 shadow=0, 814 flatness=1.0, 815 maxwidth=700, 816 transition_delay=0.4) 817 elif self._tab == 'icons': 818 txt = ba.Lstr( 819 resource='store.howToUseIconsText', 820 subs=[ 821 ('${SETTINGS}', 822 ba.Lstr(resource='mainMenu.settingsText')), 823 ('${PLAYER_PROFILES}', 824 ba.Lstr( 825 resource='playerProfilesWindow.titleText') 826 ) 827 ]) 828 ba.textwidget(parent=cnt2, 829 text=txt, 830 size=(0, 0), 831 position=(self._width * 0.5, 832 self._height - 28), 833 h_align='center', 834 v_align='center', 835 color=(0.7, 1, 0.7, 0.4), 836 scale=0.7, 837 shadow=0, 838 flatness=1.0, 839 maxwidth=700, 840 transition_delay=0.4) 841 elif self._tab == 'maps': 842 assert self._width is not None 843 assert self._height is not None 844 txt = ba.Lstr(resource='store.howToUseMapsText') 845 ba.textwidget(parent=cnt2, 846 text=txt, 847 size=(0, 0), 848 position=(self._width * 0.5, 849 self._height - 28), 850 h_align='center', 851 v_align='center', 852 color=(0.7, 1, 0.7, 0.4), 853 scale=0.7, 854 shadow=0, 855 flatness=1.0, 856 maxwidth=700, 857 transition_delay=0.4) 858 859 prev_row_buttons: list | None = None 860 this_row_buttons = [] 861 862 delay = 0.3 863 for section in self._sections: 864 if section['title'] != '': 865 ba.textwidget( 866 parent=cnt2, 867 position=(60, v - title_spacing * 0.8), 868 size=(0, 0), 869 scale=1.0, 870 transition_delay=delay, 871 color=(0.7, 0.9, 0.7, 1), 872 h_align='left', 873 v_align='center', 874 text=ba.Lstr(resource=section['title']), 875 maxwidth=self._width * 0.7) 876 v -= title_spacing 877 delay = max(0.100, delay - 0.100) 878 v -= button_border 879 b_width, b_height = section['button_size'] 880 b_count = len(section['items']) 881 b_column_count = int( 882 math.floor((self._width - boffs_h - 20) / 883 (b_width + button_spacing))) 884 col = 0 885 item: dict[str, Any] 886 assert self._store_window.button_infos is not None 887 for i, item_name in enumerate(section['items']): 888 item = self._store_window.button_infos[ 889 item_name] = {} 890 item['call'] = ba.WeakCall(self._store_window.buy, 891 item_name) 892 if 'x_offs' in section: 893 boffs_h2 = section['x_offs'] 894 else: 895 boffs_h2 = 0 896 897 if 'y_offs' in section: 898 boffs_v2 = section['y_offs'] 899 else: 900 boffs_v2 = 0 901 b_pos = (boffs_h + boffs_h2 + 902 (b_width + button_spacing) * col, 903 v - b_height + boffs_v2) 904 storeitemui.instantiate_store_item_display( 905 item_name, 906 item, 907 parent_widget=cnt2, 908 b_pos=b_pos, 909 boffs_h=boffs_h, 910 b_width=b_width, 911 b_height=b_height, 912 boffs_h2=boffs_h2, 913 boffs_v2=boffs_v2, 914 delay=delay) 915 btn = item['button'] 916 delay = max(0.1, delay - 0.1) 917 this_row_buttons.append(btn) 918 919 # Wire this button to the equivalent in the 920 # previous row. 921 if prev_row_buttons is not None: 922 # pylint: disable=unsubscriptable-object 923 if len(prev_row_buttons) > col: 924 ba.widget(edit=btn, 925 up_widget=prev_row_buttons[col]) 926 ba.widget(edit=prev_row_buttons[col], 927 down_widget=btn) 928 929 # If we're the last button in our row, 930 # wire any in the previous row past 931 # our position to go to us if down is 932 # pressed. 933 if (col + 1 == b_column_count 934 or i == b_count - 1): 935 for b_prev in prev_row_buttons[col + 936 1:]: 937 ba.widget(edit=b_prev, 938 down_widget=btn) 939 else: 940 ba.widget(edit=btn, 941 up_widget=prev_row_buttons[-1]) 942 else: 943 ba.widget(edit=btn, up_widget=tab_button) 944 945 col += 1 946 if col == b_column_count or i == b_count - 1: 947 prev_row_buttons = this_row_buttons 948 this_row_buttons = [] 949 col = 0 950 v -= b_height 951 if i < b_count - 1: 952 v -= section['v_spacing'] 953 954 v -= button_border 955 956 # Set a timer to update these buttons periodically as long 957 # as we're alive (so if we buy one it will grey out, etc). 958 self._store_window.update_buttons_timer = ba.Timer( 959 0.5, 960 ba.WeakCall(self._store_window.update_buttons), 961 repeat=True, 962 timetype=ba.TimeType.REAL) 963 964 # Also update them immediately. 965 self._store_window.update_buttons() 966 967 if self._current_tab in (self.TabID.EXTRAS, self.TabID.MINIGAMES, 968 self.TabID.CHARACTERS, self.TabID.MAPS, 969 self.TabID.ICONS): 970 store = _Store(self, data, self._scroll_width) 971 assert self._scrollwidget is not None 972 store.instantiate( 973 scrollwidget=self._scrollwidget, 974 tab_button=self._tab_row.tabs[self._current_tab].button) 975 else: 976 cnt = ba.containerwidget(parent=self._scrollwidget, 977 scale=1.0, 978 size=(self._scroll_width, 979 self._scroll_height * 0.95), 980 background=False, 981 claims_left_right=True, 982 claims_tab=True, 983 selection_loops_to_parent=True) 984 self._status_textwidget = ba.textwidget( 985 parent=cnt, 986 position=(self._scroll_width * 0.5, 987 self._scroll_height * 0.5), 988 size=(0, 0), 989 scale=1.3, 990 transition_delay=0.1, 991 color=(1, 1, 0.3, 1.0), 992 h_align='center', 993 v_align='center', 994 text=ba.Lstr(resource=self._r + '.comingSoonText'), 995 maxwidth=self._scroll_width * 0.9) 996 997 def _save_state(self) -> None: 998 try: 999 sel = self._root_widget.get_selected_child() 1000 selected_tab_ids = [ 1001 tab_id for tab_id, tab in self._tab_row.tabs.items() 1002 if sel == tab.button 1003 ] 1004 if sel == self._get_tickets_button: 1005 sel_name = 'GetTickets' 1006 elif sel == self._scrollwidget: 1007 sel_name = 'Scroll' 1008 elif sel == self._back_button: 1009 sel_name = 'Back' 1010 elif selected_tab_ids: 1011 assert len(selected_tab_ids) == 1 1012 sel_name = f'Tab:{selected_tab_ids[0].value}' 1013 else: 1014 raise ValueError(f'unrecognized selection \'{sel}\'') 1015 ba.app.ui.window_states[type(self)] = { 1016 'sel_name': sel_name, 1017 } 1018 except Exception: 1019 ba.print_exception(f'Error saving state for {self}.') 1020 1021 def _restore_state(self) -> None: 1022 from efro.util import enum_by_value 1023 try: 1024 sel: ba.Widget | None 1025 sel_name = ba.app.ui.window_states.get(type(self), 1026 {}).get('sel_name') 1027 assert isinstance(sel_name, (str, type(None))) 1028 1029 try: 1030 current_tab = enum_by_value(self.TabID, 1031 ba.app.config.get('Store Tab')) 1032 except ValueError: 1033 current_tab = self.TabID.CHARACTERS 1034 1035 if self._show_tab is not None: 1036 current_tab = self._show_tab 1037 if sel_name == 'GetTickets' and self._get_tickets_button: 1038 sel = self._get_tickets_button 1039 elif sel_name == 'Back': 1040 sel = self._back_button 1041 elif sel_name == 'Scroll': 1042 sel = self._scrollwidget 1043 elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): 1044 try: 1045 sel_tab_id = enum_by_value(self.TabID, 1046 sel_name.split(':')[-1]) 1047 except ValueError: 1048 sel_tab_id = self.TabID.CHARACTERS 1049 sel = self._tab_row.tabs[sel_tab_id].button 1050 else: 1051 sel = self._tab_row.tabs[current_tab].button 1052 1053 # If we were requested to show a tab, select it too.. 1054 if (self._show_tab is not None 1055 and self._show_tab in self._tab_row.tabs): 1056 sel = self._tab_row.tabs[self._show_tab].button 1057 self._set_tab(current_tab) 1058 if sel is not None: 1059 ba.containerwidget(edit=self._root_widget, selected_child=sel) 1060 except Exception: 1061 ba.print_exception(f'Error restoring state for {self}.') 1062 1063 def _on_get_more_tickets_press(self) -> None: 1064 # pylint: disable=cyclic-import 1065 from bastd.ui.account import show_sign_in_prompt 1066 from bastd.ui.getcurrency import GetCurrencyWindow 1067 if _ba.get_v1_account_state() != 'signed_in': 1068 show_sign_in_prompt() 1069 return 1070 self._save_state() 1071 ba.containerwidget(edit=self._root_widget, transition='out_left') 1072 window = GetCurrencyWindow( 1073 from_modal_store=self._modal, 1074 store_back_location=self._back_location).get_root_widget() 1075 if not self._modal: 1076 ba.app.ui.set_main_menu_window(window) 1077 1078 def _back(self) -> None: 1079 # pylint: disable=cyclic-import 1080 from bastd.ui.coop.browser import CoopBrowserWindow 1081 from bastd.ui.mainmenu import MainMenuWindow 1082 self._save_state() 1083 ba.containerwidget(edit=self._root_widget, 1084 transition=self._transition_out) 1085 if not self._modal: 1086 if self._back_location == 'CoopBrowserWindow': 1087 ba.app.ui.set_main_menu_window( 1088 CoopBrowserWindow(transition='in_left').get_root_widget()) 1089 else: 1090 ba.app.ui.set_main_menu_window( 1091 MainMenuWindow(transition='in_left').get_root_widget()) 1092 if self._on_close_call is not None: 1093 self._on_close_call()
class
StoreBrowserWindow(ba.ui.Window):
21class StoreBrowserWindow(ba.Window): 22 """Window for browsing the store.""" 23 24 class TabID(Enum): 25 """Our available tab types.""" 26 EXTRAS = 'extras' 27 MAPS = 'maps' 28 MINIGAMES = 'minigames' 29 CHARACTERS = 'characters' 30 ICONS = 'icons' 31 32 def __init__(self, 33 transition: str = 'in_right', 34 modal: bool = False, 35 show_tab: StoreBrowserWindow.TabID | None = None, 36 on_close_call: Callable[[], Any] | None = None, 37 back_location: str | None = None, 38 origin_widget: ba.Widget | None = None): 39 # pylint: disable=too-many-statements 40 # pylint: disable=too-many-locals 41 from bastd.ui.tabs import TabRow 42 from ba import SpecialChar 43 44 app = ba.app 45 uiscale = app.ui.uiscale 46 47 ba.set_analytics_screen('Store Window') 48 49 scale_origin: tuple[float, float] | None 50 51 # If they provided an origin-widget, scale up from that. 52 if origin_widget is not None: 53 self._transition_out = 'out_scale' 54 scale_origin = origin_widget.get_screen_space_center() 55 transition = 'in_scale' 56 else: 57 self._transition_out = 'out_right' 58 scale_origin = None 59 60 self.button_infos: dict[str, dict[str, Any]] | None = None 61 self.update_buttons_timer: ba.Timer | None = None 62 self._status_textwidget_update_timer = None 63 64 self._back_location = back_location 65 self._on_close_call = on_close_call 66 self._show_tab = show_tab 67 self._modal = modal 68 self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040 69 self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0 70 self._height = (578 if uiscale is ba.UIScale.SMALL else 71 645 if uiscale is ba.UIScale.MEDIUM else 800) 72 self._current_tab: StoreBrowserWindow.TabID | None = None 73 extra_top = 30 if uiscale is ba.UIScale.SMALL else 0 74 75 self._request: Any = None 76 self._r = 'store' 77 self._last_buy_time: float | None = None 78 79 super().__init__(root_widget=ba.containerwidget( 80 size=(self._width, self._height + extra_top), 81 transition=transition, 82 toolbar_visibility='menu_full', 83 scale=(1.3 if uiscale is ba.UIScale.SMALL else 84 0.9 if uiscale is ba.UIScale.MEDIUM else 0.8), 85 scale_origin_stack_offset=scale_origin, 86 stack_offset=((0, -5) if uiscale is ba.UIScale.SMALL else ( 87 0, 0) if uiscale is ba.UIScale.MEDIUM else (0, 0)))) 88 89 self._back_button = btn = ba.buttonwidget( 90 parent=self._root_widget, 91 position=(70 + x_inset, self._height - 74), 92 size=(140, 60), 93 scale=1.1, 94 autoselect=True, 95 label=ba.Lstr(resource='doneText' if self._modal else 'backText'), 96 button_type=None if self._modal else 'back', 97 on_activate_call=self._back) 98 ba.containerwidget(edit=self._root_widget, cancel_button=btn) 99 100 self._ticket_count_text: ba.Widget | None = None 101 self._get_tickets_button: ba.Widget | None = None 102 103 if ba.app.allow_ticket_purchases: 104 self._get_tickets_button = ba.buttonwidget( 105 parent=self._root_widget, 106 size=(210, 65), 107 on_activate_call=self._on_get_more_tickets_press, 108 autoselect=True, 109 scale=0.9, 110 text_scale=1.4, 111 left_widget=self._back_button, 112 color=(0.7, 0.5, 0.85), 113 textcolor=(0.2, 1.0, 0.2), 114 label=ba.Lstr(resource='getTicketsWindow.titleText')) 115 else: 116 self._ticket_count_text = ba.textwidget(parent=self._root_widget, 117 size=(210, 64), 118 color=(0.2, 1.0, 0.2), 119 h_align='center', 120 v_align='center') 121 122 # Move this dynamically to keep it out of the way of the party icon. 123 self._update_get_tickets_button_pos() 124 self._get_ticket_pos_update_timer = ba.Timer( 125 1.0, 126 ba.WeakCall(self._update_get_tickets_button_pos), 127 repeat=True, 128 timetype=ba.TimeType.REAL) 129 if self._get_tickets_button: 130 ba.widget(edit=self._back_button, 131 right_widget=self._get_tickets_button) 132 self._ticket_text_update_timer = ba.Timer( 133 1.0, 134 ba.WeakCall(self._update_tickets_text), 135 timetype=ba.TimeType.REAL, 136 repeat=True) 137 self._update_tickets_text() 138 139 app = ba.app 140 if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore': 141 ba.buttonwidget( 142 parent=self._root_widget, 143 position=(self._width * 0.5 - 70, 16), 144 size=(230, 50), 145 scale=0.65, 146 on_activate_call=ba.WeakCall(self._restore_purchases), 147 color=(0.35, 0.3, 0.4), 148 selectable=False, 149 textcolor=(0.55, 0.5, 0.6), 150 label=ba.Lstr( 151 resource='getTicketsWindow.restorePurchasesText')) 152 153 ba.textwidget(parent=self._root_widget, 154 position=(self._width * 0.5, self._height - 44), 155 size=(0, 0), 156 color=app.ui.title_color, 157 scale=1.5, 158 h_align='center', 159 v_align='center', 160 text=ba.Lstr(resource='storeText'), 161 maxwidth=420) 162 163 if not self._modal: 164 ba.buttonwidget(edit=self._back_button, 165 button_type='backSmall', 166 size=(60, 60), 167 label=ba.charstr(SpecialChar.BACK)) 168 169 scroll_buffer_h = 130 + 2 * x_inset 170 tab_buffer_h = 250 + 2 * x_inset 171 172 tabs_def = [ 173 (self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')), 174 (self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')), 175 (self.TabID.MINIGAMES, 176 ba.Lstr(resource=self._r + '.miniGamesText')), 177 (self.TabID.CHARACTERS, 178 ba.Lstr(resource=self._r + '.charactersText')), 179 (self.TabID.ICONS, ba.Lstr(resource=self._r + '.iconsText')), 180 ] 181 182 self._tab_row = TabRow(self._root_widget, 183 tabs_def, 184 pos=(tab_buffer_h * 0.5, self._height - 130), 185 size=(self._width - tab_buffer_h, 50), 186 on_select_call=self._set_tab) 187 188 self._purchasable_count_widgets: dict[StoreBrowserWindow.TabID, 189 dict[str, Any]] = {} 190 191 # Create our purchasable-items tags and have them update over time. 192 for tab_id, tab in self._tab_row.tabs.items(): 193 pos = tab.position 194 size = tab.size 195 button = tab.button 196 rad = 10 197 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) 198 img = ba.imagewidget(parent=self._root_widget, 199 position=(center[0] - rad * 1.04, 200 center[1] - rad * 1.15), 201 size=(rad * 2.2, rad * 2.2), 202 texture=ba.gettexture('circleShadow'), 203 color=(1, 0, 0)) 204 txt = ba.textwidget(parent=self._root_widget, 205 position=center, 206 size=(0, 0), 207 h_align='center', 208 v_align='center', 209 maxwidth=1.4 * rad, 210 scale=0.6, 211 shadow=1.0, 212 flatness=1.0) 213 rad = 20 214 sale_img = ba.imagewidget(parent=self._root_widget, 215 position=(center[0] - rad, 216 center[1] - rad), 217 size=(rad * 2, rad * 2), 218 draw_controller=button, 219 texture=ba.gettexture('circleZigZag'), 220 color=(0.5, 0, 1.0)) 221 sale_title_text = ba.textwidget(parent=self._root_widget, 222 position=(center[0], 223 center[1] + 0.24 * rad), 224 size=(0, 0), 225 h_align='center', 226 v_align='center', 227 draw_controller=button, 228 maxwidth=1.4 * rad, 229 scale=0.6, 230 shadow=0.0, 231 flatness=1.0, 232 color=(0, 1, 0)) 233 sale_time_text = ba.textwidget(parent=self._root_widget, 234 position=(center[0], 235 center[1] - 0.29 * rad), 236 size=(0, 0), 237 h_align='center', 238 v_align='center', 239 draw_controller=button, 240 maxwidth=1.4 * rad, 241 scale=0.4, 242 shadow=0.0, 243 flatness=1.0, 244 color=(0, 1, 0)) 245 self._purchasable_count_widgets[tab_id] = { 246 'img': img, 247 'text': txt, 248 'sale_img': sale_img, 249 'sale_title_text': sale_title_text, 250 'sale_time_text': sale_time_text 251 } 252 self._tab_update_timer = ba.Timer(1.0, 253 ba.WeakCall(self._update_tabs), 254 timetype=ba.TimeType.REAL, 255 repeat=True) 256 self._update_tabs() 257 258 if self._get_tickets_button: 259 last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button 260 ba.widget(edit=self._get_tickets_button, 261 down_widget=last_tab_button) 262 ba.widget(edit=last_tab_button, 263 up_widget=self._get_tickets_button, 264 right_widget=self._get_tickets_button) 265 266 self._scroll_width = self._width - scroll_buffer_h 267 self._scroll_height = self._height - 180 268 269 self._scrollwidget: ba.Widget | None = None 270 self._status_textwidget: ba.Widget | None = None 271 self._restore_state() 272 273 def _update_get_tickets_button_pos(self) -> None: 274 uiscale = ba.app.ui.uiscale 275 pos = (self._width - 252 - (self._x_inset + 276 (47 if uiscale is ba.UIScale.SMALL 277 and _ba.is_party_icon_visible() else 0)), 278 self._height - 70) 279 if self._get_tickets_button: 280 ba.buttonwidget(edit=self._get_tickets_button, position=pos) 281 if self._ticket_count_text: 282 ba.textwidget(edit=self._ticket_count_text, position=pos) 283 284 def _restore_purchases(self) -> None: 285 from bastd.ui import account 286 if _ba.get_v1_account_state() != 'signed_in': 287 account.show_sign_in_prompt() 288 else: 289 _ba.restore_purchases() 290 291 def _update_tabs(self) -> None: 292 from ba.internal import (get_available_sale_time, 293 get_available_purchase_count) 294 if not self._root_widget: 295 return 296 for tab_id, tab_data in list(self._purchasable_count_widgets.items()): 297 sale_time = get_available_sale_time(tab_id.value) 298 299 if sale_time is not None: 300 ba.textwidget(edit=tab_data['sale_title_text'], 301 text=ba.Lstr(resource='store.saleText')) 302 ba.textwidget(edit=tab_data['sale_time_text'], 303 text=ba.timestring( 304 sale_time, 305 centi=False, 306 timeformat=ba.TimeFormat.MILLISECONDS)) 307 ba.imagewidget(edit=tab_data['sale_img'], opacity=1.0) 308 count = 0 309 else: 310 ba.textwidget(edit=tab_data['sale_title_text'], text='') 311 ba.textwidget(edit=tab_data['sale_time_text'], text='') 312 ba.imagewidget(edit=tab_data['sale_img'], opacity=0.0) 313 count = get_available_purchase_count(tab_id.value) 314 315 if count > 0: 316 ba.textwidget(edit=tab_data['text'], text=str(count)) 317 ba.imagewidget(edit=tab_data['img'], opacity=1.0) 318 else: 319 ba.textwidget(edit=tab_data['text'], text='') 320 ba.imagewidget(edit=tab_data['img'], opacity=0.0) 321 322 def _update_tickets_text(self) -> None: 323 from ba import SpecialChar 324 if not self._root_widget: 325 return 326 sval: str | ba.Lstr 327 if _ba.get_v1_account_state() == 'signed_in': 328 sval = ba.charstr(SpecialChar.TICKET) + str( 329 _ba.get_v1_account_ticket_count()) 330 else: 331 sval = ba.Lstr(resource='getTicketsWindow.titleText') 332 if self._get_tickets_button: 333 ba.buttonwidget(edit=self._get_tickets_button, label=sval) 334 if self._ticket_count_text: 335 ba.textwidget(edit=self._ticket_count_text, text=sval) 336 337 def _set_tab(self, tab_id: TabID) -> None: 338 if self._current_tab is tab_id: 339 return 340 self._current_tab = tab_id 341 342 # We wanna preserve our current tab between runs. 343 cfg = ba.app.config 344 cfg['Store Tab'] = tab_id.value 345 cfg.commit() 346 347 # Update tab colors based on which is selected. 348 self._tab_row.update_appearance(tab_id) 349 350 # (Re)create scroll widget. 351 if self._scrollwidget: 352 self._scrollwidget.delete() 353 354 self._scrollwidget = ba.scrollwidget( 355 parent=self._root_widget, 356 highlight=False, 357 position=((self._width - self._scroll_width) * 0.5, 358 self._height - self._scroll_height - 79 - 48), 359 size=(self._scroll_width, self._scroll_height), 360 claims_left_right=True, 361 claims_tab=True, 362 selection_loops_to_parent=True) 363 364 # NOTE: this stuff is modified by the _Store class. 365 # Should maybe clean that up. 366 self.button_infos = {} 367 self.update_buttons_timer = None 368 369 # Show status over top. 370 if self._status_textwidget: 371 self._status_textwidget.delete() 372 self._status_textwidget = ba.textwidget( 373 parent=self._root_widget, 374 position=(self._width * 0.5, self._height * 0.5), 375 size=(0, 0), 376 color=(1, 0.7, 1, 0.5), 377 h_align='center', 378 v_align='center', 379 text=ba.Lstr(resource=self._r + '.loadingText'), 380 maxwidth=self._scroll_width * 0.9) 381 382 class _Request: 383 384 def __init__(self, window: StoreBrowserWindow): 385 self._window = weakref.ref(window) 386 data = {'tab': tab_id.value} 387 ba.timer(0.1, 388 ba.WeakCall(self._on_response, data), 389 timetype=ba.TimeType.REAL) 390 391 def _on_response(self, data: dict[str, Any] | None) -> None: 392 # FIXME: clean this up. 393 # pylint: disable=protected-access 394 window = self._window() 395 if window is not None and (window._request is self): 396 window._request = None 397 # noinspection PyProtectedMember 398 window._on_response(data) 399 400 # Kick off a server request. 401 self._request = _Request(self) 402 403 # Actually start the purchase locally. 404 def _purchase_check_result(self, item: str, is_ticket_purchase: bool, 405 result: dict[str, Any] | None) -> None: 406 if result is None: 407 ba.playsound(ba.getsound('error')) 408 ba.screenmessage( 409 ba.Lstr(resource='internal.unavailableNoConnectionText'), 410 color=(1, 0, 0)) 411 else: 412 if is_ticket_purchase: 413 if result['allow']: 414 price = _ba.get_v1_account_misc_read_val( 415 'price.' + item, None) 416 if (price is None or not isinstance(price, int) 417 or price <= 0): 418 print('Error; got invalid local price of', price, 419 'for item', item) 420 ba.playsound(ba.getsound('error')) 421 else: 422 ba.playsound(ba.getsound('click01')) 423 _ba.in_game_purchase(item, price) 424 else: 425 if result['reason'] == 'versionTooOld': 426 ba.playsound(ba.getsound('error')) 427 ba.screenmessage(ba.Lstr( 428 resource='getTicketsWindow.versionTooOldText'), 429 color=(1, 0, 0)) 430 else: 431 ba.playsound(ba.getsound('error')) 432 ba.screenmessage(ba.Lstr( 433 resource='getTicketsWindow.unavailableText'), 434 color=(1, 0, 0)) 435 # Real in-app purchase. 436 else: 437 if result['allow']: 438 _ba.purchase(item) 439 else: 440 if result['reason'] == 'versionTooOld': 441 ba.playsound(ba.getsound('error')) 442 ba.screenmessage(ba.Lstr( 443 resource='getTicketsWindow.versionTooOldText'), 444 color=(1, 0, 0)) 445 else: 446 ba.playsound(ba.getsound('error')) 447 ba.screenmessage(ba.Lstr( 448 resource='getTicketsWindow.unavailableText'), 449 color=(1, 0, 0)) 450 451 def _do_purchase_check(self, 452 item: str, 453 is_ticket_purchase: bool = False) -> None: 454 from ba.internal import master_server_get 455 456 # Here we ping the server to ask if it's valid for us to 457 # purchase this. Better to fail now than after we've 458 # paid locally. 459 app = ba.app 460 master_server_get( 461 'bsAccountPurchaseCheck', 462 { 463 'item': item, 464 'platform': app.platform, 465 'subplatform': app.subplatform, 466 'version': app.version, 467 'buildNumber': app.build_number, 468 'purchaseType': 'ticket' if is_ticket_purchase else 'real' 469 }, 470 callback=ba.WeakCall(self._purchase_check_result, item, 471 is_ticket_purchase), 472 ) 473 474 def buy(self, item: str) -> None: 475 """Attempt to purchase the provided item.""" 476 from ba.internal import (get_available_sale_time, 477 get_store_item_name_translated) 478 from bastd.ui import account 479 from bastd.ui.confirm import ConfirmWindow 480 from bastd.ui import getcurrency 481 482 # Prevent pressing buy within a few seconds of the last press 483 # (gives the buttons time to disable themselves and whatnot). 484 curtime = ba.time(ba.TimeType.REAL) 485 if self._last_buy_time is not None and (curtime - 486 self._last_buy_time) < 2.0: 487 ba.playsound(ba.getsound('error')) 488 else: 489 if _ba.get_v1_account_state() != 'signed_in': 490 account.show_sign_in_prompt() 491 else: 492 self._last_buy_time = curtime 493 494 # Pro is an actual IAP; the rest are ticket purchases. 495 if item == 'pro': 496 ba.playsound(ba.getsound('click01')) 497 498 # Purchase either pro or pro_sale depending on whether 499 # there is a sale going on. 500 self._do_purchase_check('pro' if get_available_sale_time( 501 'extras') is None else 'pro_sale') 502 else: 503 price = _ba.get_v1_account_misc_read_val( 504 'price.' + item, None) 505 our_tickets = _ba.get_v1_account_ticket_count() 506 if price is not None and our_tickets < price: 507 ba.playsound(ba.getsound('error')) 508 getcurrency.show_get_tickets_prompt() 509 else: 510 511 def do_it() -> None: 512 self._do_purchase_check(item, 513 is_ticket_purchase=True) 514 515 ba.playsound(ba.getsound('swish')) 516 ConfirmWindow( 517 ba.Lstr(resource='store.purchaseConfirmText', 518 subs=[ 519 ('${ITEM}', 520 get_store_item_name_translated(item)) 521 ]), 522 width=400, 523 height=120, 524 action=do_it, 525 ok_text=ba.Lstr(resource='store.purchaseText', 526 fallback_resource='okText')) 527 528 def _print_already_own(self, charname: str) -> None: 529 ba.screenmessage(ba.Lstr(resource=self._r + '.alreadyOwnText', 530 subs=[('${NAME}', charname)]), 531 color=(1, 0, 0)) 532 ba.playsound(ba.getsound('error')) 533 534 def update_buttons(self) -> None: 535 """Update our buttons.""" 536 # pylint: disable=too-many-statements 537 # pylint: disable=too-many-branches 538 # pylint: disable=too-many-locals 539 from ba.internal import get_available_sale_time 540 from ba import SpecialChar 541 if not self._root_widget: 542 return 543 import datetime 544 sales_raw = _ba.get_v1_account_misc_read_val('sales', {}) 545 sales = {} 546 try: 547 # Look at the current set of sales; filter any with time remaining. 548 for sale_item, sale_info in list(sales_raw.items()): 549 to_end = (datetime.datetime.utcfromtimestamp(sale_info['e']) - 550 datetime.datetime.utcnow()).total_seconds() 551 if to_end > 0: 552 sales[sale_item] = { 553 'to_end': to_end, 554 'original_price': sale_info['op'] 555 } 556 except Exception: 557 ba.print_exception('Error parsing sales.') 558 559 assert self.button_infos is not None 560 for b_type, b_info in self.button_infos.items(): 561 562 if b_type in ['upgrades.pro', 'pro']: 563 purchased = _ba.app.accounts_v1.have_pro() 564 else: 565 purchased = _ba.get_purchased(b_type) 566 567 sale_opacity = 0.0 568 sale_title_text: str | ba.Lstr = '' 569 sale_time_text: str | ba.Lstr = '' 570 571 if purchased: 572 title_color = (0.8, 0.7, 0.9, 1.0) 573 color = (0.63, 0.55, 0.78) 574 extra_image_opacity = 0.5 575 call = ba.WeakCall(self._print_already_own, b_info['name']) 576 price_text = '' 577 price_text_left = '' 578 price_text_right = '' 579 show_purchase_check = True 580 description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4) 581 description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0) 582 price_color = (0.5, 1, 0.5, 0.3) 583 else: 584 title_color = (0.7, 0.9, 0.7, 1.0) 585 color = (0.4, 0.8, 0.1) 586 extra_image_opacity = 1.0 587 call = b_info['call'] if 'call' in b_info else None 588 if b_type in ['upgrades.pro', 'pro']: 589 sale_time = get_available_sale_time('extras') 590 if sale_time is not None: 591 priceraw = _ba.get_price('pro') 592 price_text_left = (priceraw 593 if priceraw is not None else '?') 594 priceraw = _ba.get_price('pro_sale') 595 price_text_right = (priceraw 596 if priceraw is not None else '?') 597 sale_opacity = 1.0 598 price_text = '' 599 sale_title_text = ba.Lstr(resource='store.saleText') 600 sale_time_text = ba.timestring( 601 sale_time, 602 centi=False, 603 timeformat=ba.TimeFormat.MILLISECONDS) 604 else: 605 priceraw = _ba.get_price('pro') 606 price_text = priceraw if priceraw is not None else '?' 607 price_text_left = '' 608 price_text_right = '' 609 else: 610 price = _ba.get_v1_account_misc_read_val( 611 'price.' + b_type, 0) 612 613 # Color the button differently if we cant afford this. 614 if _ba.get_v1_account_state() == 'signed_in': 615 if _ba.get_v1_account_ticket_count() < price: 616 color = (0.6, 0.61, 0.6) 617 price_text = ba.charstr(ba.SpecialChar.TICKET) + str( 618 _ba.get_v1_account_misc_read_val( 619 'price.' + b_type, '?')) 620 price_text_left = '' 621 price_text_right = '' 622 623 # TESTING: 624 if b_type in sales: 625 sale_opacity = 1.0 626 price_text_left = ba.charstr(SpecialChar.TICKET) + str( 627 sales[b_type]['original_price']) 628 price_text_right = price_text 629 price_text = '' 630 sale_title_text = ba.Lstr(resource='store.saleText') 631 sale_time_text = ba.timestring( 632 int(sales[b_type]['to_end'] * 1000), 633 centi=False, 634 timeformat=ba.TimeFormat.MILLISECONDS) 635 636 description_color = (0.5, 1.0, 0.5) 637 description_color2 = (0.3, 1.0, 1.0) 638 price_color = (0.2, 1, 0.2, 1.0) 639 show_purchase_check = False 640 641 if 'title_text' in b_info: 642 ba.textwidget(edit=b_info['title_text'], color=title_color) 643 if 'purchase_check' in b_info: 644 ba.imagewidget(edit=b_info['purchase_check'], 645 opacity=1.0 if show_purchase_check else 0.0) 646 if 'price_widget' in b_info: 647 ba.textwidget(edit=b_info['price_widget'], 648 text=price_text, 649 color=price_color) 650 if 'price_widget_left' in b_info: 651 ba.textwidget(edit=b_info['price_widget_left'], 652 text=price_text_left) 653 if 'price_widget_right' in b_info: 654 ba.textwidget(edit=b_info['price_widget_right'], 655 text=price_text_right) 656 if 'price_slash_widget' in b_info: 657 ba.imagewidget(edit=b_info['price_slash_widget'], 658 opacity=sale_opacity) 659 if 'sale_bg_widget' in b_info: 660 ba.imagewidget(edit=b_info['sale_bg_widget'], 661 opacity=sale_opacity) 662 if 'sale_title_widget' in b_info: 663 ba.textwidget(edit=b_info['sale_title_widget'], 664 text=sale_title_text) 665 if 'sale_time_widget' in b_info: 666 ba.textwidget(edit=b_info['sale_time_widget'], 667 text=sale_time_text) 668 if 'button' in b_info: 669 ba.buttonwidget(edit=b_info['button'], 670 color=color, 671 on_activate_call=call) 672 if 'extra_backings' in b_info: 673 for bck in b_info['extra_backings']: 674 ba.imagewidget(edit=bck, 675 color=color, 676 opacity=extra_image_opacity) 677 if 'extra_images' in b_info: 678 for img in b_info['extra_images']: 679 ba.imagewidget(edit=img, opacity=extra_image_opacity) 680 if 'extra_texts' in b_info: 681 for etxt in b_info['extra_texts']: 682 ba.textwidget(edit=etxt, color=description_color) 683 if 'extra_texts_2' in b_info: 684 for etxt in b_info['extra_texts_2']: 685 ba.textwidget(edit=etxt, color=description_color2) 686 if 'descriptionText' in b_info: 687 ba.textwidget(edit=b_info['descriptionText'], 688 color=description_color) 689 690 def _on_response(self, data: dict[str, Any] | None) -> None: 691 # pylint: disable=too-many-statements 692 693 # clear status text.. 694 if self._status_textwidget: 695 self._status_textwidget.delete() 696 self._status_textwidget_update_timer = None 697 698 if data is None: 699 self._status_textwidget = ba.textwidget( 700 parent=self._root_widget, 701 position=(self._width * 0.5, self._height * 0.5), 702 size=(0, 0), 703 scale=1.3, 704 transition_delay=0.1, 705 color=(1, 0.3, 0.3, 1.0), 706 h_align='center', 707 v_align='center', 708 text=ba.Lstr(resource=self._r + '.loadErrorText'), 709 maxwidth=self._scroll_width * 0.9) 710 else: 711 712 class _Store: 713 714 def __init__(self, store_window: StoreBrowserWindow, 715 sdata: dict[str, Any], width: float): 716 from ba.internal import (get_store_item_display_size, 717 get_store_layout) 718 self._store_window = store_window 719 self._width = width 720 store_data = get_store_layout() 721 self._tab = sdata['tab'] 722 self._sections = copy.deepcopy(store_data[sdata['tab']]) 723 self._height: float | None = None 724 725 uiscale = ba.app.ui.uiscale 726 727 # Pre-calc a few things and add them to store-data. 728 for section in self._sections: 729 if self._tab == 'characters': 730 dummy_name = 'characters.foo' 731 elif self._tab == 'extras': 732 dummy_name = 'pro' 733 elif self._tab == 'maps': 734 dummy_name = 'maps.foo' 735 elif self._tab == 'icons': 736 dummy_name = 'icons.foo' 737 else: 738 dummy_name = '' 739 section['button_size'] = get_store_item_display_size( 740 dummy_name) 741 section['v_spacing'] = (-17 if self._tab 742 == 'characters' else 0) 743 if 'title' not in section: 744 section['title'] = '' 745 section['x_offs'] = (130 if self._tab == 'extras' else 746 270 if self._tab == 'maps' else 0) 747 section['y_offs'] = ( 748 55 if (self._tab == 'extras' 749 and uiscale is ba.UIScale.SMALL) else 750 -20 if self._tab == 'icons' else 0) 751 752 def instantiate(self, scrollwidget: ba.Widget, 753 tab_button: ba.Widget) -> None: 754 """Create the store.""" 755 # pylint: disable=too-many-locals 756 # pylint: disable=too-many-branches 757 # pylint: disable=too-many-nested-blocks 758 from bastd.ui.store import item as storeitemui 759 title_spacing = 40 760 button_border = 20 761 button_spacing = 4 762 boffs_h = 40 763 self._height = 80.0 764 765 # Calc total height. 766 for i, section in enumerate(self._sections): 767 if section['title'] != '': 768 assert self._height is not None 769 self._height += title_spacing 770 b_width, b_height = section['button_size'] 771 b_column_count = int( 772 math.floor((self._width - boffs_h - 20) / 773 (b_width + button_spacing))) 774 b_row_count = int( 775 math.ceil( 776 float(len(section['items'])) / b_column_count)) 777 b_height_total = ( 778 2 * button_border + b_row_count * b_height + 779 (b_row_count - 1) * section['v_spacing']) 780 self._height += b_height_total 781 782 assert self._height is not None 783 cnt2 = ba.containerwidget(parent=scrollwidget, 784 scale=1.0, 785 size=(self._width, self._height), 786 background=False, 787 claims_left_right=True, 788 claims_tab=True, 789 selection_loops_to_parent=True) 790 v = self._height - 20 791 792 if self._tab == 'characters': 793 txt = ba.Lstr( 794 resource='store.howToSwitchCharactersText', 795 subs=[ 796 ('${SETTINGS}', 797 ba.Lstr( 798 resource='accountSettingsWindow.titleText' 799 )), 800 ('${PLAYER_PROFILES}', 801 ba.Lstr( 802 resource='playerProfilesWindow.titleText') 803 ) 804 ]) 805 ba.textwidget(parent=cnt2, 806 text=txt, 807 size=(0, 0), 808 position=(self._width * 0.5, 809 self._height - 28), 810 h_align='center', 811 v_align='center', 812 color=(0.7, 1, 0.7, 0.4), 813 scale=0.7, 814 shadow=0, 815 flatness=1.0, 816 maxwidth=700, 817 transition_delay=0.4) 818 elif self._tab == 'icons': 819 txt = ba.Lstr( 820 resource='store.howToUseIconsText', 821 subs=[ 822 ('${SETTINGS}', 823 ba.Lstr(resource='mainMenu.settingsText')), 824 ('${PLAYER_PROFILES}', 825 ba.Lstr( 826 resource='playerProfilesWindow.titleText') 827 ) 828 ]) 829 ba.textwidget(parent=cnt2, 830 text=txt, 831 size=(0, 0), 832 position=(self._width * 0.5, 833 self._height - 28), 834 h_align='center', 835 v_align='center', 836 color=(0.7, 1, 0.7, 0.4), 837 scale=0.7, 838 shadow=0, 839 flatness=1.0, 840 maxwidth=700, 841 transition_delay=0.4) 842 elif self._tab == 'maps': 843 assert self._width is not None 844 assert self._height is not None 845 txt = ba.Lstr(resource='store.howToUseMapsText') 846 ba.textwidget(parent=cnt2, 847 text=txt, 848 size=(0, 0), 849 position=(self._width * 0.5, 850 self._height - 28), 851 h_align='center', 852 v_align='center', 853 color=(0.7, 1, 0.7, 0.4), 854 scale=0.7, 855 shadow=0, 856 flatness=1.0, 857 maxwidth=700, 858 transition_delay=0.4) 859 860 prev_row_buttons: list | None = None 861 this_row_buttons = [] 862 863 delay = 0.3 864 for section in self._sections: 865 if section['title'] != '': 866 ba.textwidget( 867 parent=cnt2, 868 position=(60, v - title_spacing * 0.8), 869 size=(0, 0), 870 scale=1.0, 871 transition_delay=delay, 872 color=(0.7, 0.9, 0.7, 1), 873 h_align='left', 874 v_align='center', 875 text=ba.Lstr(resource=section['title']), 876 maxwidth=self._width * 0.7) 877 v -= title_spacing 878 delay = max(0.100, delay - 0.100) 879 v -= button_border 880 b_width, b_height = section['button_size'] 881 b_count = len(section['items']) 882 b_column_count = int( 883 math.floor((self._width - boffs_h - 20) / 884 (b_width + button_spacing))) 885 col = 0 886 item: dict[str, Any] 887 assert self._store_window.button_infos is not None 888 for i, item_name in enumerate(section['items']): 889 item = self._store_window.button_infos[ 890 item_name] = {} 891 item['call'] = ba.WeakCall(self._store_window.buy, 892 item_name) 893 if 'x_offs' in section: 894 boffs_h2 = section['x_offs'] 895 else: 896 boffs_h2 = 0 897 898 if 'y_offs' in section: 899 boffs_v2 = section['y_offs'] 900 else: 901 boffs_v2 = 0 902 b_pos = (boffs_h + boffs_h2 + 903 (b_width + button_spacing) * col, 904 v - b_height + boffs_v2) 905 storeitemui.instantiate_store_item_display( 906 item_name, 907 item, 908 parent_widget=cnt2, 909 b_pos=b_pos, 910 boffs_h=boffs_h, 911 b_width=b_width, 912 b_height=b_height, 913 boffs_h2=boffs_h2, 914 boffs_v2=boffs_v2, 915 delay=delay) 916 btn = item['button'] 917 delay = max(0.1, delay - 0.1) 918 this_row_buttons.append(btn) 919 920 # Wire this button to the equivalent in the 921 # previous row. 922 if prev_row_buttons is not None: 923 # pylint: disable=unsubscriptable-object 924 if len(prev_row_buttons) > col: 925 ba.widget(edit=btn, 926 up_widget=prev_row_buttons[col]) 927 ba.widget(edit=prev_row_buttons[col], 928 down_widget=btn) 929 930 # If we're the last button in our row, 931 # wire any in the previous row past 932 # our position to go to us if down is 933 # pressed. 934 if (col + 1 == b_column_count 935 or i == b_count - 1): 936 for b_prev in prev_row_buttons[col + 937 1:]: 938 ba.widget(edit=b_prev, 939 down_widget=btn) 940 else: 941 ba.widget(edit=btn, 942 up_widget=prev_row_buttons[-1]) 943 else: 944 ba.widget(edit=btn, up_widget=tab_button) 945 946 col += 1 947 if col == b_column_count or i == b_count - 1: 948 prev_row_buttons = this_row_buttons 949 this_row_buttons = [] 950 col = 0 951 v -= b_height 952 if i < b_count - 1: 953 v -= section['v_spacing'] 954 955 v -= button_border 956 957 # Set a timer to update these buttons periodically as long 958 # as we're alive (so if we buy one it will grey out, etc). 959 self._store_window.update_buttons_timer = ba.Timer( 960 0.5, 961 ba.WeakCall(self._store_window.update_buttons), 962 repeat=True, 963 timetype=ba.TimeType.REAL) 964 965 # Also update them immediately. 966 self._store_window.update_buttons() 967 968 if self._current_tab in (self.TabID.EXTRAS, self.TabID.MINIGAMES, 969 self.TabID.CHARACTERS, self.TabID.MAPS, 970 self.TabID.ICONS): 971 store = _Store(self, data, self._scroll_width) 972 assert self._scrollwidget is not None 973 store.instantiate( 974 scrollwidget=self._scrollwidget, 975 tab_button=self._tab_row.tabs[self._current_tab].button) 976 else: 977 cnt = ba.containerwidget(parent=self._scrollwidget, 978 scale=1.0, 979 size=(self._scroll_width, 980 self._scroll_height * 0.95), 981 background=False, 982 claims_left_right=True, 983 claims_tab=True, 984 selection_loops_to_parent=True) 985 self._status_textwidget = ba.textwidget( 986 parent=cnt, 987 position=(self._scroll_width * 0.5, 988 self._scroll_height * 0.5), 989 size=(0, 0), 990 scale=1.3, 991 transition_delay=0.1, 992 color=(1, 1, 0.3, 1.0), 993 h_align='center', 994 v_align='center', 995 text=ba.Lstr(resource=self._r + '.comingSoonText'), 996 maxwidth=self._scroll_width * 0.9) 997 998 def _save_state(self) -> None: 999 try: 1000 sel = self._root_widget.get_selected_child() 1001 selected_tab_ids = [ 1002 tab_id for tab_id, tab in self._tab_row.tabs.items() 1003 if sel == tab.button 1004 ] 1005 if sel == self._get_tickets_button: 1006 sel_name = 'GetTickets' 1007 elif sel == self._scrollwidget: 1008 sel_name = 'Scroll' 1009 elif sel == self._back_button: 1010 sel_name = 'Back' 1011 elif selected_tab_ids: 1012 assert len(selected_tab_ids) == 1 1013 sel_name = f'Tab:{selected_tab_ids[0].value}' 1014 else: 1015 raise ValueError(f'unrecognized selection \'{sel}\'') 1016 ba.app.ui.window_states[type(self)] = { 1017 'sel_name': sel_name, 1018 } 1019 except Exception: 1020 ba.print_exception(f'Error saving state for {self}.') 1021 1022 def _restore_state(self) -> None: 1023 from efro.util import enum_by_value 1024 try: 1025 sel: ba.Widget | None 1026 sel_name = ba.app.ui.window_states.get(type(self), 1027 {}).get('sel_name') 1028 assert isinstance(sel_name, (str, type(None))) 1029 1030 try: 1031 current_tab = enum_by_value(self.TabID, 1032 ba.app.config.get('Store Tab')) 1033 except ValueError: 1034 current_tab = self.TabID.CHARACTERS 1035 1036 if self._show_tab is not None: 1037 current_tab = self._show_tab 1038 if sel_name == 'GetTickets' and self._get_tickets_button: 1039 sel = self._get_tickets_button 1040 elif sel_name == 'Back': 1041 sel = self._back_button 1042 elif sel_name == 'Scroll': 1043 sel = self._scrollwidget 1044 elif isinstance(sel_name, str) and sel_name.startswith('Tab:'): 1045 try: 1046 sel_tab_id = enum_by_value(self.TabID, 1047 sel_name.split(':')[-1]) 1048 except ValueError: 1049 sel_tab_id = self.TabID.CHARACTERS 1050 sel = self._tab_row.tabs[sel_tab_id].button 1051 else: 1052 sel = self._tab_row.tabs[current_tab].button 1053 1054 # If we were requested to show a tab, select it too.. 1055 if (self._show_tab is not None 1056 and self._show_tab in self._tab_row.tabs): 1057 sel = self._tab_row.tabs[self._show_tab].button 1058 self._set_tab(current_tab) 1059 if sel is not None: 1060 ba.containerwidget(edit=self._root_widget, selected_child=sel) 1061 except Exception: 1062 ba.print_exception(f'Error restoring state for {self}.') 1063 1064 def _on_get_more_tickets_press(self) -> None: 1065 # pylint: disable=cyclic-import 1066 from bastd.ui.account import show_sign_in_prompt 1067 from bastd.ui.getcurrency import GetCurrencyWindow 1068 if _ba.get_v1_account_state() != 'signed_in': 1069 show_sign_in_prompt() 1070 return 1071 self._save_state() 1072 ba.containerwidget(edit=self._root_widget, transition='out_left') 1073 window = GetCurrencyWindow( 1074 from_modal_store=self._modal, 1075 store_back_location=self._back_location).get_root_widget() 1076 if not self._modal: 1077 ba.app.ui.set_main_menu_window(window) 1078 1079 def _back(self) -> None: 1080 # pylint: disable=cyclic-import 1081 from bastd.ui.coop.browser import CoopBrowserWindow 1082 from bastd.ui.mainmenu import MainMenuWindow 1083 self._save_state() 1084 ba.containerwidget(edit=self._root_widget, 1085 transition=self._transition_out) 1086 if not self._modal: 1087 if self._back_location == 'CoopBrowserWindow': 1088 ba.app.ui.set_main_menu_window( 1089 CoopBrowserWindow(transition='in_left').get_root_widget()) 1090 else: 1091 ba.app.ui.set_main_menu_window( 1092 MainMenuWindow(transition='in_left').get_root_widget()) 1093 if self._on_close_call is not None: 1094 self._on_close_call()
Window for browsing the store.
StoreBrowserWindow( transition: str = 'in_right', modal: bool = False, show_tab: bastd.ui.store.browser.StoreBrowserWindow.TabID | None = None, on_close_call: Optional[Callable[[], Any]] = None, back_location: str | None = None, origin_widget: _ba.Widget | None = None)
32 def __init__(self, 33 transition: str = 'in_right', 34 modal: bool = False, 35 show_tab: StoreBrowserWindow.TabID | None = None, 36 on_close_call: Callable[[], Any] | None = None, 37 back_location: str | None = None, 38 origin_widget: ba.Widget | None = None): 39 # pylint: disable=too-many-statements 40 # pylint: disable=too-many-locals 41 from bastd.ui.tabs import TabRow 42 from ba import SpecialChar 43 44 app = ba.app 45 uiscale = app.ui.uiscale 46 47 ba.set_analytics_screen('Store Window') 48 49 scale_origin: tuple[float, float] | None 50 51 # If they provided an origin-widget, scale up from that. 52 if origin_widget is not None: 53 self._transition_out = 'out_scale' 54 scale_origin = origin_widget.get_screen_space_center() 55 transition = 'in_scale' 56 else: 57 self._transition_out = 'out_right' 58 scale_origin = None 59 60 self.button_infos: dict[str, dict[str, Any]] | None = None 61 self.update_buttons_timer: ba.Timer | None = None 62 self._status_textwidget_update_timer = None 63 64 self._back_location = back_location 65 self._on_close_call = on_close_call 66 self._show_tab = show_tab 67 self._modal = modal 68 self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040 69 self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0 70 self._height = (578 if uiscale is ba.UIScale.SMALL else 71 645 if uiscale is ba.UIScale.MEDIUM else 800) 72 self._current_tab: StoreBrowserWindow.TabID | None = None 73 extra_top = 30 if uiscale is ba.UIScale.SMALL else 0 74 75 self._request: Any = None 76 self._r = 'store' 77 self._last_buy_time: float | None = None 78 79 super().__init__(root_widget=ba.containerwidget( 80 size=(self._width, self._height + extra_top), 81 transition=transition, 82 toolbar_visibility='menu_full', 83 scale=(1.3 if uiscale is ba.UIScale.SMALL else 84 0.9 if uiscale is ba.UIScale.MEDIUM else 0.8), 85 scale_origin_stack_offset=scale_origin, 86 stack_offset=((0, -5) if uiscale is ba.UIScale.SMALL else ( 87 0, 0) if uiscale is ba.UIScale.MEDIUM else (0, 0)))) 88 89 self._back_button = btn = ba.buttonwidget( 90 parent=self._root_widget, 91 position=(70 + x_inset, self._height - 74), 92 size=(140, 60), 93 scale=1.1, 94 autoselect=True, 95 label=ba.Lstr(resource='doneText' if self._modal else 'backText'), 96 button_type=None if self._modal else 'back', 97 on_activate_call=self._back) 98 ba.containerwidget(edit=self._root_widget, cancel_button=btn) 99 100 self._ticket_count_text: ba.Widget | None = None 101 self._get_tickets_button: ba.Widget | None = None 102 103 if ba.app.allow_ticket_purchases: 104 self._get_tickets_button = ba.buttonwidget( 105 parent=self._root_widget, 106 size=(210, 65), 107 on_activate_call=self._on_get_more_tickets_press, 108 autoselect=True, 109 scale=0.9, 110 text_scale=1.4, 111 left_widget=self._back_button, 112 color=(0.7, 0.5, 0.85), 113 textcolor=(0.2, 1.0, 0.2), 114 label=ba.Lstr(resource='getTicketsWindow.titleText')) 115 else: 116 self._ticket_count_text = ba.textwidget(parent=self._root_widget, 117 size=(210, 64), 118 color=(0.2, 1.0, 0.2), 119 h_align='center', 120 v_align='center') 121 122 # Move this dynamically to keep it out of the way of the party icon. 123 self._update_get_tickets_button_pos() 124 self._get_ticket_pos_update_timer = ba.Timer( 125 1.0, 126 ba.WeakCall(self._update_get_tickets_button_pos), 127 repeat=True, 128 timetype=ba.TimeType.REAL) 129 if self._get_tickets_button: 130 ba.widget(edit=self._back_button, 131 right_widget=self._get_tickets_button) 132 self._ticket_text_update_timer = ba.Timer( 133 1.0, 134 ba.WeakCall(self._update_tickets_text), 135 timetype=ba.TimeType.REAL, 136 repeat=True) 137 self._update_tickets_text() 138 139 app = ba.app 140 if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore': 141 ba.buttonwidget( 142 parent=self._root_widget, 143 position=(self._width * 0.5 - 70, 16), 144 size=(230, 50), 145 scale=0.65, 146 on_activate_call=ba.WeakCall(self._restore_purchases), 147 color=(0.35, 0.3, 0.4), 148 selectable=False, 149 textcolor=(0.55, 0.5, 0.6), 150 label=ba.Lstr( 151 resource='getTicketsWindow.restorePurchasesText')) 152 153 ba.textwidget(parent=self._root_widget, 154 position=(self._width * 0.5, self._height - 44), 155 size=(0, 0), 156 color=app.ui.title_color, 157 scale=1.5, 158 h_align='center', 159 v_align='center', 160 text=ba.Lstr(resource='storeText'), 161 maxwidth=420) 162 163 if not self._modal: 164 ba.buttonwidget(edit=self._back_button, 165 button_type='backSmall', 166 size=(60, 60), 167 label=ba.charstr(SpecialChar.BACK)) 168 169 scroll_buffer_h = 130 + 2 * x_inset 170 tab_buffer_h = 250 + 2 * x_inset 171 172 tabs_def = [ 173 (self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')), 174 (self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')), 175 (self.TabID.MINIGAMES, 176 ba.Lstr(resource=self._r + '.miniGamesText')), 177 (self.TabID.CHARACTERS, 178 ba.Lstr(resource=self._r + '.charactersText')), 179 (self.TabID.ICONS, ba.Lstr(resource=self._r + '.iconsText')), 180 ] 181 182 self._tab_row = TabRow(self._root_widget, 183 tabs_def, 184 pos=(tab_buffer_h * 0.5, self._height - 130), 185 size=(self._width - tab_buffer_h, 50), 186 on_select_call=self._set_tab) 187 188 self._purchasable_count_widgets: dict[StoreBrowserWindow.TabID, 189 dict[str, Any]] = {} 190 191 # Create our purchasable-items tags and have them update over time. 192 for tab_id, tab in self._tab_row.tabs.items(): 193 pos = tab.position 194 size = tab.size 195 button = tab.button 196 rad = 10 197 center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1]) 198 img = ba.imagewidget(parent=self._root_widget, 199 position=(center[0] - rad * 1.04, 200 center[1] - rad * 1.15), 201 size=(rad * 2.2, rad * 2.2), 202 texture=ba.gettexture('circleShadow'), 203 color=(1, 0, 0)) 204 txt = ba.textwidget(parent=self._root_widget, 205 position=center, 206 size=(0, 0), 207 h_align='center', 208 v_align='center', 209 maxwidth=1.4 * rad, 210 scale=0.6, 211 shadow=1.0, 212 flatness=1.0) 213 rad = 20 214 sale_img = ba.imagewidget(parent=self._root_widget, 215 position=(center[0] - rad, 216 center[1] - rad), 217 size=(rad * 2, rad * 2), 218 draw_controller=button, 219 texture=ba.gettexture('circleZigZag'), 220 color=(0.5, 0, 1.0)) 221 sale_title_text = ba.textwidget(parent=self._root_widget, 222 position=(center[0], 223 center[1] + 0.24 * rad), 224 size=(0, 0), 225 h_align='center', 226 v_align='center', 227 draw_controller=button, 228 maxwidth=1.4 * rad, 229 scale=0.6, 230 shadow=0.0, 231 flatness=1.0, 232 color=(0, 1, 0)) 233 sale_time_text = ba.textwidget(parent=self._root_widget, 234 position=(center[0], 235 center[1] - 0.29 * rad), 236 size=(0, 0), 237 h_align='center', 238 v_align='center', 239 draw_controller=button, 240 maxwidth=1.4 * rad, 241 scale=0.4, 242 shadow=0.0, 243 flatness=1.0, 244 color=(0, 1, 0)) 245 self._purchasable_count_widgets[tab_id] = { 246 'img': img, 247 'text': txt, 248 'sale_img': sale_img, 249 'sale_title_text': sale_title_text, 250 'sale_time_text': sale_time_text 251 } 252 self._tab_update_timer = ba.Timer(1.0, 253 ba.WeakCall(self._update_tabs), 254 timetype=ba.TimeType.REAL, 255 repeat=True) 256 self._update_tabs() 257 258 if self._get_tickets_button: 259 last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button 260 ba.widget(edit=self._get_tickets_button, 261 down_widget=last_tab_button) 262 ba.widget(edit=last_tab_button, 263 up_widget=self._get_tickets_button, 264 right_widget=self._get_tickets_button) 265 266 self._scroll_width = self._width - scroll_buffer_h 267 self._scroll_height = self._height - 180 268 269 self._scrollwidget: ba.Widget | None = None 270 self._status_textwidget: ba.Widget | None = None 271 self._restore_state()
def
buy(self, item: str) -> None:
474 def buy(self, item: str) -> None: 475 """Attempt to purchase the provided item.""" 476 from ba.internal import (get_available_sale_time, 477 get_store_item_name_translated) 478 from bastd.ui import account 479 from bastd.ui.confirm import ConfirmWindow 480 from bastd.ui import getcurrency 481 482 # Prevent pressing buy within a few seconds of the last press 483 # (gives the buttons time to disable themselves and whatnot). 484 curtime = ba.time(ba.TimeType.REAL) 485 if self._last_buy_time is not None and (curtime - 486 self._last_buy_time) < 2.0: 487 ba.playsound(ba.getsound('error')) 488 else: 489 if _ba.get_v1_account_state() != 'signed_in': 490 account.show_sign_in_prompt() 491 else: 492 self._last_buy_time = curtime 493 494 # Pro is an actual IAP; the rest are ticket purchases. 495 if item == 'pro': 496 ba.playsound(ba.getsound('click01')) 497 498 # Purchase either pro or pro_sale depending on whether 499 # there is a sale going on. 500 self._do_purchase_check('pro' if get_available_sale_time( 501 'extras') is None else 'pro_sale') 502 else: 503 price = _ba.get_v1_account_misc_read_val( 504 'price.' + item, None) 505 our_tickets = _ba.get_v1_account_ticket_count() 506 if price is not None and our_tickets < price: 507 ba.playsound(ba.getsound('error')) 508 getcurrency.show_get_tickets_prompt() 509 else: 510 511 def do_it() -> None: 512 self._do_purchase_check(item, 513 is_ticket_purchase=True) 514 515 ba.playsound(ba.getsound('swish')) 516 ConfirmWindow( 517 ba.Lstr(resource='store.purchaseConfirmText', 518 subs=[ 519 ('${ITEM}', 520 get_store_item_name_translated(item)) 521 ]), 522 width=400, 523 height=120, 524 action=do_it, 525 ok_text=ba.Lstr(resource='store.purchaseText', 526 fallback_resource='okText'))
Attempt to purchase the provided item.
Inherited Members
- ba.ui.Window
- get_root_widget
class
StoreBrowserWindow.TabID(enum.Enum):
24 class TabID(Enum): 25 """Our available tab types.""" 26 EXTRAS = 'extras' 27 MAPS = 'maps' 28 MINIGAMES = 'minigames' 29 CHARACTERS = 'characters' 30 ICONS = 'icons'
Our available tab types.
Inherited Members
- enum.Enum
- name
- value