extends PanelContainer const Brand := preload("res://scripts/ui/medmind_style.gd") 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 CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg") 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}, } const MACHINE_SIZE := 380.0 const CELL := 71.0 const REEL_SEPARATION := 4 var main: Control var drag_active := false var drag_offset := Vector2.ZERO var has_been_positioned := false var machine_root: Control var lever_button: TextureButton var reel_states: Array = [] var status_label: Label var _spinning := false func _ready() -> void: name = "RzSlotMachinePanel" visible = false top_level = true mouse_filter = Control.MOUSE_FILTER_STOP custom_minimum_size = Vector2(440, 470) size = custom_minimum_size _build_ui() if not get_viewport().size_changed.is_connected(_on_viewport_size_changed): get_viewport().size_changed.connect(_on_viewport_size_changed) _on_viewport_size_changed() CreditsService.credits_changed.connect(_refresh_spin_button) UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls()) refresh_theme_controls() _refresh_spin_button() func open_panel() -> void: visible = true move_to_front() if not has_been_positioned: var viewport_size := get_viewport_rect().size position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0) has_been_positioned = true _clamp_to_viewport() CreditsService.refresh() CreditsService.fetch_spin_config() _refresh_spin_button() func close_panel() -> void: visible = false drag_active = false func toggle_panel() -> void: if visible: close_panel() else: open_panel() func _build_ui() -> void: var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 12) margin.add_theme_constant_override("margin_right", 12) margin.add_theme_constant_override("margin_top", 12) margin.add_theme_constant_override("margin_bottom", 14) add_child(margin) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) margin.add_child(content) _build_title_bar(content) _build_machine(content) status_label = Label.new() status_label.add_theme_font_override("font", Brand.font) status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER content.add_child(status_label) func _build_title_bar(content: VBoxContainer) -> void: var title_bar := HBoxContainer.new() title_bar.name = "SlotMachineTitleBar" title_bar.mouse_filter = Control.MOUSE_FILTER_STOP title_bar.custom_minimum_size.y = 34 title_bar.gui_input.connect(_on_title_bar_gui_input) content.add_child(title_bar) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE title_bar.add_child(spacer) var close := Button.new() close.name = "SlotMachineClose" close.text = "×" close.tooltip_text = "Close slot machine" close.custom_minimum_size = Vector2(36, 32) close.focus_mode = Control.FOCUS_NONE close.pressed.connect(close_panel) title_bar.add_child(close) func _build_machine(parent: Control) -> void: var center := CenterContainer.new() parent.add_child(center) machine_root = Control.new() machine_root.custom_minimum_size = Vector2(MACHINE_SIZE, MACHINE_SIZE) center.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 reel_center := CenterContainer.new() reel_center.set_anchors_preset(Control.PRESET_FULL_RECT) window.add_child(reel_center) var reels_grid := HBoxContainer.new() reels_grid.add_theme_constant_override("separation", REEL_SEPARATION) reel_center.add_child(reels_grid) reel_states.clear() for _column_index in range(3): var clip := Control.new() clip.clip_contents = true clip.custom_minimum_size = Vector2(CELL, CELL * 3) reels_grid.add_child(clip) var strip := VBoxContainer.new() strip.add_theme_constant_override("separation", 0) clip.add_child(strip) var icons: Array = [] for _row_index in range(4): var icon := TextureRect.new() icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] icon.custom_minimum_size = Vector2(CELL, CELL) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED strip.add_child(icon) icons.append(icon) reel_states.append({ "strip": strip, "icons": icons, "step": CELL, "speed": 420.0, "spinning": false, "stop_requested": false, "target_texture": null, "recycles_after_target": -1, "done": true, }) lever_button = TextureButton.new() lever_button.texture_normal = LEVER_TEXTURE lever_button.ignore_texture_size = true lever_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED lever_button.anchor_left = 0.80 lever_button.anchor_right = 1.06 lever_button.anchor_top = 0.24 lever_button.anchor_bottom = 0.88 lever_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND lever_button.tooltip_text = "Pull to spin" lever_button.resized.connect(func(): lever_button.pivot_offset = Vector2(lever_button.size.x * 0.5, lever_button.size.y * 0.92)) lever_button.pressed.connect(_on_lever_pulled) machine_root.add_child(lever_button) func refresh_theme_controls() -> void: var dark := UIThemeService.is_dark_mode() add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) func _refresh_spin_button() -> void: if lever_button == null: return lever_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost func _on_lever_pulled() -> void: _play_lever_pull_animation() _on_spin_pressed() func _play_lever_pull_animation() -> void: var tween := create_tween() tween.tween_property(lever_button, "rotation_degrees", -20.0, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.tween_property(lever_button, "rotation_degrees", 0.0, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT) func _on_spin_pressed() -> void: if _spinning: return _spinning = true _refresh_spin_button() await _do_one_spin() _spinning = false _refresh_spin_button() func _do_one_spin() -> bool: for state in reel_states: state["stop_requested"] = false state["target_texture"] = null state["recycles_after_target"] = -1 state["done"] = false _spin_reel(state) var result := await _await_spin_result() if not result.get("ok", false): for state in reel_states: state["target_texture"] = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] state["stop_requested"] = true await _await_reels_done() 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_key := str(result.get("reward_key", "")) var reward_texture := _icon_for_reward(reward_key) for index in range(reel_states.size()): var state: Dictionary = reel_states[index] get_tree().create_timer(index * 0.25).timeout.connect(func(): state["target_texture"] = reward_texture state["stop_requested"] = true) await _await_reels_done() 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 _icon_for_reward(key: String) -> Texture2D: var spec: Dictionary = REWARD_ICONS.get(key, {}) var texture: Texture2D = spec.get("texture", SLOT_COIN) return texture if texture != null else SLOT_COIN func _await_reels_done() -> void: while true: var all_done := true for state in reel_states: if not state.get("done", false): all_done = false break if all_done: return await get_tree().process_frame 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 _spin_reel(state: Dictionary) -> void: state["spinning"] = true var strip: VBoxContainer = state["strip"] var icons: Array = state["icons"] var step: float = state["step"] var speed: float = state["speed"] while true: var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45 strip.position.y -= current_speed * get_process_delta_time() if strip.position.y <= -step: strip.position.y += step var front: TextureRect = icons.pop_front() strip.move_child(front, strip.get_child_count() - 1) icons.append(front) if state["stop_requested"] and state["recycles_after_target"] == -1: front.texture = state["target_texture"] state["recycles_after_target"] = 0 else: front.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()] if state["recycles_after_target"] >= 0: state["recycles_after_target"] += 1 if state["recycles_after_target"] == 2: strip.position.y = 0.0 break await get_tree().process_frame state["spinning"] = false state["done"] = true 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.expand_mode = TextureRect.EXPAND_IGNORE_SIZE coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED 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 _on_title_bar_gui_input(event: InputEvent) -> void: var touch_event := event as InputEventScreenTouch if touch_event != null: drag_active = touch_event.pressed if drag_active: drag_offset = touch_event.position get_viewport().set_input_as_handled() return var touch_drag := event as InputEventScreenDrag if touch_drag != null and drag_active: global_position += touch_drag.relative _clamp_to_viewport() get_viewport().set_input_as_handled() return var mouse_event := event as InputEventMouseButton if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT: drag_active = mouse_event.pressed if drag_active: drag_offset = mouse_event.global_position - global_position get_viewport().set_input_as_handled() return var motion := event as InputEventMouseMotion if motion != null and drag_active: global_position = motion.global_position - drag_offset _clamp_to_viewport() get_viewport().set_input_as_handled() func _on_viewport_size_changed() -> void: _clamp_to_viewport() func _clamp_to_viewport() -> void: if not is_inside_tree(): return var viewport_size := get_viewport_rect().size position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0)) position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0))