extends Control ## GachaPanel — Two-banner gacha interface. ## Banners: Star (✦) and Gold (▤) ## CSGO-style spinning case reveal animation. signal closed # ─── Node refs ─────────────────────────────────────────────────────────────── @onready var back_btn := %BackBtn as Button @onready var star_tab_btn := %StarTabBtn as Button @onready var gold_tab_btn := %GoldTabBtn as Button @onready var banner_label := %BannerLabel as Label @onready var gold_label := %GoldLabel as Label @onready var star_label := %StarLabel as Label @onready var pity_label := %PityLabel as Label @onready var pull_1_btn := %Pull1Btn as Button @onready var pull_10_btn := %Pull10Btn as Button @onready var cost_1_label := %Cost1Label as Label @onready var cost_10_label := %Cost10Label as Label @onready var rates_label := %RatesLabel as RichTextLabel @onready var result_panel := %ResultPanel as PanelContainer @onready var result_grid := %ResultGrid as GridContainer @onready var close_result_btn := %CloseResultBtn as Button @onready var craft_btn := %CraftBtn as Button @onready var status_label := %StatusLabel as Label # ─── CSGO Roll nodes (built at runtime) ────────────────────────────────────── var _roll_overlay: Control = null # dims the screen during roll var _roll_container: Control = null # clip rect var _roll_strip: HBoxContainer = null # scrolling items var _roll_arrow: Control = null # center arrow indicator var _roll_tween: Tween = null var _roll_lbl: Label = null var _skip_roll: bool = false const CARD_W := 130 const CARD_H := 160 const CARD_GAP := 8 const STRIP_VISIBLE_COUNT := 7 # how many cards show at once # ─── State ─────────────────────────────────────────────────────────────────── var _current_banner: String = "star" var _pulling: bool = false const RARITY_COLORS := { "common": Color(0.75, 0.75, 0.75), "uncommon": Color(0.20, 0.85, 0.35), "rare": Color(0.20, 0.55, 1.00), "real_prize": Color(1.00, 0.75, 0.10) } const RARITY_LABELS := { "common": "Common", "uncommon": "Uncommon", "rare": "Rare", "real_prize": "✨ REAL PRIZE ✨" } const RARITY_ICONS := { "common": "⬜", "uncommon": "🟩", "rare": "🟦", "real_prize": "✨" } # ─── Lifecycle ──────────────────────────────────────────────────────────────── func _ready() -> void: back_btn.pressed.connect(_on_close) star_tab_btn.pressed.connect(func(): _switch_banner("star")) gold_tab_btn.pressed.connect(func(): _switch_banner("gold")) pull_1_btn.pressed.connect(func(): _do_pull(1)) pull_10_btn.pressed.connect(func(): _do_pull(10)) close_result_btn.pressed.connect(func(): result_panel.hide()) craft_btn.pressed.connect(_on_open_craft) result_panel.hide() _switch_banner("star") func show_panel() -> void: show() _refresh_ui() # ─── Banner switching ───────────────────────────────────────────────────────── func _switch_banner(id: String) -> void: _current_banner = id # Create active tab style (dark blue) var active_style := StyleBoxFlat.new() active_style.bg_color = Color(0.1, 0.19, 0.27, 1) active_style.content_margin_left = 16.0 active_style.content_margin_top = 14.0 active_style.content_margin_right = 16.0 active_style.content_margin_bottom = 14.0 active_style.corner_radius_top_left = 8 active_style.corner_radius_top_right = 8 active_style.corner_radius_bottom_right = 8 active_style.corner_radius_bottom_left = 8 # Create inactive tab style (cyan) var inactive_style := StyleBoxFlat.new() inactive_style.bg_color = Color(0.33, 0.62, 0.78, 1) inactive_style.content_margin_left = 16.0 inactive_style.content_margin_top = 14.0 inactive_style.content_margin_right = 16.0 inactive_style.content_margin_bottom = 14.0 inactive_style.corner_radius_top_left = 8 inactive_style.corner_radius_top_right = 8 inactive_style.corner_radius_bottom_right = 8 inactive_style.corner_radius_bottom_left = 8 # Apply styles star_tab_btn.add_theme_stylebox_override("normal", active_style if id == "star" else inactive_style) star_tab_btn.add_theme_stylebox_override("hover", active_style if id == "star" else inactive_style) star_tab_btn.add_theme_stylebox_override("pressed", active_style if id == "star" else inactive_style) gold_tab_btn.add_theme_stylebox_override("normal", active_style if id == "gold" else inactive_style) gold_tab_btn.add_theme_stylebox_override("hover", active_style if id == "gold" else inactive_style) gold_tab_btn.add_theme_stylebox_override("pressed", active_style if id == "gold" else inactive_style) _refresh_ui() func _refresh_ui() -> void: var gd: Dictionary = GachaManager.data var banner: Dictionary = gd.get("banners", {}).get(_current_banner, {}) if banner.is_empty(): return var currency: String = banner.get("currency", "star") var icon: String = "✦" if currency == "star" else "▤" var bal: int = GachaManager.get_balance(_current_banner) var pity: int = GachaManager.get_pity(_current_banner) var pity_at: int = banner.get("pity_at", 90) var c1: int = banner.get("pull_1_cost", 0) var c10: int = banner.get("pull_10_cost", 0) var rates: Dictionary = banner.get("rates", {}) banner_label.text = banner.get("name", "Banner") # Update both gold and star labels star_label.text = str(UserProfileManager.wallet.get("star", 0)) gold_label.text = str(UserProfileManager.wallet.get("gold", 0)) pity_label.text = "Pity: %d / %d" % [pity, pity_at] cost_1_label.text = "%s %d" % [icon, c1] cost_10_label.text = "%s %d" % [icon, c10] pull_1_btn.disabled = (bal < c1) or _pulling pull_10_btn.disabled = (bal < c10) or _pulling var real_pool: Array = gd.get("pools", {}).get("real_prize", []) var real_names: Array = [] for rid in real_pool: var rd = gd.get("real_prize_catalog", {}).get(rid, {}) real_names.append(rd.get("name", rid)) if not rates_label: return rates_label.text = ( "Common %.0f%% Uncommon %.0f%% Rare %.0f%% ✨Real Prize %.1f%%\n" + "Guaranteed Real Prize every %d pulls (current pity: %d)\n\n" + "✨ Exclusive Real Prizes:\n" + "\n".join(real_names.map(func(n): return " • " + n)) ) % [ rates.get("common", 0) * 100, rates.get("uncommon", 0) * 100, rates.get("rare", 0) * 100, rates.get("real_prize", 0) * 100, pity_at, pity ] # ─── Pull ───────────────────────────────────────────────────────────────────── func _do_pull(count: int) -> void: if _pulling: return _pulling = true pull_1_btn.disabled = true pull_10_btn.disabled = true status_label.text = "Rolling..." await get_tree().process_frame var results: Array = await GachaManager.pull(_current_banner, count) if results.is_empty(): status_label.text = "❌ Not enough currency!" _pulling = false _refresh_ui() return status_label.text = "" # Show CSGO roll for first result, then list the rest await _play_case_roll(results) _pulling = false _refresh_ui() # ─── CSGO Case Roll ─────────────────────────────────────────────────────────── func _play_case_roll(results: Array) -> void: _skip_roll = false _build_roll_ui() await get_tree().process_frame for i in range(results.size()): if _skip_roll: break var winner: Dictionary = results[i] if _roll_lbl: if results.size() > 1: _roll_lbl.text = "🎰 Opening %d / %d..." % [i + 1, results.size()] else: _roll_lbl.text = "🎰 Opening..." # Filler pool: weighted toward common so rare winner pops var filler_pool: Array = [] var gd = GachaManager.data for rarity in ["common", "common", "common", "uncommon", "uncommon", "rare"]: var pool: Array = gd.get("pools", {}).get(rarity, []) if pool.is_empty(): continue var fid: String = pool[randi() % pool.size()] var fname: String = gd.get("fragments", {}).get(fid, {}).get("name", fid) filler_pool.append({"id": fid, "rarity": rarity, "name": fname}) # Build full strip items const FILLER_BEFORE := 30 const FILLER_AFTER := 5 var strip_items: Array = [] for j in range(FILLER_BEFORE): strip_items.append(filler_pool[j % filler_pool.size()]) strip_items.append(winner) for j in range(FILLER_AFTER): strip_items.append(filler_pool[j % filler_pool.size()]) # Populate strip for child in _roll_strip.get_children(): child.queue_free() for item in strip_items: _roll_strip.add_child(_make_roll_card(item)) await get_tree().process_frame # Calculate scroll target: winner is at index FILLER_BEFORE var step := CARD_W + CARD_GAP var strip_w := float(_roll_strip.get_children().size() * step) var container_w := float(STRIP_VISIBLE_COUNT * step - CARD_GAP) var center_offset := container_w / 2.0 - CARD_W / 2.0 var target_x := -(FILLER_BEFORE * step - center_offset) # Add small random offset so it doesn't always stop dead center target_x += randf_range(-20, 20) # Start far left (fast) _roll_strip.position.x = 0.0 _roll_overlay.visible = true # Animate: fast then ease-out _roll_tween = create_tween() _roll_tween.set_ease(Tween.EASE_OUT) _roll_tween.set_trans(Tween.TRANS_QUINT) # Faster spin for multiples to save time var duration = 3.5 if results.size() == 1 else 2.2 _roll_tween.tween_property(_roll_strip, "position:x", target_x, duration) await _roll_tween.finished if _skip_roll: break # Flash winner card var winner_card = _roll_strip.get_children()[FILLER_BEFORE] var winner_col = RARITY_COLORS.get(winner.get("rarity", "common"), Color.WHITE) var flash_tween := create_tween().set_loops(3) flash_tween.tween_property(winner_card, "modulate", winner_col * 2.0, 0.12) flash_tween.tween_property(winner_card, "modulate", Color.WHITE, 0.12) await flash_tween.finished if not _skip_roll and i < results.size() - 1: await get_tree().create_timer(0.4).timeout # Tear down roll, show full results _roll_overlay.visible = false _show_results(results) func _build_roll_ui() -> void: if _roll_overlay: _roll_overlay.queue_free() var step := CARD_W + CARD_GAP var container_w := STRIP_VISIBLE_COUNT * step - CARD_GAP # Dim overlay _roll_overlay = Control.new() _roll_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _roll_overlay.visible = false add_child(_roll_overlay) var bg := ColorRect.new() bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) bg.color = Color(0.02, 0.02, 0.06, 0.92) _roll_overlay.add_child(bg) # Center container _roll_container = Control.new() _roll_container.custom_minimum_size = Vector2(container_w, CARD_H) _roll_container.clip_contents = true _roll_container.set_anchors_and_offsets_preset(Control.PRESET_CENTER) _roll_container.offset_left = -container_w / 2.0 _roll_container.offset_right = container_w / 2.0 _roll_container.offset_top = -CARD_H / 2.0 _roll_container.offset_bottom = CARD_H / 2.0 _roll_overlay.add_child(_roll_container) # Strip _roll_strip = HBoxContainer.new() _roll_strip.add_theme_constant_override("separation", CARD_GAP) _roll_strip.position = Vector2.ZERO _roll_container.add_child(_roll_strip) # Center arrow indicator var arrow_line := ColorRect.new() arrow_line.color = Color(1, 0.9, 0.2, 0.9) arrow_line.size = Vector2(3, CARD_H) arrow_line.position = Vector2(container_w / 2.0 - 1, 0) _roll_container.add_child(arrow_line) # Label above _roll_lbl = Label.new() _roll_lbl.text = "🎰 Opening..." _roll_lbl.add_theme_font_size_override("font_size", 20) _roll_lbl.add_theme_color_override("font_color", Color(1, 0.9, 0.3)) _roll_lbl.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP) _roll_lbl.offset_top = -CARD_H / 2.0 - 44 _roll_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _roll_overlay.add_child(_roll_lbl) # Skip button var skip_btn := Button.new() skip_btn.text = "Skip Animation ⏭" skip_btn.add_theme_font_size_override("font_size", 16) skip_btn.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) skip_btn.offset_left = -200 skip_btn.offset_top = -80 skip_btn.offset_right = -40 skip_btn.offset_bottom = -40 skip_btn.pressed.connect(func(): _skip_roll = true if _roll_tween and _roll_tween.is_running(): _roll_tween.kill() _roll_tween.finished.emit() # Ensure await yields correctly ) _roll_overlay.add_child(skip_btn) func _make_roll_card(item: Dictionary) -> PanelContainer: var rarity: String = item.get("rarity", "common") var col: Color = RARITY_COLORS.get(rarity, Color.WHITE) var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(CARD_W, CARD_H) # Rarity-colored border via StyleBoxFlat var style := StyleBoxFlat.new() style.bg_color = col.darkened(0.7) style.border_color = col style.set_border_width_all(2) style.set_corner_radius_all(6) panel.add_theme_stylebox_override("panel", style) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 6) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_right", 6) margin.add_theme_constant_override("margin_bottom", 8) panel.add_child(margin) var vbox := VBoxContainer.new() vbox.alignment = BoxContainer.ALIGNMENT_CENTER margin.add_child(vbox) var icon_lbl := Label.new() icon_lbl.text = RARITY_ICONS.get(rarity, "❓") icon_lbl.add_theme_font_size_override("font_size", 40) icon_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER icon_lbl.add_theme_color_override("font_color", col) vbox.add_child(icon_lbl) var name_lbl := Label.new() name_lbl.text = item.get("name", "?") name_lbl.add_theme_font_size_override("font_size", 11) name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART name_lbl.add_theme_color_override("font_color", Color.WHITE) vbox.add_child(name_lbl) var rar_lbl := Label.new() rar_lbl.text = RARITY_LABELS.get(rarity, rarity.capitalize()) rar_lbl.add_theme_font_size_override("font_size", 9) rar_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER rar_lbl.add_theme_color_override("font_color", col) vbox.add_child(rar_lbl) return panel # ─── Result display (after roll finishes) ──────────────────────────────────── func _show_results(results: Array) -> void: for c in result_grid.get_children(): c.queue_free() await get_tree().process_frame result_panel.show() for res in results: var card := _make_result_card(res) result_grid.add_child(card) await get_tree().create_timer(0.06).timeout func _make_result_card(res: Dictionary) -> PanelContainer: var rarity: String = res.get("rarity", "common") var col: Color = RARITY_COLORS.get(rarity, Color.WHITE) var label_txt: String = RARITY_LABELS.get(rarity, rarity.capitalize()) var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(110, 130) var style := StyleBoxFlat.new() style.bg_color = col.darkened(0.7) style.border_color = col style.set_border_width_all(2) style.set_corner_radius_all(6) panel.add_theme_stylebox_override("panel", style) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 8) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_right", 8) margin.add_theme_constant_override("margin_bottom", 8) panel.add_child(margin) var vbox := VBoxContainer.new() vbox.alignment = BoxContainer.ALIGNMENT_CENTER margin.add_child(vbox) var icon_lbl := Label.new() icon_lbl.text = RARITY_ICONS.get(rarity, "❓") icon_lbl.add_theme_font_size_override("font_size", 36) icon_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER icon_lbl.add_theme_color_override("font_color", col) vbox.add_child(icon_lbl) var name_lbl := Label.new() name_lbl.text = res.get("name", "?") name_lbl.add_theme_font_size_override("font_size", 11) name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART name_lbl.add_theme_color_override("font_color", Color.WHITE) vbox.add_child(name_lbl) var rar_lbl := Label.new() rar_lbl.text = label_txt rar_lbl.add_theme_font_size_override("font_size", 9) rar_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER rar_lbl.add_theme_color_override("font_color", col) vbox.add_child(rar_lbl) # Fade-in panel.modulate.a = 0.0 var tween := create_tween() tween.tween_property(panel, "modulate:a", 1.0, 0.25) if rarity == "real_prize": tween.tween_property(panel, "modulate", Color(1.4, 1.3, 0.5, 1.0), 0.2) tween.tween_property(panel, "modulate", Color.WHITE, 0.3) return panel # ─── Craft link ─────────────────────────────────────────────────────────────── func _on_open_craft() -> void: # Load FragmentCraftPanel as overlay on top of GachaPanel var fcp_scene = load("res://scenes/ui/fragment_craft_panel.tscn") if not fcp_scene: status_label.text = "Fragment Craft panel not found" return var fcp = fcp_scene.instantiate() fcp.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(fcp) fcp.closed.connect(func(): fcp.queue_free() _refresh_ui() ) fcp.show_panel() func _on_close() -> void: hide() closed.emit()