decdb74ade
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.
480 lines
18 KiB
GDScript
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()
|