feat: add dailylogin feature

This commit is contained in:
2026-05-01 05:07:54 +08:00
parent 54be7bbb25
commit 21875cdf8a
17 changed files with 1262 additions and 34 deletions
+2
View File
@@ -15,6 +15,7 @@ signal profile_loaded(profile: Dictionary)
signal profile_updated
signal profile_update_failed(error: String)
signal avatar_changed(url: String)
signal stats_updated
# Profile data
var profile: Dictionary = {}
@@ -423,6 +424,7 @@ func save_wallet() -> void:
func update_stats(new_stats: Dictionary) -> bool:
stats.merge(new_stats, true)
emit_signal("stats_updated")
if not NakamaManager.session:
return false
+117 -2
View File
@@ -26,6 +26,18 @@ signal closed
@onready var sync_lb_btn := %SyncLeaderboardBtn as Button
@onready var reset_lb_btn := %ResetLBBtn as Button
# Tab: Daily Rewards
@onready var month_option_btn := %MonthOptionBtn as OptionButton
@onready var days_grid := %DaysGrid as GridContainer
@onready var day_config_template := %DayConfigTemplate as VBoxContainer
@onready var load_dr_btn := %LoadDRConfigBtn as Button
@onready var save_dr_btn := %SaveDRConfigBtn as Button
var _daily_reward_config_data: Dictionary = {}
var _current_dr_month: String = ""
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
# -- Data --
var all_users: Array = []
var lb_data: Array = []
@@ -62,7 +74,7 @@ func _apply_plain_style() -> void:
count_label.add_theme_color_override("font_color", CLR_HEADER)
status_label.add_theme_color_override("font_color", CLR_DIM)
for btn: Button in [refresh_btn, close_btn, select_all_btn, deselect_btn]:
for btn: Button in [refresh_btn, close_btn, select_all_btn, deselect_btn, load_dr_btn, save_dr_btn]:
_style_button(btn, Color(0.2, 0.2, 0.24), CLR_TEXT)
_style_button(ban_btn, Color(0.3, 0.2, 0.1), CLR_BTN_BAN)
_style_button(unban_btn, Color(0.1, 0.22, 0.1), CLR_BTN_UNBAN)
@@ -134,6 +146,11 @@ func _connect_signals() -> void:
# LB actions
lb_tree.button_clicked.connect(_on_lb_tree_button_clicked)
sync_lb_btn.pressed.connect(_on_sync_leaderboard)
# DR actions
load_dr_btn.pressed.connect(_load_daily_rewards_config)
save_dr_btn.pressed.connect(_save_daily_rewards_config)
month_option_btn.item_selected.connect(_on_dr_month_selected)
# =============================================================================
# Core Panel Logic
@@ -153,8 +170,10 @@ func _on_tab_changed(tab_index: int) -> void:
_set_status("")
if tab_index == 0:
await _load_users()
else:
elif tab_index == 1:
await _load_leaderboard()
elif tab_index == 2:
await _load_daily_rewards_config()
# =============================================================================
# RPC Helper
@@ -508,3 +527,99 @@ func _get_edit_icon() -> Texture2D:
img.set_pixel(i, 11 - i + 4, CLR_TEXT)
img.set_pixel(i, 12 - i + 4, CLR_TEXT)
return ImageTexture.create_from_image(img)
# =============================================================================
# TAB 3: DAILY REWARDS
# =============================================================================
func _load_daily_rewards_config() -> void:
_set_status("Loading Daily Rewards Config...")
var res := await _rpc("get_daily_reward_config_admin", {})
if res.has("error"):
_set_status("Failed to load DR config", CLR_STATUS_ERR)
return
var config = res.get("config", {})
if config.is_empty():
for m in range(1, 13):
var m_str = "%02d" % m
var arr = []
for d in range(30):
arr.append({"type": "star", "amount": min(10 + d*5, 100)})
config[m_str] = arr
_daily_reward_config_data = config
month_option_btn.clear()
for i in range(1, 13):
month_option_btn.add_item(MONTH_NAMES[i - 1])
month_option_btn.set_item_metadata(i - 1, "%02d" % i)
if not _daily_reward_config_data.is_empty():
_current_dr_month = "01"
month_option_btn.select(0)
_build_dr_grid()
_set_status("Config Loaded", CLR_STATUS_OK)
func _on_dr_month_selected(index: int) -> void:
# Save current grid values into the dictionary before switching
_save_current_grid_to_dict()
_current_dr_month = month_option_btn.get_item_metadata(index)
_build_dr_grid()
func _save_current_grid_to_dict() -> void:
if _current_dr_month.is_empty(): return
var arr = []
for child in days_grid.get_children():
if child != day_config_template:
var opt = child.get_node("Margin/VBox/TypeOptionBtn") as OptionButton
var spin = child.get_node("Margin/VBox/AmountSpinBox") as SpinBox
var item_type = opt.get_item_text(opt.selected) if opt.selected >= 0 else "star"
arr.append({"type": item_type, "amount": int(spin.value)})
if not arr.is_empty():
_daily_reward_config_data[_current_dr_month] = arr
func _build_dr_grid() -> void:
for child in days_grid.get_children():
if child != day_config_template:
child.queue_free()
var arr = _daily_reward_config_data.get(_current_dr_month, [])
for i in range(arr.size()):
var slot = day_config_template.duplicate()
slot.visible = true
days_grid.add_child(slot)
var lbl = slot.get_node("Margin/VBox/DayLabel") as Label
var opt = slot.get_node("Margin/VBox/TypeOptionBtn") as OptionButton
var spin = slot.get_node("Margin/VBox/AmountSpinBox") as SpinBox
lbl.text = "Day " + str(i + 1)
var rdata = arr[i]
if typeof(rdata) == TYPE_DICTIONARY:
spin.value = rdata.get("amount", 0)
var type_str = rdata.get("type", "star")
var found = false
for j in range(opt.item_count):
if opt.get_item_text(j) == type_str:
opt.select(j)
found = true
break
if not found:
opt.add_item(type_str)
opt.select(opt.item_count - 1)
else:
# Fallback for old int format
spin.value = int(rdata)
opt.select(0)
func _save_daily_rewards_config() -> void:
_save_current_grid_to_dict()
_set_status("Saving config...")
var req = { "config": _daily_reward_config_data }
var res = await _rpc("set_daily_reward_config", req)
if res.has("error"):
_set_status("Save failed: " + res.get("error"), CLR_STATUS_ERR)
else:
_set_status("Config saved successfully!", CLR_STATUS_OK)
+51
View File
@@ -0,0 +1,51 @@
extends Control
@onready var close_btn = %CloseBtn
@onready var save_btn = %SaveBtn
@onready var status_lbl = %StatusLabel
@onready var text_edit = %TextEdit
func _ready():
close_btn.pressed.connect(func(): queue_free())
save_btn.pressed.connect(_on_save)
_load_config()
func _load_config():
if not NakamaManager.session:
status_lbl.text = "Not authenticated"
return
status_lbl.text = "Loading..."
var res = await NakamaManager.client.rpc_async(NakamaManager.session, "get_daily_reward_config_admin", "{}")
if res.is_exception():
status_lbl.text = "Error: " + res.get_exception().message
return
var json = JSON.new()
if json.parse(res.payload) == OK:
var config = json.get_data().get("config", {})
if config.is_empty():
# generate default 12 months for 2026/2027
var year = 2026
for m in range(1, 13):
var m_str = "%d-%02d" % [year, m]
var arr = []
for d in range(30):
arr.append(min(10 + d*5, 100)) # Reward is star currency, max 100
config[m_str] = arr
text_edit.text = JSON.stringify(config, "\t")
status_lbl.text = "Loaded"
func _on_save():
var json = JSON.new()
if json.parse(text_edit.text) != OK:
status_lbl.text = "Invalid JSON syntax. Please check your formatting."
return
status_lbl.text = "Saving..."
var req = { "config": json.get_data() }
var res = await NakamaManager.client.rpc_async(NakamaManager.session, "set_daily_reward_config", JSON.stringify(req))
if res.is_exception():
status_lbl.text = "Save error: " + res.get_exception().message
else:
status_lbl.text = "Config saved successfully!"
@@ -0,0 +1 @@
uid://v0h4qheiyh1k
+151
View File
@@ -0,0 +1,151 @@
extends Control
signal closed
@onready var close_btn = %CloseBtn
@onready var grid_container = %GridContainer
@onready var status_label = %StatusLabel
@onready var claim_btn = %ClaimBtn
@onready var slot_template = %RewardSlotTemplate
@onready var month_label = %MonthLabel
@onready var time_label = %TimeLabel
@onready var reward_amount_label = %RewardAmount
@onready var big_icon_label = $MainWindow/HBox/RightCol/MiddleDetails/VBox/BigIcon
@onready var reward_name_label = $MainWindow/HBox/RightCol/MiddleDetails/VBox/RewardName
var _month_rewards: Array = []
var _claimed_days: int = 0
var _can_claim: bool = false
var _today: String = ""
func _ready():
close_btn.pressed.connect(func():
hide()
emit_signal("closed")
)
claim_btn.pressed.connect(_on_claim_pressed)
var time_dict = Time.get_datetime_dict_from_system()
var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
month_label.text = months[time_dict.month - 1] + " Sign-in"
func show_panel():
show()
status_label.text = "Loading rewards..."
claim_btn.disabled = true
claim_btn.text = "Loading..."
_fetch_state()
func _fetch_state():
if not NakamaManager.session:
status_label.text = "Must be logged in to claim rewards."
return
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_daily_reward_state", "{}")
if result.is_exception():
status_label.text = "Failed to load: " + result.get_exception().message
return
var json = JSON.new()
if json.parse(result.payload) == OK:
var data = json.get_data()
_month_rewards = data.get("month_rewards", [])
var state = data.get("state", {})
_claimed_days = state.get("claimed_days", 0)
_can_claim = data.get("can_claim_today", false)
_today = data.get("today_date", "")
_update_ui()
else:
status_label.text = "Error parsing data."
func _get_reward_display_data(type: String) -> Dictionary:
if type == "gold": return {"icon": "💰", "name": "Gold"}
elif type == "frag_common": return {"icon": "🧩", "name": "Common Fragment"}
elif type == "frag_uncommon": return {"icon": "📦", "name": "Uncommon Fragment"}
elif type == "frag_rare": return {"icon": "💎", "name": "Rare Fragment"}
return {"icon": "", "name": "Star Currency"}
func _update_ui():
for child in grid_container.get_children():
if child != slot_template:
child.queue_free()
var today_reward_amount = 0
var today_reward_type = "star"
if _claimed_days < _month_rewards.size():
var r = _month_rewards[_claimed_days]
if typeof(r) == TYPE_DICTIONARY:
today_reward_amount = r.get("amount", 0)
today_reward_type = r.get("type", "star")
else:
today_reward_amount = int(r)
reward_amount_label.text = "x " + str(today_reward_amount)
var r_data = _get_reward_display_data(today_reward_type)
big_icon_label.text = r_data.icon
reward_name_label.text = r_data.name
for i in range(_month_rewards.size()):
var reward_raw = _month_rewards[i]
var reward_amount = 0
var reward_type = "star"
if typeof(reward_raw) == TYPE_DICTIONARY:
reward_amount = reward_raw.get("amount", 0)
reward_type = reward_raw.get("type", "star")
else:
reward_amount = int(reward_raw)
var r_slot_data = _get_reward_display_data(reward_type)
var is_claimed = i < _claimed_days
var is_today = i == _claimed_days
var slot = slot_template.duplicate()
slot.visible = true
grid_container.add_child(slot)
var day_lbl = slot.get_node("DayNumber")
var amt_lbl = slot.get_node("Amount")
var icon_lbl = slot.get_node("IconLabel")
var claimed_overlay = slot.get_node("ClaimedOverlay")
var today_border = slot.get_node("TodayBorder")
day_lbl.text = str(i + 1)
amt_lbl.text = str(reward_amount)
icon_lbl.text = r_slot_data.icon
claimed_overlay.visible = is_claimed
today_border.visible = is_today
if _can_claim:
status_label.text = ""
claim_btn.disabled = false
claim_btn.text = "Sign In"
else:
status_label.text = "Come back tomorrow!"
claim_btn.disabled = true
claim_btn.text = "Signed-in"
func _on_claim_pressed():
if not _can_claim or not NakamaManager.session:
return
claim_btn.disabled = true
claim_btn.text = "Claiming..."
status_label.text = ""
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "claim_daily_reward", "{}")
if result.is_exception():
status_label.text = "Failed to claim: " + result.get_exception().message
claim_btn.disabled = false
claim_btn.text = "Sign In"
return
# Refresh wallet
if UserProfileManager.has_method("_reload_wallet"):
UserProfileManager._reload_wallet()
await get_tree().create_timer(1.0).timeout
_fetch_state()
+1
View File
@@ -0,0 +1 @@
uid://332tmk1jxdw4
+33
View File
@@ -51,6 +51,15 @@ func _ready() -> void:
_update_tab_visuals()
_setup_3d_preview()
# Listen to profile and stats changes to keep the panel updated
UserProfileManager.profile_updated.connect(_on_profile_or_stats_changed)
UserProfileManager.stats_updated.connect(_on_profile_or_stats_changed)
UserProfileManager.avatar_changed.connect(func(_url): _on_profile_or_stats_changed())
func _on_profile_or_stats_changed() -> void:
if visible:
_fetch_leaderboard_data()
# -------------------------------------------------------------------------
# Show / Close
# -------------------------------------------------------------------------
@@ -97,6 +106,7 @@ func _fetch_leaderboard_data() -> void:
var native_data = await _fetch_native_leaderboard()
if native_data.size() > 0:
_apply_local_overrides(native_data)
leaderboard_data = native_data
_calculate_win_rates()
status_label.text = ""
@@ -156,6 +166,7 @@ func _fetch_via_rpc() -> void:
if json.parse(result.payload) == OK:
var data = json.get_data()
if data.has("leaderboard") and data.leaderboard.size() > 0:
_apply_local_overrides(data.leaderboard)
leaderboard_data = data.leaderboard
_calculate_win_rates()
status_label.text = ""
@@ -174,6 +185,28 @@ func _calculate_win_rates() -> void:
var won = entry.get("games_won", 0)
entry["win_rate"] = float(won) / float(played) * 100.0 if played > 0 else 0.0
func _apply_local_overrides(data: Array) -> void:
if not NakamaManager.session:
return
var my_id = NakamaManager.session.user_id
for entry in data:
if entry.get("user_id") == my_id:
entry["display_name"] = UserProfileManager.get_display_name(entry.get("display_name", "Unknown"))
entry["avatar_url"] = UserProfileManager.get_avatar_url()
entry["loadout_character"] = UserProfileManager.profile.get("loadout_character", entry.get("loadout_character", "Copper"))
var local_score = UserProfileManager.stats.get("high_score", 0)
if local_score >= entry.get("high_score", 0):
entry["high_score"] = local_score
var local_played = UserProfileManager.stats.get("games_played", 0)
if local_played >= entry.get("games_played", 0):
entry["games_played"] = local_played
var local_won = UserProfileManager.stats.get("games_won", 0)
if local_won >= entry.get("games_won", 0):
entry["games_won"] = local_won
# -------------------------------------------------------------------------
# Sorting / Display
# -------------------------------------------------------------------------