Files
step3/scripts/ui/rz_slot_machine_screen.gd
T
hz 1860e6ede6 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.
2026-07-30 00:29:49 -05:00

518 lines
19 KiB
GDScript

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