feat: fix gatcha, and login flow connection

This commit is contained in:
2026-04-24 02:50:16 +08:00
parent d97109aa8d
commit 5a08db38de
15 changed files with 400 additions and 145 deletions
+14 -7
View File
@@ -73,7 +73,8 @@ func _try_restore_session() -> void:
session = refreshed
_save_session(session, saved_auth_mode)
else:
print("[AuthManager] Session refresh failed, need to re-login")
var err_msg: String = refreshed.get_exception().message
print("[AuthManager] Session refresh failed (%s) — need to re-login" % err_msg)
return
else:
print("[AuthManager] Session expired, need to re-login")
@@ -85,12 +86,18 @@ func _try_restore_session() -> void:
is_guest = auth_mode == AuthMode.GUEST
var socket_success := await _connect_socket()
if socket_success:
await _load_user_profile()
is_authenticated = true
emit_signal("session_restored")
emit_signal("auth_completed", true, current_user)
print("[AuthManager] Session restored successfully")
if not socket_success:
# Clean up so manual login starts from a blank slate
push_warning("[AuthManager] Session restore failed — socket could not connect. Resetting state.")
NakamaManager.session = null
NakamaManager.socket = null
return
await _load_user_profile()
is_authenticated = true
emit_signal("session_restored")
emit_signal("auth_completed", true, current_user)
print("[AuthManager] Session restored successfully")
func _save_session(session: NakamaSession, mode: AuthMode) -> void:
var file := FileAccess.open_encrypted_with_pass(SESSION_FILE, FileAccess.WRITE, ENCRYPTION_KEY)
+8 -2
View File
@@ -405,11 +405,17 @@ func _reload_wallet() -> void:
func save_wallet() -> void:
"""Persist wallet deductions and fragment counts to Nakama storage."""
if not NakamaManager.session: return
if not NakamaManager.session:
print("[UserProfileManager] save_wallet: no session, saved in-memory only.")
return
var write_objs: Array = [
NakamaWriteStorageObject.new(PROFILE_COLLECTION, "fragments", 1, 1, JSON.stringify(fragments), "")
]
await NakamaManager.client.write_storage_objects_async(NakamaManager.session, write_objs)
var result = await NakamaManager.client.write_storage_objects_async(NakamaManager.session, write_objs)
if result.is_exception():
push_warning("[UserProfileManager] save_wallet failed: " + result.get_exception().message)
else:
print("[UserProfileManager] Fragments saved.")
# =============================================================================
# Stats Management
+2 -2
View File
@@ -46,9 +46,9 @@ func _ready() -> void:
status_label.text = "Checking versions..."
# Instant bypass in editor — go straight to game
# In the editor always skip update check — login screen handles session restore
if OS.has_feature("editor"):
print("[BootScreen] Editor detected — bypassing update check.")
print("[BootScreen] Editor mode — bypassing update check.")
_begin_resource_load()
return
+272 -35
View File
@@ -1,7 +1,7 @@
extends Control
## GachaPanel — Two-banner gacha interface.
## Banners: Star (✦) and Gold (▤)
## Pull results shown in animated card reveal.
## CSGO-style spinning case reveal animation.
signal closed
@@ -16,20 +16,34 @@ signal closed
@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 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.80, 0.80, 0.80),
"uncommon": Color(0.30, 0.85, 0.35),
"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)
}
@@ -39,6 +53,12 @@ const RARITY_LABELS := {
"rare": "Rare",
"real_prize": "✨ REAL PRIZE ✨"
}
const RARITY_ICONS := {
"common": "",
"uncommon": "🟩",
"rare": "🟦",
"real_prize": ""
}
# ─── Lifecycle ────────────────────────────────────────────────────────────────
func _ready() -> void:
@@ -50,12 +70,21 @@ func _ready() -> void:
close_result_btn.pressed.connect(func(): result_panel.hide())
craft_btn.pressed.connect(_on_open_craft)
result_panel.hide()
_ensure_dummy_wallet()
_switch_banner("star")
func show_panel() -> void:
show()
_refresh_ui()
# ─── Dummy wallet so editor testing works without Nakama ─────────────────────
func _ensure_dummy_wallet() -> void:
var w: Dictionary = UserProfileManager.wallet
if w.get("star", 0) == 0:
UserProfileManager.wallet["star"] = 3200
if w.get("gold", 0) == 0:
UserProfileManager.wallet["gold"] = 1500
# ─── Banner switching ─────────────────────────────────────────────────────────
func _switch_banner(id: String) -> void:
_current_banner = id
@@ -91,6 +120,7 @@ func _refresh_ui() -> void:
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" +
@@ -107,25 +137,230 @@ func _refresh_ui() -> void:
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..."
var results: Array = await _run_pull(count)
_pulling = false
await get_tree().process_frame
var results: Array = 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 _run_pull(count: int) -> Array:
# Yield one frame so UI updates first
await get_tree().process_frame
return GachaManager.pull(_current_banner, count)
func _build_roll_ui() -> void:
if _roll_overlay:
_roll_overlay.queue_free()
# ─── Result display ───────────────────────────────────────────────────────────
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:
# Clear old cards
for c in result_grid.get_children(): c.queue_free()
await get_tree().process_frame
@@ -133,8 +368,7 @@ func _show_results(results: Array) -> void:
for res in results:
var card := _make_result_card(res)
result_grid.add_child(card)
# Staggered reveal
await get_tree().create_timer(0.08).timeout
await get_tree().create_timer(0.06).timeout
func _make_result_card(res: Dictionary) -> PanelContainer:
var rarity: String = res.get("rarity", "common")
@@ -144,6 +378,13 @@ func _make_result_card(res: Dictionary) -> PanelContainer:
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)
@@ -155,32 +396,29 @@ func _make_result_card(res: Dictionary) -> PanelContainer:
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
margin.add_child(vbox)
# Rarity icon
var icon_lbl := Label.new()
icon_lbl.text = _rarity_icon(rarity)
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)
# Item name
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", col)
name_lbl.add_theme_color_override("font_color", Color.WHITE)
vbox.add_child(name_lbl)
# Rarity label
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.lerp(Color.WHITE, 0.3))
rar_lbl.add_theme_color_override("font_color", col)
vbox.add_child(rar_lbl)
# Flash animation
# Fade-in
panel.modulate.a = 0.0
var tween := create_tween()
tween.tween_property(panel, "modulate:a", 1.0, 0.25)
@@ -190,22 +428,21 @@ func _make_result_card(res: Dictionary) -> PanelContainer:
return panel
func _rarity_icon(rarity: String) -> String:
match rarity:
"common": return ""
"uncommon": return "🟩"
"rare": return "🟦"
"real_prize": return ""
return ""
# ─── Craft link ───────────────────────────────────────────────────────────────
func _on_open_craft() -> void:
hide()
# Find or load the fragment craft panel in the scene tree
var main = get_tree().current_scene
var fcp = main.get_node_or_null("FragmentCraftPanel")
if fcp:
fcp.show_panel()
# 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()