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