extends VBoxContainer const Brand := preload("res://scripts/ui/medmind_style.gd") const STAR_ICON := preload("res://assets/icons/lucide/star.svg") const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg") const CABINET_FRAME := preload("res://assets/slot_machine/cabinet_frame.png") const LEVER_TEXTURE := preload("res://assets/slot_machine/lever.png") const SLOT_COIN := preload("res://assets/slot_machine/slot_coin.png") const SLOT_GEM := preload("res://assets/slot_machine/slot_gem.png") const SLOT_POTION := preload("res://assets/slot_machine/slot_potion.png") const SLOT_SHIELD := preload("res://assets/slot_machine/slot_shield.png") const SLOT_COIN_STACK := preload("res://assets/slot_machine/slot_coin_stack.png") const SLOT_GIFT := preload("res://assets/slot_machine/slot_gift.png") const REEL_SYMBOLS := [SLOT_COIN, SLOT_GEM, SLOT_POTION, SLOT_SHIELD, SLOT_COIN_STACK, SLOT_GIFT] # fraction-of-image rect of the cabinet's empty reel window, measured against cabinet_frame.png const WINDOW_RECT := Rect2(0.203, 0.246, 0.591, 0.604) const REWARD_ICONS := { "jackpot": {"texture": SLOT_COIN, "modulate": false}, "epic": {"texture": SLOT_GEM, "modulate": false}, "great": {"texture": SLOT_SHIELD, "modulate": false}, "exam_token": {"texture": CLOCK_ICON, "modulate": true}, "qbank_reset": {"texture": SLOT_GIFT, "modulate": false}, "nice": {"texture": SLOT_COIN_STACK, "modulate": false}, "good_luck": {"texture": SLOT_COIN, "modulate": false, "tint": true}, } var main: Control var subtitle_label: Label var machine_root: Control var reel_columns: Array = [] var win_up_to_card: PanelContainer var win_up_to_label: Label var cost_card: PanelContainer var cost_value_label: Label var auto_card: PanelContainer 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) var left_column := VBoxContainer.new() left_column.add_theme_constant_override("separation", 16) left_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL body.add_child(left_column) _build_machine(left_column) _build_controls(left_column) _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: 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: machine_root = Control.new() machine_root.custom_minimum_size = Vector2(560, 560) machine_root.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN machine_root.size_flags_vertical = Control.SIZE_SHRINK_BEGIN parent.add_child(machine_root) var frame := TextureRect.new() frame.texture = CABINET_FRAME frame.set_anchors_preset(Control.PRESET_FULL_RECT) frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE frame.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT frame.mouse_filter = Control.MOUSE_FILTER_IGNORE machine_root.add_child(frame) var window := Control.new() window.anchor_left = WINDOW_RECT.position.x window.anchor_top = WINDOW_RECT.position.y window.anchor_right = WINDOW_RECT.position.x + WINDOW_RECT.size.x window.anchor_bottom = WINDOW_RECT.position.y + WINDOW_RECT.size.y window.mouse_filter = Control.MOUSE_FILTER_IGNORE machine_root.add_child(window) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) window.add_child(center) var reels_grid := HBoxContainer.new() reels_grid.add_theme_constant_override("separation", 16) center.add_child(reels_grid) reel_columns.clear() for _column_index in range(3): var reel_col := VBoxContainer.new() reel_col.add_theme_constant_override("separation", 10) reels_grid.add_child(reel_col) var cells: Array = [] for _row_index in range(3): var icon := TextureRect.new() icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] icon.custom_minimum_size = Vector2(66, 66) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED reel_col.add_child(icon) cells.append(icon) reel_columns.append(cells) var lever := TextureRect.new() lever.texture = LEVER_TEXTURE lever.anchor_left = 0.80 lever.anchor_right = 1.06 lever.anchor_top = 0.24 lever.anchor_bottom = 0.88 lever.expand_mode = TextureRect.EXPAND_IGNORE_SIZE lever.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT lever.mouse_filter = Control.MOUSE_FILTER_IGNORE machine_root.add_child(lever) func _build_controls(parent: Control) -> void: var dark := UIThemeService.is_dark_mode() win_up_to_card = PanelContainer.new() win_up_to_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) parent.add_child(win_up_to_card) var win_margin := MarginContainer.new() win_margin.add_theme_constant_override("margin_left", 14) win_margin.add_theme_constant_override("margin_right", 14) win_margin.add_theme_constant_override("margin_top", 10) win_margin.add_theme_constant_override("margin_bottom", 10) win_up_to_card.add_child(win_margin) 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)) win_margin.add_child(win_up_to_label) var controls_row := HBoxContainer.new() controls_row.add_theme_constant_override("separation", 12) parent.add_child(controls_row) cost_card = PanelContainer.new() cost_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) controls_row.add_child(cost_card) var cost_margin := MarginContainer.new() cost_margin.add_theme_constant_override("margin_left", 14) cost_margin.add_theme_constant_override("margin_right", 14) cost_margin.add_theme_constant_override("margin_top", 8) cost_margin.add_theme_constant_override("margin_bottom", 8) cost_card.add_child(cost_margin) var cost_box := VBoxContainer.new() cost_box.add_theme_constant_override("separation", 0) cost_margin.add_child(cost_box) 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) spin_button = Button.new() spin_button.text = "Spin" spin_button.custom_minimum_size = Vector2(140, 58) spin_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL spin_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER spin_button.add_theme_font_override("font", Brand.font) spin_button.add_theme_font_size_override("font_size", 17) _style_spin_button() spin_button.pressed.connect(_on_spin_pressed) controls_row.add_child(spin_button) auto_card = PanelContainer.new() auto_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) controls_row.add_child(auto_card) var auto_margin := MarginContainer.new() auto_margin.add_theme_constant_override("margin_left", 14) auto_margin.add_theme_constant_override("margin_right", 14) auto_margin.add_theme_constant_override("margin_top", 8) auto_margin.add_theme_constant_override("margin_bottom", 8) auto_card.add_child(auto_margin) var auto_box := VBoxContainer.new() auto_box.add_theme_constant_override("separation", 0) auto_margin.add_child(auto_box) 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) 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 parent.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)) win_up_to_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) win_up_to_label.add_theme_color_override("font_color", Brand.text_color(dark)) cost_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) cost_value_label.add_theme_color_override("font_color", Brand.text_color(dark)) auto_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_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(36, 36) icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 18)) var icon := TextureRect.new() var spec: Dictionary = REWARD_ICONS.get(key, {"texture": SLOT_COIN, "modulate": false}) icon.texture = spec.get("texture") icon.custom_minimum_size = Vector2(22, 22) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED if spec.get("modulate", false): icon.modulate = accent elif spec.get("tint", false): icon.modulate = Color("94a3b8") 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_columns.size()): _spin_one_reel(reel_columns[index], 0.9 + index * 0.25) func _spin_one_reel(cells: Array, duration: float) -> void: var elapsed := 0.0 var interval := 0.06 while elapsed < duration: var symbol: Texture2D = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] for cell in cells: cell.texture = symbol await get_tree().create_timer(interval).timeout elapsed += interval interval = minf(interval * 1.18, 0.22) for cell in cells: cell.texture = SLOT_COIN 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_root.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 = SLOT_COIN 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