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