diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs index 755142c..f1bc69a 100644 --- a/api/QuestionsApi/Controllers/CreditsController.cs +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -48,6 +48,27 @@ public sealed class CreditsController(CreditStore credits, ExamSimulationStore s { code = "reset_token_required", message = "A QBank Reset Token is required.", credits = result.Status }); } + [HttpGet("spin-config")] + public IActionResult SpinConfig() => Ok(new SpinConfigResponse()); + + [HttpPost("spin")] + public async Task Spin([FromBody] SpinRequest request) + { + if (!ValidOperationId(request.OperationId)) + return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." }); + var result = await credits.SpinAsync(UserId(), request.OperationId); + return result.Result switch + { + CreditOperationResult.Succeeded => Ok(result), + CreditOperationResult.InsufficientCredits => Conflict(new + { code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }), + CreditOperationResult.RateLimited => Conflict(new + { code = "rate_limited", message = "Too many spins - try again later.", credits = result.Status }), + _ => Conflict(new + { code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status }) + }; + } + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index e3ed6a1..72d1386 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -36,7 +36,49 @@ public static class CreditEconomy Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal)); } -public enum RzStoreGrant { ExamToken, QBankResetToken } +public static class SlotMachineEconomy +{ + public const int SpinCost = 50; + public const int RollingSpinLimit = 200; + public static readonly TimeSpan SpinWindow = TimeSpan.FromHours(24); + + public static readonly IReadOnlyList Rewards = + [ + new() { Key = "jackpot", Label = "10,000 RZ Coins", Description = "Jackpot!", Grant = RzStoreGrant.Coins, CoinAmount = 10000, Probability = 0.005 }, + new() { Key = "epic", Label = "2,000 RZ Coins", Description = "Epic Reward", Grant = RzStoreGrant.Coins, CoinAmount = 2000, Probability = 0.02 }, + new() { Key = "great", Label = "500 RZ Coins", Description = "Great Reward", Grant = RzStoreGrant.Coins, CoinAmount = 500, Probability = 0.10 }, + new() { Key = "exam_token", Label = "Exam Simulation Token", Description = "Starts one new full exam simulation.", Grant = RzStoreGrant.ExamToken, Probability = 0.15 }, + new() { Key = "qbank_reset", Label = "QBank Reset Token", Description = "Makes every QBank question eligible again.", Grant = RzStoreGrant.QBankResetToken, Probability = 0.20 }, + new() { Key = "nice", Label = "100 RZ Coins", Description = "Nice!", Grant = RzStoreGrant.Coins, CoinAmount = 100, Probability = 0.30 }, + new() { Key = "good_luck", Label = "50 RZ Coins", Description = "Good luck!", Grant = RzStoreGrant.Coins, CoinAmount = 50, Probability = 0.225 }, + ]; + + public static SpinReward RollReward(double roll) + { + var cumulative = 0.0; + foreach (var reward in Rewards) + { + cumulative += reward.Probability; + if (roll < cumulative) return reward; + } + return Rewards[^1]; + } + + public static SpinReward? FindReward(string key) => + Rewards.FirstOrDefault(value => value.Key.Equals(key, StringComparison.Ordinal)); +} + +public enum RzStoreGrant { ExamToken, QBankResetToken, Coins } + +public sealed class SpinReward +{ + public string Key { get; set; } = ""; + public string Label { get; set; } = ""; + public string Description { get; set; } = ""; + public RzStoreGrant Grant { get; set; } + public int CoinAmount { get; set; } + public double Probability { get; set; } +} public sealed class RzStoreProduct { @@ -61,6 +103,14 @@ public sealed class CreditPurchaseEvent public DateTime CreatedUtc { get; set; } } +public sealed class SpinEvent +{ + public string OperationId { get; set; } = ""; + public string RewardKey { get; set; } = ""; + public int CoinsWon { get; set; } + public DateTime CreatedUtc { get; set; } +} + public sealed class UserCreditAccount { [BsonId] @@ -74,6 +124,8 @@ public sealed class UserCreditAccount public bool InitialExamGrantApplied { get; set; } public List RecentEarnings { get; set; } = []; public List Purchases { get; set; } = []; + public List Spins { get; set; } = []; + public List RecentSpins { get; set; } = []; public List AppliedOperations { get; set; } = []; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } @@ -102,10 +154,30 @@ public sealed class QBankResetRequest public string OperationId { get; set; } = ""; } -public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict } +public sealed class SpinRequest +{ + public string OperationId { get; set; } = ""; +} + +public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict, RateLimited } public sealed class CreditOperationResponse { public CreditOperationResult Result { get; set; } public CreditStatusResponse Status { get; set; } = new(); } + +public sealed class SpinConfigResponse +{ + public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost; + public IReadOnlyList Rewards { get; set; } = SlotMachineEconomy.Rewards; +} + +public sealed class SpinResponse +{ + public CreditOperationResult Result { get; set; } + public string RewardKey { get; set; } = ""; + public string RewardLabel { get; set; } = ""; + public int CoinsWon { get; set; } + public CreditStatusResponse Status { get; set; } = new(); +} diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs index 81e7802..2a71fe1 100644 --- a/api/QuestionsApi/Services/CreditStore.cs +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -130,6 +130,65 @@ public sealed class CreditStore return Response(CreditOperationResult.InsufficientTokens, account); } + public async Task SpinAsync(string userId, string operationId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId); + if (priorSpin is not null) + return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin); + + var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble()); + var netCreditDelta = reward.Grant == RzStoreGrant.Coins + ? reward.CoinAmount - SlotMachineEconomy.SpinCost + : -SlotMachineEconomy.SpinCost; + var spinEvent = new SpinEvent + { + OperationId = operationId, + RewardKey = reward.Key, + CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0, + CreatedUtc = now + }; + + var belowSpinCap = new BsonDocumentFilterDefinition( + new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray + { + new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })), + SlotMachineEconomy.RollingSpinLimit + }))); + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) & + Builders.Filter.Not( + Builders.Filter.ElemMatch( + value => value.Spins, spin => spin.OperationId == operationId)) & + belowSpinCap; + var update = Builders.Update + .Inc(value => value.Credits, netCreditDelta) + .Push(value => value.Spins, spinEvent) + .Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now }) + .Set(value => value.UpdatedUtc, now); + update = reward.Grant switch + { + RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1), + RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1), + _ => update + }; + var account = await _accounts.FindOneAndUpdateAsync( + filter, update, new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent); + + account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId); + if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin); + if (account.Credits < SlotMachineEconomy.SpinCost) + return SpinResult(CreditOperationResult.InsufficientCredits, account, null); + return SpinResult(CreditOperationResult.RateLimited, account, null); + } + public async Task TryConsumeExamTokenAsync(string userId, string operationId) { var now = _clock.GetUtcNow().UtcDateTime; @@ -166,6 +225,8 @@ public sealed class CreditStore .SetOnInsert(value => value.InitialExamGrantApplied, true) .SetOnInsert(value => value.RecentEarnings, []) .SetOnInsert(value => value.Purchases, []) + .SetOnInsert(value => value.Spins, []) + .SetOnInsert(value => value.RecentSpins, []) .SetOnInsert(value => value.AppliedOperations, []) .SetOnInsert(value => value.UpdatedUtc, now), new UpdateOptions { IsUpsert = true }); @@ -183,6 +244,14 @@ public sealed class CreditStore Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.Purchases), false), Builders.Update.Set(value => value.Purchases, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.Spins), false), + Builders.Update.Set(value => value.Spins, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false), + Builders.Update.Set(value => value.RecentSpins, [])); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false), @@ -193,6 +262,12 @@ public sealed class CreditStore Builders.Update.PullFilter( value => value.RecentEarnings, earning => earning.CreatedUtc <= cutoff)); + var spinCutoff = now - SlotMachineEconomy.SpinWindow; + await _accounts.UpdateOneAsync( + value => value.UserId == userId, + Builders.Update.PullFilter( + value => value.RecentSpins, + spin => spin.CreatedUtc <= spinCutoff)); } private static CreditStatusResponse ToStatus(UserCreditAccount account) => new() @@ -212,4 +287,14 @@ public sealed class CreditStore Result = result, Status = ToStatus(account) }; + + private static SpinResponse SpinResult( + CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new() + { + Result = result, + RewardKey = spinEvent?.RewardKey ?? "", + RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "", + CoinsWon = spinEvent?.CoinsWon ?? 0, + Status = ToStatus(account) + }; } diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd index fc40a41..f38b93e 100644 --- a/scripts/autoload/credits_service.gd +++ b/scripts/autoload/credits_service.gd @@ -3,6 +3,8 @@ extends Node signal credits_changed signal credits_failed(message: String) signal store_operation_completed(message: String) +signal spin_resolved(reward_key: String, reward_label: String, coins_won: int) +signal spin_config_loaded const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_URL := "https://us3q.ranaze.com/api/credits" @@ -12,10 +14,13 @@ var exam_tokens: int = 0 var qbank_reset_tokens: int = 0 var bank_cycle: int = 0 var products: Array = [] +var spin_cost := 50 +var spin_rewards: Array = [] var loaded := false var loading := false var request: HTTPRequest var action_request: HTTPRequest +var spin_config_request: HTTPRequest var request_token := "" var api_url := DEFAULT_URL var pending_store_action := "" @@ -33,6 +38,10 @@ func _ready() -> void: action_request.timeout = 20.0 action_request.request_completed.connect(_on_action_completed) add_child(action_request) + spin_config_request = HTTPRequest.new() + spin_config_request.timeout = 20.0 + spin_config_request.request_completed.connect(_on_spin_config_completed) + add_child(spin_config_request) AuthState.auth_changed.connect(_on_auth_changed) @@ -83,8 +92,33 @@ func reset_qbank() -> void: _post("/qbank-reset", {"confirmation": "RESET_QBANK", "operationId": _operation_id("reset")}) +func spin() -> void: + pending_store_action = "spin" + _post("/spin", {"operationId": _operation_id("spin")}) + + +func fetch_spin_config() -> void: + if not AuthState.can_send_authenticated_request(): + return + var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + spin_config_request.request(api_url + "/spin-config", headers, HTTPClient.METHOD_GET) + + +func _on_spin_config_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + return + spin_cost = int(parsed.get("spinCost", spin_cost)) + spin_rewards = parsed.get("rewards", spin_rewards) + spin_config_loaded.emit() + + func _post(path: String, payload: Dictionary) -> void: if not AuthState.can_send_authenticated_request(): + pending_store_action = "" + credits_failed.emit("You're signed out. Sign back in and try again.") return var headers := PackedStringArray([ "Authorization: Bearer %s" % AuthState.access_token, @@ -101,6 +135,16 @@ func _on_action_completed(result: int, response_code: int, _headers: PackedStrin credits_failed.emit("The Rz Coin Store could not complete that action.") return if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY: + if pending_store_action == "spin": + var status: Variant = parsed.get("status", {}) + if typeof(status) == TYPE_DICTIONARY: + apply_status(status) + pending_store_action = "" + spin_resolved.emit( + str(parsed.get("rewardKey", "")), + str(parsed.get("rewardLabel", "")), + int(parsed.get("coinsWon", 0))) + return apply_status(parsed) if pending_store_action == "reset": ProfileService.reset_qbank_eligibility() @@ -118,7 +162,7 @@ func _on_action_completed(result: int, response_code: int, _headers: PackedStrin func _operation_id(prefix: String) -> String: - return "%s:%s:%d" % [prefix, str(AuthState.user_id), Time.get_ticks_usec()] + return "%s:%s:%d" % [prefix, str(AuthState.username), Time.get_ticks_usec()] func _on_auth_changed() -> void: @@ -128,6 +172,7 @@ func _on_auth_changed() -> void: qbank_reset_tokens = 0 bank_cycle = 0 products = [] + spin_rewards = [] loaded = false loading = false if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 35ef020..ddf06cd 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -361,6 +361,11 @@ func _build_ui() -> void: rz_store.main = self screens_container.add_child(rz_store) screens["rz_store"] = rz_store + var slot_machine := preload("res://scripts/ui/rz_slot_machine_screen.gd").new() + slot_machine.name = "RzSlotMachine" + slot_machine.main = self + screens_container.add_child(slot_machine) + screens["slot_machine"] = slot_machine feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() feedback_dialog.name = "AppFeedbackDialog" diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index c9a1005..436406b 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -31,4 +31,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "donations" elif screen_name == "rz_store": key = "rz_store" + elif screen_name == "slot_machine": + key = "rz_store" return key diff --git a/scripts/ui/rz_slot_machine_screen.gd b/scripts/ui/rz_slot_machine_screen.gd new file mode 100644 index 0000000..b54679d --- /dev/null +++ b/scripts/ui/rz_slot_machine_screen.gd @@ -0,0 +1,517 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const STAR_ICON := preload("res://assets/icons/lucide/star.svg") +const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg") +const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg") +const RESET_ICON := preload("res://assets/icons/lucide/rotate-ccw.svg") +const REEL_SYMBOLS := [STAR_ICON, SHIELD_ICON, CLOCK_ICON, RESET_ICON] +const ICON_BY_REWARD_KEY := { + "jackpot": "star", + "epic": "star", + "great": "star", + "exam_token": "clock-3", + "qbank_reset": "rotate-ccw", + "nice": "star", + "good_luck": "star", +} + +var main: Control + +var subtitle_label: Label +var back_button: Button +var machine_panel: PanelContainer +var reel_icons: Array = [] +var win_up_to_label: Label +var cost_value_label: Label +var spin_button: Button +var auto_spin_toggle: CheckButton +var status_label: Label +var rewards_list: VBoxContainer +var footer_card: PanelContainer +var footer_icon_panel: PanelContainer +var footer_icon: TextureRect +var footer_heading: Label +var footer_description: Label + +var _spinning := false + + +func _ready() -> void: + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 18) + + _build_header() + + var body := HBoxContainer.new() + body.add_theme_constant_override("separation", 18) + body.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(body) + + _build_machine(body) + _build_rewards_panel(body) + + status_label = Label.new() + status_label.add_theme_font_override("font", Brand.font) + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(status_label) + + _build_footer() + + CreditsService.credits_changed.connect(_refresh_spin_button) + CreditsService.spin_config_loaded.connect(_refresh_rewards) + UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls()) + _refresh_rewards() + _refresh_spin_button() + + +func on_show(_payload: Variant = null) -> void: + CreditsService.refresh() + CreditsService.fetch_spin_config() + _refresh_rewards() + _refresh_spin_button() + + +func _build_header() -> void: + back_button = Button.new() + back_button.text = "← Back to Shop" + back_button.add_theme_font_override("font", Brand.font) + back_button.flat = true + back_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + back_button.pressed.connect(func(): main.show_screen("rz_store")) + add_child(back_button) + + var title := Label.new() + title.text = "RZ Coin Slot Machine" + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 30) + add_child(title) + subtitle_label = Label.new() + subtitle_label.text = "Test your luck and win exciting rewards!" + subtitle_label.add_theme_font_override("font", Brand.font) + subtitle_label.add_theme_font_size_override("font_size", 14) + subtitle_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + add_child(subtitle_label) + + +func _build_machine(parent: Control) -> void: + var dark := UIThemeService.is_dark_mode() + machine_panel = PanelContainer.new() + machine_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + machine_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + parent.add_child(machine_panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 20) + margin.add_theme_constant_override("margin_right", 20) + margin.add_theme_constant_override("margin_top", 18) + margin.add_theme_constant_override("margin_bottom", 18) + machine_panel.add_child(margin) + + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 14) + margin.add_child(column) + + var reels_row := HBoxContainer.new() + reels_row.add_theme_constant_override("separation", 12) + reels_row.alignment = BoxContainer.ALIGNMENT_CENTER + column.add_child(reels_row) + + reel_icons.clear() + for _i in range(3): + var reel_panel := PanelContainer.new() + reel_panel.custom_minimum_size = Vector2(84, 84) + reel_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 12)) + var icon := TextureRect.new() + icon.texture = STAR_ICON + icon.modulate = Brand.AMBER + icon.custom_minimum_size = Vector2(40, 40) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER + reel_panel.add_child(icon) + reels_row.add_child(reel_panel) + reel_icons.append(icon) + + win_up_to_label = Label.new() + win_up_to_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + win_up_to_label.add_theme_font_override("font", Brand.font) + win_up_to_label.add_theme_font_size_override("font_size", 15) + win_up_to_label.add_theme_color_override("font_color", Brand.text_color(dark)) + column.add_child(win_up_to_label) + + var controls_row := HBoxContainer.new() + controls_row.add_theme_constant_override("separation", 14) + column.add_child(controls_row) + + var cost_box := VBoxContainer.new() + cost_box.add_theme_constant_override("separation", 0) + var cost_caption := Label.new() + cost_caption.text = "Cost per spin" + cost_caption.add_theme_font_override("font", Brand.font) + cost_caption.add_theme_font_size_override("font_size", 11) + cost_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) + cost_box.add_child(cost_caption) + cost_value_label = Label.new() + cost_value_label.add_theme_font_override("font", Brand.font) + cost_value_label.add_theme_font_size_override("font_size", 15) + cost_value_label.add_theme_color_override("font_color", Brand.text_color(dark)) + cost_box.add_child(cost_value_label) + controls_row.add_child(cost_box) + + spin_button = Button.new() + spin_button.text = "Spin" + spin_button.custom_minimum_size = Vector2(120, 44) + spin_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spin_button.add_theme_font_override("font", Brand.font) + _style_spin_button() + spin_button.pressed.connect(_on_spin_pressed) + controls_row.add_child(spin_button) + + var auto_box := VBoxContainer.new() + auto_box.add_theme_constant_override("separation", 0) + auto_spin_toggle = CheckButton.new() + auto_spin_toggle.text = "Auto Spin" + auto_spin_toggle.add_theme_font_override("font", Brand.font) + auto_box.add_child(auto_spin_toggle) + var auto_caption := Label.new() + auto_caption.text = "10 spins" + auto_caption.add_theme_font_override("font", Brand.font) + auto_caption.add_theme_font_size_override("font_size", 11) + auto_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) + auto_box.add_child(auto_caption) + controls_row.add_child(auto_box) + + var info_label := Label.new() + info_label.text = "All rewards are instant and sent to your account." + info_label.add_theme_font_override("font", Brand.font) + info_label.add_theme_font_size_override("font_size", 11) + info_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + info_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + column.add_child(info_label) + + +func _build_rewards_panel(parent: Control) -> void: + var dark := UIThemeService.is_dark_mode() + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(280, 0) + panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + parent.add_child(panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_top", 14) + margin.add_theme_constant_override("margin_bottom", 14) + panel.add_child(margin) + + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 10) + margin.add_child(column) + + var heading := Label.new() + heading.text = "Possible Rewards" + heading.add_theme_font_override("font", Brand.font) + heading.add_theme_font_size_override("font_size", 16) + heading.add_theme_color_override("font_color", Brand.text_color(dark)) + column.add_child(heading) + + rewards_list = VBoxContainer.new() + rewards_list.add_theme_constant_override("separation", 10) + column.add_child(rewards_list) + + +func _build_footer() -> void: + var dark := UIThemeService.is_dark_mode() + footer_card = PanelContainer.new() + footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + add_child(footer_card) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_bottom", 12) + footer_card.add_child(margin) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 14) + margin.add_child(row) + + footer_icon_panel = PanelContainer.new() + footer_icon_panel.custom_minimum_size = Vector2(36, 36) + footer_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18)) + footer_icon = TextureRect.new() + footer_icon.texture = STAR_ICON + footer_icon.custom_minimum_size = Vector2(18, 18) + footer_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + footer_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + footer_icon.modulate = Brand.ROYAL_BLUE + footer_icon_panel.add_child(footer_icon) + row.add_child(footer_icon_panel) + + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + copy.add_theme_constant_override("separation", 2) + footer_heading = Label.new() + footer_heading.text = "How it works" + footer_heading.add_theme_font_override("font", Brand.font) + footer_heading.add_theme_font_size_override("font_size", 15) + footer_heading.add_theme_color_override("font_color", Brand.text_color(dark)) + copy.add_child(footer_heading) + footer_description = Label.new() + footer_description.text = "Spend RZ Coins to spin the slot machine. Win coins, boosts, tokens, and more!" + footer_description.add_theme_font_override("font", Brand.font) + footer_description.add_theme_font_size_override("font_size", 12) + footer_description.add_theme_color_override("font_color", Brand.muted_color(dark)) + footer_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + copy.add_child(footer_description) + row.add_child(copy) + + +func _refresh_theme_controls() -> void: + var dark := UIThemeService.is_dark_mode() + subtitle_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + machine_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + win_up_to_label.add_theme_color_override("font_color", Brand.text_color(dark)) + cost_value_label.add_theme_color_override("font_color", Brand.text_color(dark)) + footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18)) + footer_heading.add_theme_color_override("font_color", Brand.text_color(dark)) + footer_description.add_theme_color_override("font_color", Brand.muted_color(dark)) + _refresh_rewards() + + +func _reward_color(key: String) -> Color: + match key: + "jackpot": return Brand.AMBER + "epic": return Brand.PURPLE + "great": return Brand.ROYAL_BLUE + "exam_token": return Brand.ROYAL_BLUE + "qbank_reset": return Brand.SUCCESS + "nice": return Brand.SKY_BLUE + "good_luck": return Brand.SLATE + _: return Brand.ROYAL_BLUE + + +func _refresh_rewards() -> void: + var dark := UIThemeService.is_dark_mode() + cost_value_label.text = "%d RZ Coins" % CreditsService.spin_cost + + var max_coins := 0 + for reward_variant in CreditsService.spin_rewards: + if typeof(reward_variant) != TYPE_DICTIONARY: + continue + max_coins = maxi(max_coins, int((reward_variant as Dictionary).get("coinAmount", 0))) + win_up_to_label.text = "Win up to %s RZ Coins!" % _format_thousands(max_coins) if max_coins > 0 else "" + + for child in rewards_list.get_children(): + child.queue_free() + for reward_variant in CreditsService.spin_rewards: + if typeof(reward_variant) != TYPE_DICTIONARY: + continue + var reward: Dictionary = reward_variant + rewards_list.add_child(_build_reward_row(dark, reward)) + + +func _build_reward_row(dark: bool, reward: Dictionary) -> HBoxContainer: + var key := str(reward.get("key", "")) + var accent := _reward_color(key) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(32, 32) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 16)) + var icon := TextureRect.new() + icon.texture = load("res://assets/icons/lucide/%s.svg" % str(ICON_BY_REWARD_KEY.get(key, "star"))) + icon.custom_minimum_size = Vector2(16, 16) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.modulate = accent + icon_panel.add_child(icon) + row.add_child(icon_panel) + + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + copy.add_theme_constant_override("separation", 0) + var label := Label.new() + label.text = str(reward.get("label", "")) + label.add_theme_font_override("font", Brand.font) + label.add_theme_font_size_override("font_size", 13) + label.add_theme_color_override("font_color", Brand.text_color(dark)) + copy.add_child(label) + var description := Label.new() + description.text = str(reward.get("description", "")) + description.add_theme_font_override("font", Brand.font) + description.add_theme_font_size_override("font_size", 11) + description.add_theme_color_override("font_color", Brand.muted_color(dark)) + copy.add_child(description) + row.add_child(copy) + + var percent_label := Label.new() + percent_label.text = "%s%%" % _format_percent(float(reward.get("probability", 0.0)) * 100.0) + percent_label.add_theme_font_override("font", Brand.font) + percent_label.add_theme_font_size_override("font_size", 13) + percent_label.add_theme_color_override("font_color", accent) + row.add_child(percent_label) + + return row + + +func _refresh_spin_button() -> void: + spin_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost + + +func _on_spin_pressed() -> void: + if _spinning: + return + _spinning = true + _refresh_spin_button() + var spin_count := 10 if auto_spin_toggle.button_pressed else 1 + for _i in range(spin_count): + if CreditsService.credits < CreditsService.spin_cost: + break + var should_continue := await _do_one_spin() + if not should_continue: + break + _spinning = false + _refresh_spin_button() + + +func _do_one_spin() -> bool: + _animate_reels() + var result := await _await_spin_result() + if not result.get("ok", false): + status_label.text = str(result.get("message", "The spin could not be completed.")) + status_label.add_theme_color_override("font_color", Brand.MAGENTA) + return false + + var reward_label := str(result.get("reward_label", "")) + var coins_won := int(result.get("coins_won", 0)) + status_label.text = "You won: %s" % reward_label + status_label.add_theme_color_override("font_color", Brand.SUCCESS) + if coins_won > 0: + _play_coin_burst(coins_won) + elif main != null: + var credits_badge: Object = main.get("credits_badge") + if credits_badge != null: + credits_badge.bump() + return true + + +func _await_spin_result() -> Dictionary: + var result := {} + var on_resolved := func(reward_key: String, reward_label: String, coins_won: int): + result.merge({"ok": true, "reward_key": reward_key, "reward_label": reward_label, "coins_won": coins_won}, true) + var on_failed := func(message: String): + result.merge({"ok": false, "message": message}, true) + CreditsService.spin_resolved.connect(on_resolved, CONNECT_ONE_SHOT) + CreditsService.credits_failed.connect(on_failed, CONNECT_ONE_SHOT) + CreditsService.spin() + var deadline := Time.get_ticks_msec() + 20000 + while result.is_empty() and Time.get_ticks_msec() < deadline: + await get_tree().process_frame + if result.is_empty(): + result = {"ok": false, "message": "The spin timed out. Check your connection and try again."} + if CreditsService.spin_resolved.is_connected(on_resolved): + CreditsService.spin_resolved.disconnect(on_resolved) + if CreditsService.credits_failed.is_connected(on_failed): + CreditsService.credits_failed.disconnect(on_failed) + return result + + +func _animate_reels() -> void: + for index in range(reel_icons.size()): + _spin_one_reel(reel_icons[index], 0.9 + index * 0.25) + + +func _spin_one_reel(icon: TextureRect, duration: float) -> void: + var elapsed := 0.0 + var interval := 0.06 + while elapsed < duration: + icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] + await get_tree().create_timer(interval).timeout + elapsed += interval + interval = minf(interval * 1.18, 0.22) + icon.texture = STAR_ICON + icon.modulate = Brand.AMBER + + +func _play_coin_burst(coins_won: int) -> void: + if main == null: + return + var credits_badge: Control = main.get("credits_badge") + if credits_badge == null or not is_instance_valid(credits_badge): + return + var origin := machine_panel.get_global_rect().get_center() + var target := credits_badge.get_global_rect().get_center() + var coin_count := clampi(3 + coins_won / 500, 4, 10) + var flight_time := 0.55 + var stagger := 0.035 + for index in range(coin_count): + var coin := TextureRect.new() + coin.texture = STAR_ICON + coin.modulate = Brand.AMBER + coin.custom_minimum_size = Vector2(16, 16) + coin.size = Vector2(16, 16) + coin.top_level = true + coin.mouse_filter = Control.MOUSE_FILTER_IGNORE + coin.pivot_offset = Vector2(8, 8) + var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0)) + coin.global_position = origin + jitter - coin.size * 0.5 + add_child(coin) + var tween := create_tween() + tween.tween_interval(index * stagger) + tween.set_parallel(true) + tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) + tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN) + tween.set_parallel(false) + tween.tween_callback(coin.queue_free) + var settle_time := (coin_count - 1) * stagger + flight_time + get_tree().create_timer(settle_time).timeout.connect(func(): + if is_instance_valid(credits_badge): + credits_badge.bump()) + + +func _style_spin_button() -> void: + var normal := StyleBoxFlat.new() + normal.bg_color = Brand.ROYAL_BLUE + normal.set_corner_radius_all(8) + var hover := StyleBoxFlat.new() + hover.bg_color = Brand.SKY_BLUE + hover.set_corner_radius_all(8) + var disabled := StyleBoxFlat.new() + disabled.bg_color = Color(Brand.ROYAL_BLUE, 0.35) + disabled.set_corner_radius_all(8) + spin_button.add_theme_stylebox_override("normal", normal) + spin_button.add_theme_stylebox_override("hover", hover) + spin_button.add_theme_stylebox_override("pressed", normal) + spin_button.add_theme_stylebox_override("disabled", disabled) + spin_button.add_theme_color_override("font_color", Brand.DARK_TEXT) + spin_button.add_theme_color_override("font_hover_color", Brand.DARK_TEXT) + spin_button.add_theme_color_override("font_disabled_color", Color(Brand.DARK_TEXT, 0.7)) + + +func _format_thousands(value: int) -> String: + var digits := str(absi(value)) + var grouped := "" + var count := 0 + for index in range(digits.length() - 1, -1, -1): + grouped = digits[index] + grouped + count += 1 + if count % 3 == 0 and index != 0: + grouped = "," + grouped + return ("-" if value < 0 else "") + grouped + + +func _format_percent(value: float) -> String: + if absf(value - roundf(value)) < 0.001: + return str(int(roundf(value))) + return "%.1f" % value diff --git a/scripts/ui/rz_store_screen.gd b/scripts/ui/rz_store_screen.gd index f0231cf..a0688b6 100644 --- a/scripts/ui/rz_store_screen.gd +++ b/scripts/ui/rz_store_screen.gd @@ -28,6 +28,10 @@ var balance_value_label: Label var coin_caption_label: Label var inventory_label: Label var tab_controls: Array = [] +var banner_card: PanelContainer +var banner_icon_panel: PanelContainer +var banner_heading: Label +var banner_description: Label var section_heading: Label var section_subtitle: Label var products_grid: HFlowContainer @@ -49,6 +53,7 @@ func _ready() -> void: _build_header() _build_summary_card() _build_tabs() + _build_slot_machine_banner() section_heading = Label.new() section_heading.text = "Featured" @@ -217,6 +222,63 @@ func _apply_tab_theme(dark: bool) -> void: (entry["underline"] as ColorRect).color = Brand.ROYAL_BLUE if active else Color(0, 0, 0, 0) +func _build_slot_machine_banner() -> void: + var dark := UIThemeService.is_dark_mode() + banner_card = PanelContainer.new() + banner_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + add_child(banner_card) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 16) + margin.add_theme_constant_override("margin_right", 16) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_bottom", 12) + banner_card.add_child(margin) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 14) + margin.add_child(row) + + banner_icon_panel = PanelContainer.new() + banner_icon_panel.custom_minimum_size = Vector2(40, 40) + banner_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + banner_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 20)) + var icon := TextureRect.new() + icon.texture = load("res://assets/icons/lucide/star.svg") + icon.custom_minimum_size = Vector2(20, 20) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.modulate = Brand.ROYAL_BLUE + banner_icon_panel.add_child(icon) + row.add_child(banner_icon_panel) + + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + copy.add_theme_constant_override("separation", 2) + banner_heading = Label.new() + banner_heading.text = "RZ Coin Slot Machine" + banner_heading.add_theme_font_override("font", Brand.font) + banner_heading.add_theme_font_size_override("font_size", 15) + banner_heading.add_theme_color_override("font_color", Brand.text_color(dark)) + copy.add_child(banner_heading) + banner_description = Label.new() + banner_description.text = "Spend RZ Coins for a chance at bigger rewards." + banner_description.add_theme_font_override("font", Brand.font) + banner_description.add_theme_font_size_override("font_size", 12) + banner_description.add_theme_color_override("font_color", Brand.muted_color(dark)) + copy.add_child(banner_description) + row.add_child(copy) + + var play_button := Button.new() + play_button.text = "Play Now" + play_button.custom_minimum_size = Vector2(96, 36) + play_button.add_theme_font_override("font", Brand.font) + _style_buy_button(play_button) + play_button.pressed.connect(func(): main.show_screen("slot_machine")) + row.add_child(play_button) + + func _build_footer() -> void: var dark := UIThemeService.is_dark_mode() footer_card = PanelContainer.new() @@ -276,6 +338,10 @@ func _refresh_theme_controls() -> void: coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark)) _apply_tab_theme(dark) + banner_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + banner_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 20)) + banner_heading.add_theme_color_override("font_color", Brand.text_color(dark)) + banner_description.add_theme_color_override("font_color", Brand.muted_color(dark)) section_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark)) footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))