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