Add an RZ Coin slot machine
Adds a spend-coins-for-a-random-reward mini-game reachable from the shop. Backend mirrors the store's existing atomic/idempotent purchase pattern (SpinAsync in CreditStore.cs) with a server-authoritative, weighted reward roll and a rolling per-user spin-rate guard; odds are served from GET /api/credits/spin-config so the client never hardcodes numbers that could drift from the real payout table. The mockup's "Streak Protector" reward was swapped for a real Exam Simulation Token since streaks aren't currently a persisted, protectable concept. Also fixes two latent bugs surfaced while building this: - credits_service.gd's operation-id builder referenced a nonexistent AuthState.user_id (should be .username), throwing on every credit action. - _post() silently no-op'd with no signal when signed out, which combined with a GDScript closure gotcha (lambdas capture outer locals by value, so reassigning inside one never reached the caller) meant a spin attempted while unauthenticated could hang forever instead of surfacing an error.
This commit is contained in:
@@ -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 });
|
{ 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<IActionResult> 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)
|
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
?? User.FindFirstValue(ClaimTypes.Name)
|
?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
?? User.Identity?.Name
|
?? User.Identity?.Name
|
||||||
|
|||||||
@@ -36,7 +36,49 @@ public static class CreditEconomy
|
|||||||
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
|
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<SpinReward> 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
|
public sealed class RzStoreProduct
|
||||||
{
|
{
|
||||||
@@ -61,6 +103,14 @@ public sealed class CreditPurchaseEvent
|
|||||||
public DateTime CreatedUtc { get; set; }
|
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
|
public sealed class UserCreditAccount
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
@@ -74,6 +124,8 @@ public sealed class UserCreditAccount
|
|||||||
public bool InitialExamGrantApplied { get; set; }
|
public bool InitialExamGrantApplied { get; set; }
|
||||||
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
||||||
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
||||||
|
public List<SpinEvent> Spins { get; set; } = [];
|
||||||
|
public List<CreditEvent> RecentSpins { get; set; } = [];
|
||||||
public List<string> AppliedOperations { get; set; } = [];
|
public List<string> AppliedOperations { get; set; } = [];
|
||||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
@@ -102,10 +154,30 @@ public sealed class QBankResetRequest
|
|||||||
public string OperationId { get; set; } = "";
|
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 sealed class CreditOperationResponse
|
||||||
{
|
{
|
||||||
public CreditOperationResult Result { get; set; }
|
public CreditOperationResult Result { get; set; }
|
||||||
public CreditStatusResponse Status { get; set; } = new();
|
public CreditStatusResponse Status { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SpinConfigResponse
|
||||||
|
{
|
||||||
|
public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost;
|
||||||
|
public IReadOnlyList<SpinReward> 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,65 @@ public sealed class CreditStore
|
|||||||
return Response(CreditOperationResult.InsufficientTokens, account);
|
return Response(CreditOperationResult.InsufficientTokens, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SpinResponse> 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<UserCreditAccount>(
|
||||||
|
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
||||||
|
{
|
||||||
|
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
|
||||||
|
SlotMachineEconomy.RollingSpinLimit
|
||||||
|
})));
|
||||||
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Not(
|
||||||
|
Builders<UserCreditAccount>.Filter.ElemMatch(
|
||||||
|
value => value.Spins, spin => spin.OperationId == operationId)) &
|
||||||
|
belowSpinCap;
|
||||||
|
var update = Builders<UserCreditAccount>.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<UserCreditAccount>
|
||||||
|
{
|
||||||
|
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<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||||
{
|
{
|
||||||
var now = _clock.GetUtcNow().UtcDateTime;
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
@@ -166,6 +225,8 @@ public sealed class CreditStore
|
|||||||
.SetOnInsert(value => value.InitialExamGrantApplied, true)
|
.SetOnInsert(value => value.InitialExamGrantApplied, true)
|
||||||
.SetOnInsert(value => value.RecentEarnings, [])
|
.SetOnInsert(value => value.RecentEarnings, [])
|
||||||
.SetOnInsert(value => value.Purchases, [])
|
.SetOnInsert(value => value.Purchases, [])
|
||||||
|
.SetOnInsert(value => value.Spins, [])
|
||||||
|
.SetOnInsert(value => value.RecentSpins, [])
|
||||||
.SetOnInsert(value => value.AppliedOperations, [])
|
.SetOnInsert(value => value.AppliedOperations, [])
|
||||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||||
new UpdateOptions { IsUpsert = true });
|
new UpdateOptions { IsUpsert = true });
|
||||||
@@ -183,6 +244,14 @@ public sealed class CreditStore
|
|||||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
|
||||||
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
|
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
|
||||||
|
await _accounts.UpdateOneAsync(
|
||||||
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
|
||||||
|
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
|
||||||
|
await _accounts.UpdateOneAsync(
|
||||||
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
|
||||||
|
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
|
||||||
await _accounts.UpdateOneAsync(
|
await _accounts.UpdateOneAsync(
|
||||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
|
||||||
@@ -193,6 +262,12 @@ public sealed class CreditStore
|
|||||||
Builders<UserCreditAccount>.Update.PullFilter(
|
Builders<UserCreditAccount>.Update.PullFilter(
|
||||||
value => value.RecentEarnings,
|
value => value.RecentEarnings,
|
||||||
earning => earning.CreatedUtc <= cutoff));
|
earning => earning.CreatedUtc <= cutoff));
|
||||||
|
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
|
||||||
|
await _accounts.UpdateOneAsync(
|
||||||
|
value => value.UserId == userId,
|
||||||
|
Builders<UserCreditAccount>.Update.PullFilter(
|
||||||
|
value => value.RecentSpins,
|
||||||
|
spin => spin.CreatedUtc <= spinCutoff));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
||||||
@@ -212,4 +287,14 @@ public sealed class CreditStore
|
|||||||
Result = result,
|
Result = result,
|
||||||
Status = ToStatus(account)
|
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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ extends Node
|
|||||||
signal credits_changed
|
signal credits_changed
|
||||||
signal credits_failed(message: String)
|
signal credits_failed(message: String)
|
||||||
signal store_operation_completed(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 EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
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 qbank_reset_tokens: int = 0
|
||||||
var bank_cycle: int = 0
|
var bank_cycle: int = 0
|
||||||
var products: Array = []
|
var products: Array = []
|
||||||
|
var spin_cost := 50
|
||||||
|
var spin_rewards: Array = []
|
||||||
var loaded := false
|
var loaded := false
|
||||||
var loading := false
|
var loading := false
|
||||||
var request: HTTPRequest
|
var request: HTTPRequest
|
||||||
var action_request: HTTPRequest
|
var action_request: HTTPRequest
|
||||||
|
var spin_config_request: HTTPRequest
|
||||||
var request_token := ""
|
var request_token := ""
|
||||||
var api_url := DEFAULT_URL
|
var api_url := DEFAULT_URL
|
||||||
var pending_store_action := ""
|
var pending_store_action := ""
|
||||||
@@ -33,6 +38,10 @@ func _ready() -> void:
|
|||||||
action_request.timeout = 20.0
|
action_request.timeout = 20.0
|
||||||
action_request.request_completed.connect(_on_action_completed)
|
action_request.request_completed.connect(_on_action_completed)
|
||||||
add_child(action_request)
|
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)
|
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")})
|
_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:
|
func _post(path: String, payload: Dictionary) -> void:
|
||||||
if not AuthState.can_send_authenticated_request():
|
if not AuthState.can_send_authenticated_request():
|
||||||
|
pending_store_action = ""
|
||||||
|
credits_failed.emit("You're signed out. Sign back in and try again.")
|
||||||
return
|
return
|
||||||
var headers := PackedStringArray([
|
var headers := PackedStringArray([
|
||||||
"Authorization: Bearer %s" % AuthState.access_token,
|
"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.")
|
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||||
return
|
return
|
||||||
if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY:
|
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)
|
apply_status(parsed)
|
||||||
if pending_store_action == "reset":
|
if pending_store_action == "reset":
|
||||||
ProfileService.reset_qbank_eligibility()
|
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:
|
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:
|
func _on_auth_changed() -> void:
|
||||||
@@ -128,6 +172,7 @@ func _on_auth_changed() -> void:
|
|||||||
qbank_reset_tokens = 0
|
qbank_reset_tokens = 0
|
||||||
bank_cycle = 0
|
bank_cycle = 0
|
||||||
products = []
|
products = []
|
||||||
|
spin_rewards = []
|
||||||
loaded = false
|
loaded = false
|
||||||
loading = false
|
loading = false
|
||||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
|||||||
@@ -361,6 +361,11 @@ func _build_ui() -> void:
|
|||||||
rz_store.main = self
|
rz_store.main = self
|
||||||
screens_container.add_child(rz_store)
|
screens_container.add_child(rz_store)
|
||||||
screens["rz_store"] = 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 = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||||
feedback_dialog.name = "AppFeedbackDialog"
|
feedback_dialog.name = "AppFeedbackDialog"
|
||||||
|
|||||||
@@ -31,4 +31,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
key = "donations"
|
key = "donations"
|
||||||
elif screen_name == "rz_store":
|
elif screen_name == "rz_store":
|
||||||
key = "rz_store"
|
key = "rz_store"
|
||||||
|
elif screen_name == "slot_machine":
|
||||||
|
key = "rz_store"
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -28,6 +28,10 @@ var balance_value_label: Label
|
|||||||
var coin_caption_label: Label
|
var coin_caption_label: Label
|
||||||
var inventory_label: Label
|
var inventory_label: Label
|
||||||
var tab_controls: Array = []
|
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_heading: Label
|
||||||
var section_subtitle: Label
|
var section_subtitle: Label
|
||||||
var products_grid: HFlowContainer
|
var products_grid: HFlowContainer
|
||||||
@@ -49,6 +53,7 @@ func _ready() -> void:
|
|||||||
_build_header()
|
_build_header()
|
||||||
_build_summary_card()
|
_build_summary_card()
|
||||||
_build_tabs()
|
_build_tabs()
|
||||||
|
_build_slot_machine_banner()
|
||||||
|
|
||||||
section_heading = Label.new()
|
section_heading = Label.new()
|
||||||
section_heading.text = "Featured"
|
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)
|
(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:
|
func _build_footer() -> void:
|
||||||
var dark := UIThemeService.is_dark_mode()
|
var dark := UIThemeService.is_dark_mode()
|
||||||
footer_card = PanelContainer.new()
|
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))
|
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))
|
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||||
_apply_tab_theme(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))
|
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_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_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||||
|
|||||||
Reference in New Issue
Block a user