feat: adding the skin_shader_generator, and gacha base barebone

This commit is contained in:
2026-04-24 00:17:00 +08:00
parent 16c82a48b8
commit 7e4b707e84
196 changed files with 4883 additions and 200 deletions
+137
View File
@@ -0,0 +1,137 @@
extends Node
## GachaManager — Autoload singleton
## Handles pull logic, pity counter, and fragment reward dispatch.
const DATA_PATH = "res://assets/data/gacha_data.json"
var data: Dictionary = {}
# pity_counters[banner_id] = int
var pity_counters: Dictionary = {"star": 0, "gold": 0}
signal pull_completed(results: Array) # Array of {id, rarity, name}
func _ready() -> void:
_load_data()
func _load_data() -> void:
var f := FileAccess.open(DATA_PATH, FileAccess.READ)
if f:
var parsed = JSON.parse_string(f.get_as_text())
if parsed is Dictionary:
data = parsed
else:
push_error("[GachaManager] Could not open gacha_data.json")
# ─── Public API ──────────────────────────────────────────────────────────────
func pull(banner_id: String, count: int) -> Array:
if data.is_empty(): _load_data()
var banner: Dictionary = data.get("banners", {}).get(banner_id, {})
if banner.is_empty():
push_error("[GachaManager] Unknown banner: " + banner_id)
return []
var currency: String = banner.get("currency", "star")
var cost_key: String = "pull_%d_cost" % count
var cost: int = banner.get(cost_key, banner.get("pull_1_cost", 999) * count)
var pity_at: int = banner.get("pity_at", 90)
# Deduct currency
var bal: int = UserProfileManager.wallet.get(currency, 0)
if bal < cost:
push_warning("[GachaManager] Not enough %s (have %d need %d)" % [currency, bal, cost])
return []
UserProfileManager.wallet[currency] = bal - cost
UserProfileManager.save_wallet()
var results: Array = []
for _i in range(count):
var pity: int = pity_counters.get(banner_id, 0)
var rarity: String
if pity + 1 >= pity_at:
rarity = "real_prize"
pity_counters[banner_id] = 0
else:
rarity = _roll_rarity(banner.get("rates", {}))
pity_counters[banner_id] = (pity + 1) if rarity != "real_prize" else 0
var item_id: String = _pick_from_pool(rarity, banner_id)
var item_data: Dictionary = _resolve_item(item_id, rarity)
results.append({
"id": item_id,
"rarity": rarity,
"name": item_data.get("name", item_id)
})
_grant_item(item_id, rarity)
pull_completed.emit(results)
return results
func get_pity(banner_id: String) -> int:
return pity_counters.get(banner_id, 0)
func get_balance(banner_id: String) -> int:
var banner: Dictionary = data.get("banners", {}).get(banner_id, {})
var currency: String = banner.get("currency", "star")
return UserProfileManager.wallet.get(currency, 0)
# ─── Internal ─────────────────────────────────────────────────────────────────
func _roll_rarity(rates: Dictionary) -> String:
var roll := randf()
var cumulative := 0.0
for rarity in ["real_prize", "rare", "uncommon", "common"]:
cumulative += rates.get(rarity, 0.0)
if roll <= cumulative:
return rarity
return "common"
func _pick_from_pool(rarity: String, _banner_id: String) -> String:
var pool: Array = data.get("pools", {}).get(rarity, [])
if pool.is_empty(): return ""
return pool[randi() % pool.size()]
func _resolve_item(item_id: String, rarity: String) -> Dictionary:
if rarity == "real_prize":
return data.get("real_prize_catalog", {}).get(item_id, {})
return data.get("fragments", {}).get(item_id, {})
func _grant_item(item_id: String, rarity: String) -> void:
if rarity == "real_prize":
# Add skin directly to inventory
if not UserProfileManager.inventory.has(item_id):
UserProfileManager.inventory.append(item_id)
else:
# Add fragment count
var frags: Dictionary = UserProfileManager.fragments
frags[item_id] = frags.get(item_id, 0) + 1
UserProfileManager.fragments = frags
# ─── Crafting ─────────────────────────────────────────────────────────────────
func get_all_recipes() -> Dictionary:
return data.get("craft_recipes", {})
func can_craft(recipe_id: String) -> bool:
var recipe: Dictionary = data.get("craft_recipes", {}).get(recipe_id, {})
if recipe.is_empty(): return false
var cost: Dictionary = recipe.get("cost", {})
for frag_id in cost:
var needed: int = cost[frag_id]
var have: int = UserProfileManager.fragments.get(frag_id, 0)
if have < needed: return false
return true
func craft(recipe_id: String) -> bool:
if not can_craft(recipe_id): return false
var recipe: Dictionary = data.get("craft_recipes", {}).get(recipe_id, {})
var cost: Dictionary = recipe.get("cost", {})
for frag_id in cost:
UserProfileManager.fragments[frag_id] -= cost[frag_id]
var result_id: String = recipe.get("result_id", "")
if not result_id.is_empty():
if not UserProfileManager.inventory.has(result_id):
UserProfileManager.inventory.append(result_id)
UserProfileManager.save_wallet()
return true
+1
View File
@@ -0,0 +1 @@
uid://m5g6vki8s87y
+67 -16
View File
@@ -23,34 +23,64 @@ extends Node
const SKIN_CATALOG: Dictionary = {
# ── [HEAD] ──────────────────────────────────────────────────────────────────────
"example-hat": {
"oldpop-blue-hat": {
"category": "head",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hat1", "mode": "override", "material": "" },
{ "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_blue.tres" },
]
},
"oldpop-green-hat": {
"category": "head",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_green.tres" },
]
},
"oldpop-red-hat": {
"category": "head",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_red.tres" },
]
},
"oldpop-yellow-hat": {
"category": "head",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hat1", "mode": "override", "material": "res://assets/characters/skins/hat/oldpop_mat_hat_yellow.tres" },
]
},
# ── [COSTUME] ──────────────────────────────────────────────────────────────────────
"oldpop-og-pant": {
"category": "costume",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_ori_pant.tres" },
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_ori_pant.tres" },
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_ori_pant.tres" },
]
},
"oldpop-grey-pant": {
"category": "costume",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/bmo_greypants.tres" },
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_greypants.tres" },
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_greypants.tres" },
]
},
"oldpop-clothing-original": {
"category": "costume",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/bmo_originalpants.tres" },
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_originalpants.tres" },
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/bmo_originalpants.tres" },
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_grey_pant.tres" },
]
},
"oldpop-red-pant": {
"category": "costume",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-body", "mode": "overlay", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres" },
{ "mesh": "oldpop-bottom1", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres" },
{ "mesh": "oldpop-bottom2", "mode": "override", "material": "res://assets/characters/skins/clothing/oldpop_mat_cloth_red_pant.tres" },
]
},
"oldpop-yellow-pant": {
"category": "costume",
"character": "Oldpop",
"slots": [
@@ -61,11 +91,32 @@ const SKIN_CATALOG: Dictionary = {
},
# ── [GLOVE] ──────────────────────────────────────────────────────────────────────
"example-gloves": {
"oldpop-blue-gloves": {
"category": "glove",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hands", "mode": "override", "material": "" },
{ "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_blue.tres" },
]
},
"oldpop-green-gloves": {
"category": "glove",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_green.tres" },
]
},
"oldpop-red-gloves": {
"category": "glove",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_red.tres" },
]
},
"oldpop-yellow-gloves": {
"category": "glove",
"character": "Oldpop",
"slots": [
{ "mesh": "oldpop-hands", "mode": "override", "material": "res://assets/characters/skins/gloves/oldpop_mat_gloves_yellow.tres" },
]
},
+20
View File
@@ -20,6 +20,7 @@ signal avatar_changed(url: String)
var profile: Dictionary = {}
var stats: Dictionary = {}
var wallet: Dictionary = {"gold": 0, "star": 0}
var fragments: Dictionary = {} # frag_common, frag_uncommon, frag_rare
var inventory: Array = []
var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""}
var shop_catalog: Dictionary = {}
@@ -51,6 +52,7 @@ func _on_auth_completed(success: bool, _user_data: Dictionary) -> void:
func _on_logged_out() -> void:
profile = {}
stats = {}
fragments = {}
is_profile_loaded = false
# =============================================================================
@@ -62,6 +64,7 @@ func load_profile() -> Dictionary:
profile = {}
stats = {}
wallet = {"gold": 0, "star": 0}
fragments = {}
inventory = []
is_profile_loaded = false
@@ -116,6 +119,15 @@ func load_profile() -> Dictionary:
# Load stats
await load_stats()
# Load fragments from storage
var frag_result = await NakamaManager.client.read_storage_objects_async(
NakamaManager.session,
[NakamaStorageObjectId.new(PROFILE_COLLECTION, "fragments", account.user.id)]
)
if not frag_result.is_exception() and frag_result.objects.size() > 0:
var fdata = JSON.parse_string(frag_result.objects[0].value)
if fdata is Dictionary: fragments = fdata
is_profile_loaded = true
emit_signal("profile_loaded", profile)
print("[UserProfileManager] Profile loaded: ", profile.get("display_name", "Unknown"))
@@ -391,6 +403,14 @@ func _reload_wallet() -> void:
wallet["star"] = w_data.get("star", 0)
emit_signal("profile_updated")
func save_wallet() -> void:
"""Persist wallet deductions and fragment counts to Nakama storage."""
if not NakamaManager.session: 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)
# =============================================================================
# Stats Management
# =============================================================================