Files
tekton/scripts/ui/gacha_panel.gd
adtpdn decdb74ade chore: release version 2.3.5 and refactor lobby
Bump export_presets.cfg version to 2.3.5. Update CHANGELOG_DRAFT.md.
Refactor lobby.gd into LobbyChat, LobbyMainMenu, LobbyRoomList, LobbyRoom.
Move Nakama config to environment variables in nakama_manager.gd.
Derive auth_manager.gd encryption key from OS.get_unique_id().sha256_text().
Remove Steam email auth fallback. Require auth ticket.
Make GachaManager.pull() async in gacha_panel.gd.
Remove dummy wallet seeding. Add store_type to IAP payload.
Validate IAP receipts server-side in economy.lua.
Register gacha module in main.lua.
Clean backend_service.gd stubs.
Fix featured_banners type safety in gacha_manager.gd. Guards non-array responses.
Move tiles_armagedon_a1.res to assets/models/meshes/. Fix import fallback_path.
2026-05-22 12:08:11 +08:00

480 lines
18 KiB
GDScript

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()