bastd.ui.specialoffer
UI for presenting sales/etc.
1# Released under the MIT License. See LICENSE for details. 2# 3"""UI for presenting sales/etc.""" 4 5from __future__ import annotations 6 7import copy 8from typing import TYPE_CHECKING 9 10import ba 11import ba.internal 12 13if TYPE_CHECKING: 14 from typing import Any 15 16 17class SpecialOfferWindow(ba.Window): 18 """Window for presenting sales/etc.""" 19 20 def __init__(self, offer: dict[str, Any], transition: str = 'in_right'): 21 # pylint: disable=too-many-statements 22 # pylint: disable=too-many-branches 23 # pylint: disable=too-many-locals 24 from ba.internal import get_store_item_display_size, get_clean_price 25 from ba import SpecialChar 26 from bastd.ui.store import item as storeitemui 27 28 self._cancel_delay = offer.get('cancelDelay', 0) 29 30 # First thing: if we're offering pro or an IAP, see if we have a 31 # price for it. 32 # If not, abort and go into zombie mode (the user should never see 33 # us that way). 34 35 real_price: str | None 36 37 # Misnomer: 'pro' actually means offer 'pro_sale'. 38 if offer['item'] in ['pro', 'pro_fullprice']: 39 real_price = ba.internal.get_price( 40 'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale' 41 ) 42 if real_price is None and ba.app.debug_build: 43 print('NOTE: Faking prices for debug build.') 44 real_price = '$1.23' 45 zombie = real_price is None 46 elif isinstance(offer['price'], str): 47 # (a string price implies IAP id) 48 real_price = ba.internal.get_price(offer['price']) 49 if real_price is None and ba.app.debug_build: 50 print('NOTE: Faking price for debug build.') 51 real_price = '$1.23' 52 zombie = real_price is None 53 else: 54 real_price = None 55 zombie = False 56 if real_price is None: 57 real_price = '?' 58 59 if offer['item'] in ['pro', 'pro_fullprice']: 60 self._offer_item = 'pro' 61 else: 62 self._offer_item = offer['item'] 63 64 # If we wanted a real price but didn't find one, go zombie. 65 if zombie: 66 return 67 68 # This can pop up suddenly, so lets block input for 1 second. 69 ba.internal.lock_all_input() 70 ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL) 71 ba.playsound(ba.getsound('ding')) 72 ba.timer( 73 0.3, 74 lambda: ba.playsound(ba.getsound('ooh')), 75 timetype=ba.TimeType.REAL, 76 ) 77 self._offer = copy.deepcopy(offer) 78 self._width = 580 79 self._height = 590 80 uiscale = ba.app.ui.uiscale 81 super().__init__( 82 root_widget=ba.containerwidget( 83 size=(self._width, self._height), 84 transition=transition, 85 scale=( 86 1.2 87 if uiscale is ba.UIScale.SMALL 88 else 1.15 89 if uiscale is ba.UIScale.MEDIUM 90 else 1.0 91 ), 92 stack_offset=(0, -15) 93 if uiscale is ba.UIScale.SMALL 94 else (0, 0), 95 ) 96 ) 97 self._is_bundle_sale = False 98 try: 99 if offer['item'] in ['pro', 'pro_fullprice']: 100 original_price_str = ba.internal.get_price('pro') 101 if original_price_str is None: 102 original_price_str = '?' 103 new_price_str = ba.internal.get_price('pro_sale') 104 if new_price_str is None: 105 new_price_str = '?' 106 percent_off_text = '' 107 else: 108 # If the offer includes bonus tickets, it's a bundle-sale. 109 if ( 110 'bonusTickets' in offer 111 and offer['bonusTickets'] is not None 112 ): 113 self._is_bundle_sale = True 114 original_price = ba.internal.get_v1_account_misc_read_val( 115 'price.' + self._offer_item, 9999 116 ) 117 118 # For pure ticket prices we can show a percent-off. 119 if isinstance(offer['price'], int): 120 new_price = offer['price'] 121 tchar = ba.charstr(SpecialChar.TICKET) 122 original_price_str = tchar + str(original_price) 123 new_price_str = tchar + str(new_price) 124 percent_off = int( 125 round( 126 100.0 - (float(new_price) / original_price) * 100.0 127 ) 128 ) 129 percent_off_text = ' ' + ba.Lstr( 130 resource='store.salePercentText' 131 ).evaluate().replace('${PERCENT}', str(percent_off)) 132 else: 133 original_price_str = new_price_str = '?' 134 percent_off_text = '' 135 136 except Exception: 137 print(f'Offer: {offer}') 138 ba.print_exception('Error setting up special-offer') 139 original_price_str = new_price_str = '?' 140 percent_off_text = '' 141 142 # If its a bundle sale, change the title. 143 if self._is_bundle_sale: 144 sale_text = ba.Lstr( 145 resource='store.saleBundleText', 146 fallback_resource='store.saleText', 147 ).evaluate() 148 else: 149 # For full pro we say 'Upgrade?' since its not really a sale. 150 if offer['item'] == 'pro_fullprice': 151 sale_text = ba.Lstr( 152 resource='store.upgradeQuestionText', 153 fallback_resource='store.saleExclaimText', 154 ).evaluate() 155 else: 156 sale_text = ba.Lstr( 157 resource='store.saleExclaimText', 158 fallback_resource='store.saleText', 159 ).evaluate() 160 161 self._title_text = ba.textwidget( 162 parent=self._root_widget, 163 position=(self._width * 0.5, self._height - 40), 164 size=(0, 0), 165 text=sale_text 166 + ( 167 (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate()) 168 if self._offer['oneTimeOnly'] 169 else '' 170 ) 171 + percent_off_text, 172 h_align='center', 173 v_align='center', 174 maxwidth=self._width * 0.9 - 220, 175 scale=1.4, 176 color=(0.3, 1, 0.3), 177 ) 178 179 self._flash_on = False 180 self._flashing_timer: ba.Timer | None = ba.Timer( 181 0.05, 182 ba.WeakCall(self._flash_cycle), 183 repeat=True, 184 timetype=ba.TimeType.REAL, 185 ) 186 ba.timer( 187 0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL 188 ) 189 190 size = get_store_item_display_size(self._offer_item) 191 display: dict[str, Any] = {} 192 storeitemui.instantiate_store_item_display( 193 self._offer_item, 194 display, 195 parent_widget=self._root_widget, 196 b_pos=( 197 self._width * 0.5 198 - size[0] * 0.5 199 + 10 200 - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0), 201 self._height * 0.5 202 - size[1] * 0.5 203 + 20 204 + (20 if self._is_bundle_sale else 0), 205 ), 206 b_width=size[0], 207 b_height=size[1], 208 button=not self._is_bundle_sale, 209 ) 210 211 # Wire up the parts we need. 212 if self._is_bundle_sale: 213 self._plus_text = ba.textwidget( 214 parent=self._root_widget, 215 position=(self._width * 0.5, self._height * 0.5 + 50), 216 size=(0, 0), 217 text='+', 218 h_align='center', 219 v_align='center', 220 maxwidth=self._width * 0.9, 221 scale=1.4, 222 color=(0.5, 0.5, 0.5), 223 ) 224 self._plus_tickets = ba.textwidget( 225 parent=self._root_widget, 226 position=(self._width * 0.5 + 120, self._height * 0.5 + 50), 227 size=(0, 0), 228 text=ba.charstr(SpecialChar.TICKET_BACKING) 229 + str(offer['bonusTickets']), 230 h_align='center', 231 v_align='center', 232 maxwidth=self._width * 0.9, 233 scale=2.5, 234 color=(0.2, 1, 0.2), 235 ) 236 self._price_text = ba.textwidget( 237 parent=self._root_widget, 238 position=(self._width * 0.5, 150), 239 size=(0, 0), 240 text=real_price, 241 h_align='center', 242 v_align='center', 243 maxwidth=self._width * 0.9, 244 scale=1.4, 245 color=(0.2, 1, 0.2), 246 ) 247 # Total-value if they supplied it. 248 total_worth_item = offer.get('valueItem', None) 249 if total_worth_item is not None: 250 price = ba.internal.get_price(total_worth_item) 251 total_worth_price = ( 252 get_clean_price(price) if price is not None else None 253 ) 254 if total_worth_price is not None: 255 total_worth_text = ba.Lstr( 256 resource='store.totalWorthText', 257 subs=[('${TOTAL_WORTH}', total_worth_price)], 258 ) 259 self._total_worth_text = ba.textwidget( 260 parent=self._root_widget, 261 text=total_worth_text, 262 position=(self._width * 0.5, 210), 263 scale=0.9, 264 maxwidth=self._width * 0.7, 265 size=(0, 0), 266 h_align='center', 267 v_align='center', 268 shadow=1.0, 269 flatness=1.0, 270 color=(0.3, 1, 1), 271 ) 272 273 elif offer['item'] == 'pro_fullprice': 274 # for full-price pro we simply show full price 275 ba.textwidget(edit=display['price_widget'], text=real_price) 276 ba.buttonwidget( 277 edit=display['button'], on_activate_call=self._purchase 278 ) 279 else: 280 # Show old/new prices otherwise (for pro sale). 281 ba.buttonwidget( 282 edit=display['button'], on_activate_call=self._purchase 283 ) 284 ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0) 285 ba.textwidget( 286 edit=display['price_widget_left'], text=original_price_str 287 ) 288 ba.textwidget( 289 edit=display['price_widget_right'], text=new_price_str 290 ) 291 292 # Add ticket button only if this is ticket-purchasable. 293 if isinstance(offer.get('price'), int): 294 self._get_tickets_button = ba.buttonwidget( 295 parent=self._root_widget, 296 position=(self._width - 125, self._height - 68), 297 size=(90, 55), 298 scale=1.0, 299 button_type='square', 300 color=(0.7, 0.5, 0.85), 301 textcolor=(0.2, 1, 0.2), 302 autoselect=True, 303 label=ba.Lstr(resource='getTicketsWindow.titleText'), 304 on_activate_call=self._on_get_more_tickets_press, 305 ) 306 307 self._ticket_text_update_timer = ba.Timer( 308 1.0, 309 ba.WeakCall(self._update_tickets_text), 310 timetype=ba.TimeType.REAL, 311 repeat=True, 312 ) 313 self._update_tickets_text() 314 315 self._update_timer = ba.Timer( 316 1.0, 317 ba.WeakCall(self._update), 318 timetype=ba.TimeType.REAL, 319 repeat=True, 320 ) 321 322 self._cancel_button = ba.buttonwidget( 323 parent=self._root_widget, 324 position=(50, 40) 325 if self._is_bundle_sale 326 else (self._width * 0.5 - 75, 40), 327 size=(150, 60), 328 scale=1.0, 329 on_activate_call=self._cancel, 330 autoselect=True, 331 label=ba.Lstr(resource='noThanksText'), 332 ) 333 self._cancel_countdown_text = ba.textwidget( 334 parent=self._root_widget, 335 text='', 336 position=(50 + 150 + 20, 40 + 27) 337 if self._is_bundle_sale 338 else (self._width * 0.5 - 75 + 150 + 20, 40 + 27), 339 scale=1.1, 340 size=(0, 0), 341 h_align='left', 342 v_align='center', 343 shadow=1.0, 344 flatness=1.0, 345 color=(0.6, 0.5, 0.5), 346 ) 347 self._update_cancel_button_graphics() 348 349 if self._is_bundle_sale: 350 self._purchase_button = ba.buttonwidget( 351 parent=self._root_widget, 352 position=(self._width - 200, 40), 353 size=(150, 60), 354 scale=1.0, 355 on_activate_call=self._purchase, 356 autoselect=True, 357 label=ba.Lstr(resource='store.purchaseText'), 358 ) 359 360 ba.containerwidget( 361 edit=self._root_widget, 362 cancel_button=self._cancel_button, 363 start_button=self._purchase_button 364 if self._is_bundle_sale 365 else None, 366 selected_child=self._purchase_button 367 if self._is_bundle_sale 368 else display['button'], 369 ) 370 371 def _stop_flashing(self) -> None: 372 self._flashing_timer = None 373 ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3)) 374 375 def _flash_cycle(self) -> None: 376 if not self._root_widget: 377 return 378 self._flash_on = not self._flash_on 379 ba.textwidget( 380 edit=self._title_text, 381 color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0), 382 ) 383 384 def _update_cancel_button_graphics(self) -> None: 385 ba.buttonwidget( 386 edit=self._cancel_button, 387 color=(0.5, 0.5, 0.5) 388 if self._cancel_delay > 0 389 else (0.7, 0.4, 0.34), 390 textcolor=(0.5, 0.5, 0.5) 391 if self._cancel_delay > 0 392 else (0.9, 0.9, 1.0), 393 ) 394 ba.textwidget( 395 edit=self._cancel_countdown_text, 396 text=str(self._cancel_delay) if self._cancel_delay > 0 else '', 397 ) 398 399 def _update(self) -> None: 400 401 # If we've got seconds left on our countdown, update it. 402 if self._cancel_delay > 0: 403 self._cancel_delay = max(0, self._cancel_delay - 1) 404 self._update_cancel_button_graphics() 405 406 can_die = False 407 408 # We go away if we see that our target item is owned. 409 if self._offer_item == 'pro': 410 if ba.app.accounts_v1.have_pro(): 411 can_die = True 412 else: 413 if ba.internal.get_purchased(self._offer_item): 414 can_die = True 415 416 if can_die: 417 self._transition_out('out_left') 418 419 def _transition_out(self, transition: str = 'out_left') -> None: 420 # Also clear any pending-special-offer we've stored at this point. 421 cfg = ba.app.config 422 if 'pendingSpecialOffer' in cfg: 423 del cfg['pendingSpecialOffer'] 424 cfg.commit() 425 426 ba.containerwidget(edit=self._root_widget, transition=transition) 427 428 def _update_tickets_text(self) -> None: 429 from ba import SpecialChar 430 431 if not self._root_widget: 432 return 433 sval: str | ba.Lstr 434 if ba.internal.get_v1_account_state() == 'signed_in': 435 sval = ba.charstr(SpecialChar.TICKET) + str( 436 ba.internal.get_v1_account_ticket_count() 437 ) 438 else: 439 sval = ba.Lstr(resource='getTicketsWindow.titleText') 440 ba.buttonwidget(edit=self._get_tickets_button, label=sval) 441 442 def _on_get_more_tickets_press(self) -> None: 443 from bastd.ui import account 444 from bastd.ui import getcurrency 445 446 if ba.internal.get_v1_account_state() != 'signed_in': 447 account.show_sign_in_prompt() 448 return 449 getcurrency.GetCurrencyWindow(modal=True).get_root_widget() 450 451 def _purchase(self) -> None: 452 from ba.internal import get_store_item_name_translated 453 from bastd.ui import getcurrency 454 from bastd.ui import confirm 455 456 if self._offer['item'] == 'pro': 457 ba.internal.purchase('pro_sale') 458 elif self._offer['item'] == 'pro_fullprice': 459 ba.internal.purchase('pro') 460 elif self._is_bundle_sale: 461 # With bundle sales, the price is the name of the IAP. 462 ba.internal.purchase(self._offer['price']) 463 else: 464 ticket_count: int | None 465 try: 466 ticket_count = ba.internal.get_v1_account_ticket_count() 467 except Exception: 468 ticket_count = None 469 if ticket_count is not None and ticket_count < self._offer['price']: 470 getcurrency.show_get_tickets_prompt() 471 ba.playsound(ba.getsound('error')) 472 return 473 474 def do_it() -> None: 475 ba.internal.in_game_purchase( 476 'offer:' + str(self._offer['id']), self._offer['price'] 477 ) 478 479 ba.playsound(ba.getsound('swish')) 480 confirm.ConfirmWindow( 481 ba.Lstr( 482 resource='store.purchaseConfirmText', 483 subs=[ 484 ( 485 '${ITEM}', 486 get_store_item_name_translated(self._offer['item']), 487 ) 488 ], 489 ), 490 width=400, 491 height=120, 492 action=do_it, 493 ok_text=ba.Lstr( 494 resource='store.purchaseText', fallback_resource='okText' 495 ), 496 ) 497 498 def _cancel(self) -> None: 499 if self._cancel_delay > 0: 500 ba.playsound(ba.getsound('error')) 501 return 502 self._transition_out('out_right') 503 504 505def show_offer() -> bool: 506 """(internal)""" 507 try: 508 from bastd.ui import feedback 509 510 app = ba.app 511 512 # Space things out a bit so we don't hit the poor user with an ad and 513 # then an in-game offer. 514 has_been_long_enough_since_ad = True 515 if app.ads.last_ad_completion_time is not None and ( 516 ba.time(ba.TimeType.REAL) - app.ads.last_ad_completion_time < 30.0 517 ): 518 has_been_long_enough_since_ad = False 519 520 if app.special_offer is not None and has_been_long_enough_since_ad: 521 522 # Special case: for pro offers, store this in our prefs so we 523 # can re-show it if the user kills us (set phasers to 'NAG'!!!). 524 if app.special_offer.get('item') == 'pro_fullprice': 525 cfg = app.config 526 cfg['pendingSpecialOffer'] = { 527 'a': ba.internal.get_public_login_id(), 528 'o': app.special_offer, 529 } 530 cfg.commit() 531 532 with ba.Context('ui'): 533 if app.special_offer['item'] == 'rating': 534 feedback.ask_for_rating() 535 else: 536 SpecialOfferWindow(app.special_offer) 537 538 app.special_offer = None 539 return True 540 except Exception: 541 ba.print_exception('Error showing offer.') 542 543 return False
class
SpecialOfferWindow(ba.ui.Window):
18class SpecialOfferWindow(ba.Window): 19 """Window for presenting sales/etc.""" 20 21 def __init__(self, offer: dict[str, Any], transition: str = 'in_right'): 22 # pylint: disable=too-many-statements 23 # pylint: disable=too-many-branches 24 # pylint: disable=too-many-locals 25 from ba.internal import get_store_item_display_size, get_clean_price 26 from ba import SpecialChar 27 from bastd.ui.store import item as storeitemui 28 29 self._cancel_delay = offer.get('cancelDelay', 0) 30 31 # First thing: if we're offering pro or an IAP, see if we have a 32 # price for it. 33 # If not, abort and go into zombie mode (the user should never see 34 # us that way). 35 36 real_price: str | None 37 38 # Misnomer: 'pro' actually means offer 'pro_sale'. 39 if offer['item'] in ['pro', 'pro_fullprice']: 40 real_price = ba.internal.get_price( 41 'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale' 42 ) 43 if real_price is None and ba.app.debug_build: 44 print('NOTE: Faking prices for debug build.') 45 real_price = '$1.23' 46 zombie = real_price is None 47 elif isinstance(offer['price'], str): 48 # (a string price implies IAP id) 49 real_price = ba.internal.get_price(offer['price']) 50 if real_price is None and ba.app.debug_build: 51 print('NOTE: Faking price for debug build.') 52 real_price = '$1.23' 53 zombie = real_price is None 54 else: 55 real_price = None 56 zombie = False 57 if real_price is None: 58 real_price = '?' 59 60 if offer['item'] in ['pro', 'pro_fullprice']: 61 self._offer_item = 'pro' 62 else: 63 self._offer_item = offer['item'] 64 65 # If we wanted a real price but didn't find one, go zombie. 66 if zombie: 67 return 68 69 # This can pop up suddenly, so lets block input for 1 second. 70 ba.internal.lock_all_input() 71 ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL) 72 ba.playsound(ba.getsound('ding')) 73 ba.timer( 74 0.3, 75 lambda: ba.playsound(ba.getsound('ooh')), 76 timetype=ba.TimeType.REAL, 77 ) 78 self._offer = copy.deepcopy(offer) 79 self._width = 580 80 self._height = 590 81 uiscale = ba.app.ui.uiscale 82 super().__init__( 83 root_widget=ba.containerwidget( 84 size=(self._width, self._height), 85 transition=transition, 86 scale=( 87 1.2 88 if uiscale is ba.UIScale.SMALL 89 else 1.15 90 if uiscale is ba.UIScale.MEDIUM 91 else 1.0 92 ), 93 stack_offset=(0, -15) 94 if uiscale is ba.UIScale.SMALL 95 else (0, 0), 96 ) 97 ) 98 self._is_bundle_sale = False 99 try: 100 if offer['item'] in ['pro', 'pro_fullprice']: 101 original_price_str = ba.internal.get_price('pro') 102 if original_price_str is None: 103 original_price_str = '?' 104 new_price_str = ba.internal.get_price('pro_sale') 105 if new_price_str is None: 106 new_price_str = '?' 107 percent_off_text = '' 108 else: 109 # If the offer includes bonus tickets, it's a bundle-sale. 110 if ( 111 'bonusTickets' in offer 112 and offer['bonusTickets'] is not None 113 ): 114 self._is_bundle_sale = True 115 original_price = ba.internal.get_v1_account_misc_read_val( 116 'price.' + self._offer_item, 9999 117 ) 118 119 # For pure ticket prices we can show a percent-off. 120 if isinstance(offer['price'], int): 121 new_price = offer['price'] 122 tchar = ba.charstr(SpecialChar.TICKET) 123 original_price_str = tchar + str(original_price) 124 new_price_str = tchar + str(new_price) 125 percent_off = int( 126 round( 127 100.0 - (float(new_price) / original_price) * 100.0 128 ) 129 ) 130 percent_off_text = ' ' + ba.Lstr( 131 resource='store.salePercentText' 132 ).evaluate().replace('${PERCENT}', str(percent_off)) 133 else: 134 original_price_str = new_price_str = '?' 135 percent_off_text = '' 136 137 except Exception: 138 print(f'Offer: {offer}') 139 ba.print_exception('Error setting up special-offer') 140 original_price_str = new_price_str = '?' 141 percent_off_text = '' 142 143 # If its a bundle sale, change the title. 144 if self._is_bundle_sale: 145 sale_text = ba.Lstr( 146 resource='store.saleBundleText', 147 fallback_resource='store.saleText', 148 ).evaluate() 149 else: 150 # For full pro we say 'Upgrade?' since its not really a sale. 151 if offer['item'] == 'pro_fullprice': 152 sale_text = ba.Lstr( 153 resource='store.upgradeQuestionText', 154 fallback_resource='store.saleExclaimText', 155 ).evaluate() 156 else: 157 sale_text = ba.Lstr( 158 resource='store.saleExclaimText', 159 fallback_resource='store.saleText', 160 ).evaluate() 161 162 self._title_text = ba.textwidget( 163 parent=self._root_widget, 164 position=(self._width * 0.5, self._height - 40), 165 size=(0, 0), 166 text=sale_text 167 + ( 168 (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate()) 169 if self._offer['oneTimeOnly'] 170 else '' 171 ) 172 + percent_off_text, 173 h_align='center', 174 v_align='center', 175 maxwidth=self._width * 0.9 - 220, 176 scale=1.4, 177 color=(0.3, 1, 0.3), 178 ) 179 180 self._flash_on = False 181 self._flashing_timer: ba.Timer | None = ba.Timer( 182 0.05, 183 ba.WeakCall(self._flash_cycle), 184 repeat=True, 185 timetype=ba.TimeType.REAL, 186 ) 187 ba.timer( 188 0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL 189 ) 190 191 size = get_store_item_display_size(self._offer_item) 192 display: dict[str, Any] = {} 193 storeitemui.instantiate_store_item_display( 194 self._offer_item, 195 display, 196 parent_widget=self._root_widget, 197 b_pos=( 198 self._width * 0.5 199 - size[0] * 0.5 200 + 10 201 - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0), 202 self._height * 0.5 203 - size[1] * 0.5 204 + 20 205 + (20 if self._is_bundle_sale else 0), 206 ), 207 b_width=size[0], 208 b_height=size[1], 209 button=not self._is_bundle_sale, 210 ) 211 212 # Wire up the parts we need. 213 if self._is_bundle_sale: 214 self._plus_text = ba.textwidget( 215 parent=self._root_widget, 216 position=(self._width * 0.5, self._height * 0.5 + 50), 217 size=(0, 0), 218 text='+', 219 h_align='center', 220 v_align='center', 221 maxwidth=self._width * 0.9, 222 scale=1.4, 223 color=(0.5, 0.5, 0.5), 224 ) 225 self._plus_tickets = ba.textwidget( 226 parent=self._root_widget, 227 position=(self._width * 0.5 + 120, self._height * 0.5 + 50), 228 size=(0, 0), 229 text=ba.charstr(SpecialChar.TICKET_BACKING) 230 + str(offer['bonusTickets']), 231 h_align='center', 232 v_align='center', 233 maxwidth=self._width * 0.9, 234 scale=2.5, 235 color=(0.2, 1, 0.2), 236 ) 237 self._price_text = ba.textwidget( 238 parent=self._root_widget, 239 position=(self._width * 0.5, 150), 240 size=(0, 0), 241 text=real_price, 242 h_align='center', 243 v_align='center', 244 maxwidth=self._width * 0.9, 245 scale=1.4, 246 color=(0.2, 1, 0.2), 247 ) 248 # Total-value if they supplied it. 249 total_worth_item = offer.get('valueItem', None) 250 if total_worth_item is not None: 251 price = ba.internal.get_price(total_worth_item) 252 total_worth_price = ( 253 get_clean_price(price) if price is not None else None 254 ) 255 if total_worth_price is not None: 256 total_worth_text = ba.Lstr( 257 resource='store.totalWorthText', 258 subs=[('${TOTAL_WORTH}', total_worth_price)], 259 ) 260 self._total_worth_text = ba.textwidget( 261 parent=self._root_widget, 262 text=total_worth_text, 263 position=(self._width * 0.5, 210), 264 scale=0.9, 265 maxwidth=self._width * 0.7, 266 size=(0, 0), 267 h_align='center', 268 v_align='center', 269 shadow=1.0, 270 flatness=1.0, 271 color=(0.3, 1, 1), 272 ) 273 274 elif offer['item'] == 'pro_fullprice': 275 # for full-price pro we simply show full price 276 ba.textwidget(edit=display['price_widget'], text=real_price) 277 ba.buttonwidget( 278 edit=display['button'], on_activate_call=self._purchase 279 ) 280 else: 281 # Show old/new prices otherwise (for pro sale). 282 ba.buttonwidget( 283 edit=display['button'], on_activate_call=self._purchase 284 ) 285 ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0) 286 ba.textwidget( 287 edit=display['price_widget_left'], text=original_price_str 288 ) 289 ba.textwidget( 290 edit=display['price_widget_right'], text=new_price_str 291 ) 292 293 # Add ticket button only if this is ticket-purchasable. 294 if isinstance(offer.get('price'), int): 295 self._get_tickets_button = ba.buttonwidget( 296 parent=self._root_widget, 297 position=(self._width - 125, self._height - 68), 298 size=(90, 55), 299 scale=1.0, 300 button_type='square', 301 color=(0.7, 0.5, 0.85), 302 textcolor=(0.2, 1, 0.2), 303 autoselect=True, 304 label=ba.Lstr(resource='getTicketsWindow.titleText'), 305 on_activate_call=self._on_get_more_tickets_press, 306 ) 307 308 self._ticket_text_update_timer = ba.Timer( 309 1.0, 310 ba.WeakCall(self._update_tickets_text), 311 timetype=ba.TimeType.REAL, 312 repeat=True, 313 ) 314 self._update_tickets_text() 315 316 self._update_timer = ba.Timer( 317 1.0, 318 ba.WeakCall(self._update), 319 timetype=ba.TimeType.REAL, 320 repeat=True, 321 ) 322 323 self._cancel_button = ba.buttonwidget( 324 parent=self._root_widget, 325 position=(50, 40) 326 if self._is_bundle_sale 327 else (self._width * 0.5 - 75, 40), 328 size=(150, 60), 329 scale=1.0, 330 on_activate_call=self._cancel, 331 autoselect=True, 332 label=ba.Lstr(resource='noThanksText'), 333 ) 334 self._cancel_countdown_text = ba.textwidget( 335 parent=self._root_widget, 336 text='', 337 position=(50 + 150 + 20, 40 + 27) 338 if self._is_bundle_sale 339 else (self._width * 0.5 - 75 + 150 + 20, 40 + 27), 340 scale=1.1, 341 size=(0, 0), 342 h_align='left', 343 v_align='center', 344 shadow=1.0, 345 flatness=1.0, 346 color=(0.6, 0.5, 0.5), 347 ) 348 self._update_cancel_button_graphics() 349 350 if self._is_bundle_sale: 351 self._purchase_button = ba.buttonwidget( 352 parent=self._root_widget, 353 position=(self._width - 200, 40), 354 size=(150, 60), 355 scale=1.0, 356 on_activate_call=self._purchase, 357 autoselect=True, 358 label=ba.Lstr(resource='store.purchaseText'), 359 ) 360 361 ba.containerwidget( 362 edit=self._root_widget, 363 cancel_button=self._cancel_button, 364 start_button=self._purchase_button 365 if self._is_bundle_sale 366 else None, 367 selected_child=self._purchase_button 368 if self._is_bundle_sale 369 else display['button'], 370 ) 371 372 def _stop_flashing(self) -> None: 373 self._flashing_timer = None 374 ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3)) 375 376 def _flash_cycle(self) -> None: 377 if not self._root_widget: 378 return 379 self._flash_on = not self._flash_on 380 ba.textwidget( 381 edit=self._title_text, 382 color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0), 383 ) 384 385 def _update_cancel_button_graphics(self) -> None: 386 ba.buttonwidget( 387 edit=self._cancel_button, 388 color=(0.5, 0.5, 0.5) 389 if self._cancel_delay > 0 390 else (0.7, 0.4, 0.34), 391 textcolor=(0.5, 0.5, 0.5) 392 if self._cancel_delay > 0 393 else (0.9, 0.9, 1.0), 394 ) 395 ba.textwidget( 396 edit=self._cancel_countdown_text, 397 text=str(self._cancel_delay) if self._cancel_delay > 0 else '', 398 ) 399 400 def _update(self) -> None: 401 402 # If we've got seconds left on our countdown, update it. 403 if self._cancel_delay > 0: 404 self._cancel_delay = max(0, self._cancel_delay - 1) 405 self._update_cancel_button_graphics() 406 407 can_die = False 408 409 # We go away if we see that our target item is owned. 410 if self._offer_item == 'pro': 411 if ba.app.accounts_v1.have_pro(): 412 can_die = True 413 else: 414 if ba.internal.get_purchased(self._offer_item): 415 can_die = True 416 417 if can_die: 418 self._transition_out('out_left') 419 420 def _transition_out(self, transition: str = 'out_left') -> None: 421 # Also clear any pending-special-offer we've stored at this point. 422 cfg = ba.app.config 423 if 'pendingSpecialOffer' in cfg: 424 del cfg['pendingSpecialOffer'] 425 cfg.commit() 426 427 ba.containerwidget(edit=self._root_widget, transition=transition) 428 429 def _update_tickets_text(self) -> None: 430 from ba import SpecialChar 431 432 if not self._root_widget: 433 return 434 sval: str | ba.Lstr 435 if ba.internal.get_v1_account_state() == 'signed_in': 436 sval = ba.charstr(SpecialChar.TICKET) + str( 437 ba.internal.get_v1_account_ticket_count() 438 ) 439 else: 440 sval = ba.Lstr(resource='getTicketsWindow.titleText') 441 ba.buttonwidget(edit=self._get_tickets_button, label=sval) 442 443 def _on_get_more_tickets_press(self) -> None: 444 from bastd.ui import account 445 from bastd.ui import getcurrency 446 447 if ba.internal.get_v1_account_state() != 'signed_in': 448 account.show_sign_in_prompt() 449 return 450 getcurrency.GetCurrencyWindow(modal=True).get_root_widget() 451 452 def _purchase(self) -> None: 453 from ba.internal import get_store_item_name_translated 454 from bastd.ui import getcurrency 455 from bastd.ui import confirm 456 457 if self._offer['item'] == 'pro': 458 ba.internal.purchase('pro_sale') 459 elif self._offer['item'] == 'pro_fullprice': 460 ba.internal.purchase('pro') 461 elif self._is_bundle_sale: 462 # With bundle sales, the price is the name of the IAP. 463 ba.internal.purchase(self._offer['price']) 464 else: 465 ticket_count: int | None 466 try: 467 ticket_count = ba.internal.get_v1_account_ticket_count() 468 except Exception: 469 ticket_count = None 470 if ticket_count is not None and ticket_count < self._offer['price']: 471 getcurrency.show_get_tickets_prompt() 472 ba.playsound(ba.getsound('error')) 473 return 474 475 def do_it() -> None: 476 ba.internal.in_game_purchase( 477 'offer:' + str(self._offer['id']), self._offer['price'] 478 ) 479 480 ba.playsound(ba.getsound('swish')) 481 confirm.ConfirmWindow( 482 ba.Lstr( 483 resource='store.purchaseConfirmText', 484 subs=[ 485 ( 486 '${ITEM}', 487 get_store_item_name_translated(self._offer['item']), 488 ) 489 ], 490 ), 491 width=400, 492 height=120, 493 action=do_it, 494 ok_text=ba.Lstr( 495 resource='store.purchaseText', fallback_resource='okText' 496 ), 497 ) 498 499 def _cancel(self) -> None: 500 if self._cancel_delay > 0: 501 ba.playsound(ba.getsound('error')) 502 return 503 self._transition_out('out_right')
Window for presenting sales/etc.
SpecialOfferWindow(offer: dict[str, typing.Any], transition: str = 'in_right')
21 def __init__(self, offer: dict[str, Any], transition: str = 'in_right'): 22 # pylint: disable=too-many-statements 23 # pylint: disable=too-many-branches 24 # pylint: disable=too-many-locals 25 from ba.internal import get_store_item_display_size, get_clean_price 26 from ba import SpecialChar 27 from bastd.ui.store import item as storeitemui 28 29 self._cancel_delay = offer.get('cancelDelay', 0) 30 31 # First thing: if we're offering pro or an IAP, see if we have a 32 # price for it. 33 # If not, abort and go into zombie mode (the user should never see 34 # us that way). 35 36 real_price: str | None 37 38 # Misnomer: 'pro' actually means offer 'pro_sale'. 39 if offer['item'] in ['pro', 'pro_fullprice']: 40 real_price = ba.internal.get_price( 41 'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale' 42 ) 43 if real_price is None and ba.app.debug_build: 44 print('NOTE: Faking prices for debug build.') 45 real_price = '$1.23' 46 zombie = real_price is None 47 elif isinstance(offer['price'], str): 48 # (a string price implies IAP id) 49 real_price = ba.internal.get_price(offer['price']) 50 if real_price is None and ba.app.debug_build: 51 print('NOTE: Faking price for debug build.') 52 real_price = '$1.23' 53 zombie = real_price is None 54 else: 55 real_price = None 56 zombie = False 57 if real_price is None: 58 real_price = '?' 59 60 if offer['item'] in ['pro', 'pro_fullprice']: 61 self._offer_item = 'pro' 62 else: 63 self._offer_item = offer['item'] 64 65 # If we wanted a real price but didn't find one, go zombie. 66 if zombie: 67 return 68 69 # This can pop up suddenly, so lets block input for 1 second. 70 ba.internal.lock_all_input() 71 ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL) 72 ba.playsound(ba.getsound('ding')) 73 ba.timer( 74 0.3, 75 lambda: ba.playsound(ba.getsound('ooh')), 76 timetype=ba.TimeType.REAL, 77 ) 78 self._offer = copy.deepcopy(offer) 79 self._width = 580 80 self._height = 590 81 uiscale = ba.app.ui.uiscale 82 super().__init__( 83 root_widget=ba.containerwidget( 84 size=(self._width, self._height), 85 transition=transition, 86 scale=( 87 1.2 88 if uiscale is ba.UIScale.SMALL 89 else 1.15 90 if uiscale is ba.UIScale.MEDIUM 91 else 1.0 92 ), 93 stack_offset=(0, -15) 94 if uiscale is ba.UIScale.SMALL 95 else (0, 0), 96 ) 97 ) 98 self._is_bundle_sale = False 99 try: 100 if offer['item'] in ['pro', 'pro_fullprice']: 101 original_price_str = ba.internal.get_price('pro') 102 if original_price_str is None: 103 original_price_str = '?' 104 new_price_str = ba.internal.get_price('pro_sale') 105 if new_price_str is None: 106 new_price_str = '?' 107 percent_off_text = '' 108 else: 109 # If the offer includes bonus tickets, it's a bundle-sale. 110 if ( 111 'bonusTickets' in offer 112 and offer['bonusTickets'] is not None 113 ): 114 self._is_bundle_sale = True 115 original_price = ba.internal.get_v1_account_misc_read_val( 116 'price.' + self._offer_item, 9999 117 ) 118 119 # For pure ticket prices we can show a percent-off. 120 if isinstance(offer['price'], int): 121 new_price = offer['price'] 122 tchar = ba.charstr(SpecialChar.TICKET) 123 original_price_str = tchar + str(original_price) 124 new_price_str = tchar + str(new_price) 125 percent_off = int( 126 round( 127 100.0 - (float(new_price) / original_price) * 100.0 128 ) 129 ) 130 percent_off_text = ' ' + ba.Lstr( 131 resource='store.salePercentText' 132 ).evaluate().replace('${PERCENT}', str(percent_off)) 133 else: 134 original_price_str = new_price_str = '?' 135 percent_off_text = '' 136 137 except Exception: 138 print(f'Offer: {offer}') 139 ba.print_exception('Error setting up special-offer') 140 original_price_str = new_price_str = '?' 141 percent_off_text = '' 142 143 # If its a bundle sale, change the title. 144 if self._is_bundle_sale: 145 sale_text = ba.Lstr( 146 resource='store.saleBundleText', 147 fallback_resource='store.saleText', 148 ).evaluate() 149 else: 150 # For full pro we say 'Upgrade?' since its not really a sale. 151 if offer['item'] == 'pro_fullprice': 152 sale_text = ba.Lstr( 153 resource='store.upgradeQuestionText', 154 fallback_resource='store.saleExclaimText', 155 ).evaluate() 156 else: 157 sale_text = ba.Lstr( 158 resource='store.saleExclaimText', 159 fallback_resource='store.saleText', 160 ).evaluate() 161 162 self._title_text = ba.textwidget( 163 parent=self._root_widget, 164 position=(self._width * 0.5, self._height - 40), 165 size=(0, 0), 166 text=sale_text 167 + ( 168 (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate()) 169 if self._offer['oneTimeOnly'] 170 else '' 171 ) 172 + percent_off_text, 173 h_align='center', 174 v_align='center', 175 maxwidth=self._width * 0.9 - 220, 176 scale=1.4, 177 color=(0.3, 1, 0.3), 178 ) 179 180 self._flash_on = False 181 self._flashing_timer: ba.Timer | None = ba.Timer( 182 0.05, 183 ba.WeakCall(self._flash_cycle), 184 repeat=True, 185 timetype=ba.TimeType.REAL, 186 ) 187 ba.timer( 188 0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL 189 ) 190 191 size = get_store_item_display_size(self._offer_item) 192 display: dict[str, Any] = {} 193 storeitemui.instantiate_store_item_display( 194 self._offer_item, 195 display, 196 parent_widget=self._root_widget, 197 b_pos=( 198 self._width * 0.5 199 - size[0] * 0.5 200 + 10 201 - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0), 202 self._height * 0.5 203 - size[1] * 0.5 204 + 20 205 + (20 if self._is_bundle_sale else 0), 206 ), 207 b_width=size[0], 208 b_height=size[1], 209 button=not self._is_bundle_sale, 210 ) 211 212 # Wire up the parts we need. 213 if self._is_bundle_sale: 214 self._plus_text = ba.textwidget( 215 parent=self._root_widget, 216 position=(self._width * 0.5, self._height * 0.5 + 50), 217 size=(0, 0), 218 text='+', 219 h_align='center', 220 v_align='center', 221 maxwidth=self._width * 0.9, 222 scale=1.4, 223 color=(0.5, 0.5, 0.5), 224 ) 225 self._plus_tickets = ba.textwidget( 226 parent=self._root_widget, 227 position=(self._width * 0.5 + 120, self._height * 0.5 + 50), 228 size=(0, 0), 229 text=ba.charstr(SpecialChar.TICKET_BACKING) 230 + str(offer['bonusTickets']), 231 h_align='center', 232 v_align='center', 233 maxwidth=self._width * 0.9, 234 scale=2.5, 235 color=(0.2, 1, 0.2), 236 ) 237 self._price_text = ba.textwidget( 238 parent=self._root_widget, 239 position=(self._width * 0.5, 150), 240 size=(0, 0), 241 text=real_price, 242 h_align='center', 243 v_align='center', 244 maxwidth=self._width * 0.9, 245 scale=1.4, 246 color=(0.2, 1, 0.2), 247 ) 248 # Total-value if they supplied it. 249 total_worth_item = offer.get('valueItem', None) 250 if total_worth_item is not None: 251 price = ba.internal.get_price(total_worth_item) 252 total_worth_price = ( 253 get_clean_price(price) if price is not None else None 254 ) 255 if total_worth_price is not None: 256 total_worth_text = ba.Lstr( 257 resource='store.totalWorthText', 258 subs=[('${TOTAL_WORTH}', total_worth_price)], 259 ) 260 self._total_worth_text = ba.textwidget( 261 parent=self._root_widget, 262 text=total_worth_text, 263 position=(self._width * 0.5, 210), 264 scale=0.9, 265 maxwidth=self._width * 0.7, 266 size=(0, 0), 267 h_align='center', 268 v_align='center', 269 shadow=1.0, 270 flatness=1.0, 271 color=(0.3, 1, 1), 272 ) 273 274 elif offer['item'] == 'pro_fullprice': 275 # for full-price pro we simply show full price 276 ba.textwidget(edit=display['price_widget'], text=real_price) 277 ba.buttonwidget( 278 edit=display['button'], on_activate_call=self._purchase 279 ) 280 else: 281 # Show old/new prices otherwise (for pro sale). 282 ba.buttonwidget( 283 edit=display['button'], on_activate_call=self._purchase 284 ) 285 ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0) 286 ba.textwidget( 287 edit=display['price_widget_left'], text=original_price_str 288 ) 289 ba.textwidget( 290 edit=display['price_widget_right'], text=new_price_str 291 ) 292 293 # Add ticket button only if this is ticket-purchasable. 294 if isinstance(offer.get('price'), int): 295 self._get_tickets_button = ba.buttonwidget( 296 parent=self._root_widget, 297 position=(self._width - 125, self._height - 68), 298 size=(90, 55), 299 scale=1.0, 300 button_type='square', 301 color=(0.7, 0.5, 0.85), 302 textcolor=(0.2, 1, 0.2), 303 autoselect=True, 304 label=ba.Lstr(resource='getTicketsWindow.titleText'), 305 on_activate_call=self._on_get_more_tickets_press, 306 ) 307 308 self._ticket_text_update_timer = ba.Timer( 309 1.0, 310 ba.WeakCall(self._update_tickets_text), 311 timetype=ba.TimeType.REAL, 312 repeat=True, 313 ) 314 self._update_tickets_text() 315 316 self._update_timer = ba.Timer( 317 1.0, 318 ba.WeakCall(self._update), 319 timetype=ba.TimeType.REAL, 320 repeat=True, 321 ) 322 323 self._cancel_button = ba.buttonwidget( 324 parent=self._root_widget, 325 position=(50, 40) 326 if self._is_bundle_sale 327 else (self._width * 0.5 - 75, 40), 328 size=(150, 60), 329 scale=1.0, 330 on_activate_call=self._cancel, 331 autoselect=True, 332 label=ba.Lstr(resource='noThanksText'), 333 ) 334 self._cancel_countdown_text = ba.textwidget( 335 parent=self._root_widget, 336 text='', 337 position=(50 + 150 + 20, 40 + 27) 338 if self._is_bundle_sale 339 else (self._width * 0.5 - 75 + 150 + 20, 40 + 27), 340 scale=1.1, 341 size=(0, 0), 342 h_align='left', 343 v_align='center', 344 shadow=1.0, 345 flatness=1.0, 346 color=(0.6, 0.5, 0.5), 347 ) 348 self._update_cancel_button_graphics() 349 350 if self._is_bundle_sale: 351 self._purchase_button = ba.buttonwidget( 352 parent=self._root_widget, 353 position=(self._width - 200, 40), 354 size=(150, 60), 355 scale=1.0, 356 on_activate_call=self._purchase, 357 autoselect=True, 358 label=ba.Lstr(resource='store.purchaseText'), 359 ) 360 361 ba.containerwidget( 362 edit=self._root_widget, 363 cancel_button=self._cancel_button, 364 start_button=self._purchase_button 365 if self._is_bundle_sale 366 else None, 367 selected_child=self._purchase_button 368 if self._is_bundle_sale 369 else display['button'], 370 )
Inherited Members
- ba.ui.Window
- get_root_widget