feat: adding the skin_shader_generator, and gacha base barebone
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://m5g6vki8s87y
|
||||
@@ -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,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
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
shader_type spatial;
|
||||
render_mode depth_draw_always, cull_back;
|
||||
|
||||
uniform sampler2D albedo_texture : source_color;
|
||||
uniform vec4 hat_color : source_color = vec4(1.0);
|
||||
uniform vec4 gloves_color : source_color = vec4(1.0);
|
||||
uniform vec4 cloth_color : source_color = vec4(1.0);
|
||||
uniform vec4 skin_color : source_color = vec4(1.0);
|
||||
|
||||
uniform vec2 hat_uv_min = vec2(0.6, 0.1);
|
||||
uniform vec2 hat_uv_max = vec2(1.0, 0.5);
|
||||
uniform vec2 hat_uv_min2 = vec2(-1.0);
|
||||
uniform vec2 hat_uv_max2 = vec2(-1.0);
|
||||
|
||||
uniform vec2 gloves_uv_min = vec2(0.6, 0.5);
|
||||
uniform vec2 gloves_uv_max = vec2(1.0, 0.7);
|
||||
uniform vec2 gloves_uv_min2 = vec2(-1.0);
|
||||
uniform vec2 gloves_uv_max2 = vec2(-1.0);
|
||||
|
||||
uniform vec2 cloth_uv_min = vec2(0.0, 0.7);
|
||||
uniform vec2 cloth_uv_max = vec2(1.0, 1.0);
|
||||
uniform vec2 cloth_uv_min2 = vec2(-1.0);
|
||||
uniform vec2 cloth_uv_max2 = vec2(-1.0);
|
||||
|
||||
uniform vec4 hat_match_color : source_color = vec4(0.0);
|
||||
uniform vec4 gloves_match_color : source_color = vec4(0.0);
|
||||
uniform vec4 cloth_match_color : source_color = vec4(0.0);
|
||||
uniform float color_tolerance = 0.05;
|
||||
|
||||
uniform int highlight_part = -1; // -1: none, 0: hat, 1: gloves, 2: cloth, 3: skin
|
||||
uniform float alpha_scissor_threshold : hint_range(0.0, 1.0) = 0.5;
|
||||
uniform bool is_selected = false;
|
||||
instance uniform int mesh_category : hint_range(-1, 3) = -1;
|
||||
|
||||
bool in_rect(vec2 uv, vec2 mi, vec2 ma) {
|
||||
if (mi.x < 0.0) return false;
|
||||
return uv.x >= mi.x && uv.x <= ma.x && uv.y >= mi.y && uv.y <= ma.y;
|
||||
}
|
||||
|
||||
bool color_match(vec3 c1, vec3 c2) {
|
||||
if (length(c2) < 0.01) return false; // Ignore black/empty targets
|
||||
return distance(c1, c2) < color_tolerance;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
vec4 tex = texture(albedo_texture, UV);
|
||||
|
||||
if (tex.a < alpha_scissor_threshold) discard;
|
||||
|
||||
vec3 final_color = tex.rgb;
|
||||
float emission_mult = 0.0;
|
||||
if (is_selected) emission_mult = 0.4;
|
||||
|
||||
int cat = mesh_category;
|
||||
|
||||
// If no manual mesh assignment, use UV Detection + Color Match (Magic Wand)
|
||||
if (cat == -1) {
|
||||
if (in_rect(UV, hat_uv_min, hat_uv_max) || in_rect(UV, hat_uv_min2, hat_uv_max2) || color_match(tex.rgb, hat_match_color.rgb)) cat = 0;
|
||||
else if (in_rect(UV, gloves_uv_min, gloves_uv_max) || in_rect(UV, gloves_uv_min2, gloves_uv_max2) || color_match(tex.rgb, gloves_match_color.rgb)) cat = 1;
|
||||
else if (in_rect(UV, cloth_uv_min, cloth_uv_max) || in_rect(UV, cloth_uv_min2, cloth_uv_max2) || color_match(tex.rgb, cloth_match_color.rgb)) cat = 2;
|
||||
else cat = 3;
|
||||
}
|
||||
|
||||
if (cat == 0) {
|
||||
final_color *= hat_color.rgb;
|
||||
if (highlight_part == 0) emission_mult = 0.5;
|
||||
} else if (cat == 1) {
|
||||
final_color *= gloves_color.rgb;
|
||||
if (highlight_part == 1) emission_mult = 0.5;
|
||||
} else if (cat == 2) {
|
||||
final_color *= cloth_color.rgb;
|
||||
if (highlight_part == 2) emission_mult = 0.5;
|
||||
} else {
|
||||
final_color *= skin_color.rgb;
|
||||
if (highlight_part == 3) emission_mult = 0.5;
|
||||
}
|
||||
|
||||
ALBEDO = final_color;
|
||||
EMISSION = final_color * emission_mult;
|
||||
ALPHA = tex.a;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://pbq1c8la17vd
|
||||
@@ -40,6 +40,7 @@ var _form_star: SpinBox
|
||||
var _form_category: OptionButton
|
||||
var _form_rarity: OptionButton
|
||||
var _slots_vbox: VBoxContainer
|
||||
var _duplicate_btn: Button
|
||||
var _delete_btn: Button
|
||||
var _save_btn: Button
|
||||
|
||||
@@ -77,6 +78,12 @@ func _build_ui() -> void:
|
||||
add_btn.pressed.connect(_on_add_pressed)
|
||||
top.add_child(add_btn)
|
||||
|
||||
_duplicate_btn = Button.new()
|
||||
_duplicate_btn.text = "📋 Duplicate"
|
||||
_duplicate_btn.disabled = true
|
||||
_duplicate_btn.pressed.connect(_on_duplicate_pressed)
|
||||
top.add_child(_duplicate_btn)
|
||||
|
||||
_delete_btn = Button.new()
|
||||
_delete_btn.text = "✕ Delete"
|
||||
_delete_btn.disabled = true
|
||||
@@ -247,7 +254,9 @@ func _refresh_list() -> void:
|
||||
c.queue_free()
|
||||
for i in _data.size():
|
||||
var entry: Dictionary = _data[i]
|
||||
var btn := Button.new()
|
||||
var btn := SkinListItem.new()
|
||||
btn.index = i
|
||||
btn.editor = self
|
||||
var cat: String = entry.get("category", "?")
|
||||
var iid: String = entry.get("item_id", "?")
|
||||
btn.text = "[%s]\n%s" % [cat, iid]
|
||||
@@ -273,11 +282,13 @@ func _populate_form() -> void:
|
||||
if _selected_idx < 0 or _selected_idx >= _data.size():
|
||||
_form_panel.visible = false
|
||||
_no_sel_label.visible = true
|
||||
_duplicate_btn.disabled = true
|
||||
_delete_btn.disabled = true
|
||||
return
|
||||
|
||||
_form_panel.visible = true
|
||||
_no_sel_label.visible = false
|
||||
_duplicate_btn.disabled = false
|
||||
_delete_btn.disabled = false
|
||||
|
||||
var e: Dictionary = _data[_selected_idx]
|
||||
@@ -388,6 +399,20 @@ func _on_add_pressed() -> void:
|
||||
_set_status("New skin created. Fill in the form and Save & Generate.", Color.YELLOW)
|
||||
|
||||
|
||||
func _on_duplicate_pressed() -> void:
|
||||
if _selected_idx < 0:
|
||||
return
|
||||
_commit_form()
|
||||
var original = _data[_selected_idx]
|
||||
var duplicate = original.duplicate(true)
|
||||
duplicate["item_id"] += "_copy"
|
||||
_data.insert(_selected_idx + 1, duplicate)
|
||||
_selected_idx += 1
|
||||
_refresh_list()
|
||||
_populate_form()
|
||||
_set_status("Skin duplicated.", Color.YELLOW)
|
||||
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
if _selected_idx < 0:
|
||||
return
|
||||
@@ -399,6 +424,19 @@ func _on_delete_pressed() -> void:
|
||||
_set_status("Deleted: " + removed, Color.YELLOW)
|
||||
|
||||
|
||||
func move_item(from_idx: int, to_idx: int) -> void:
|
||||
if from_idx == to_idx:
|
||||
return
|
||||
_commit_form()
|
||||
var item = _data[from_idx]
|
||||
_data.remove_at(from_idx)
|
||||
_data.insert(to_idx, item)
|
||||
_selected_idx = to_idx
|
||||
_refresh_list()
|
||||
_populate_form()
|
||||
_set_status("Reordered: %s" % item.get("item_id", "?"), Color.WHITE)
|
||||
|
||||
|
||||
func _on_add_slot_pressed() -> void:
|
||||
if _selected_idx < 0:
|
||||
return
|
||||
@@ -534,3 +572,22 @@ func _set_status(msg: String, color: Color = Color.WHITE) -> void:
|
||||
return
|
||||
_status_label.add_theme_color_override("font_color", color)
|
||||
_status_label.text = msg
|
||||
|
||||
|
||||
# ─── Inner Classes ───────────────────────────────────────────────────────────
|
||||
class SkinListItem extends Button:
|
||||
var index: int
|
||||
var editor: Control
|
||||
|
||||
func _get_drag_data(_at_position: Vector2) -> Variant:
|
||||
var preview := Button.new()
|
||||
preview.text = text
|
||||
preview.modulate.a = 0.5
|
||||
set_drag_preview(preview)
|
||||
return index
|
||||
|
||||
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
|
||||
return typeof(data) == TYPE_INT
|
||||
|
||||
func _drop_data(_at_position: Vector2, data: Variant) -> void:
|
||||
editor.move_item(data, index)
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
const SHADER_PATH = "res://scripts/shaders/character_skin.gdshader"
|
||||
const BASE_TEX_DIR = "res://assets/characters/skins/tex/"
|
||||
const PRESET_DIR = "res://assets/characters/skins/presets/"
|
||||
const EXPORT_DIRS = {
|
||||
"Clothing": "res://assets/characters/skins/clothing/",
|
||||
"Gloves": "res://assets/characters/skins/gloves/",
|
||||
"Hat": "res://assets/characters/skins/hat/",
|
||||
"General / Tex": "res://assets/characters/skins/tex/"
|
||||
}
|
||||
const CAT_NAMES = ["Hat", "Gloves", "Cloth", "Skin"]
|
||||
|
||||
# ─── UI Refs (Scene Unique Nodes) ───────────────────────────────────────────
|
||||
@onready var _character_opt: OptionButton = %CharacterOpt
|
||||
@onready var _status_label: Label = %StatusLabel
|
||||
@onready var _inspected_label: Label = %InspectedLabel
|
||||
@onready var _mesh_list_vbox: VBoxContainer = %MeshList
|
||||
@onready var _zoom_slider: HSlider = %ZoomSlider
|
||||
@onready var _uv_preview: TextureRect = %UVPreview
|
||||
@onready var _uv_overlay: Control = %UVOverlay
|
||||
@onready var _viewport_3d: SubViewport = %Viewport3D
|
||||
@onready var _pi_name_lbl: Label = %PIName
|
||||
@onready var _pi_cat_opt: OptionButton = %PICategory
|
||||
@onready var _pi_color_btn: ColorPickerButton = %PIColor
|
||||
@onready var _pi_image_btn: Button = %PIImageBtn
|
||||
@onready var _pi_image_clear: Button = %PIImageClear
|
||||
@onready var _pi_ref_btn: Button = %PIRefBtn
|
||||
@onready var _pi_ref_clear: Button = %PIRefClear
|
||||
@onready var _pi_pos_x: HSlider = %PIPosX
|
||||
@onready var _pi_pos_y: HSlider = %PIPosY
|
||||
@onready var _pi_scale: HSlider = %PIScale
|
||||
@onready var _pi_rot: HSlider = %PIRot
|
||||
@onready var _export_name: LineEdit = %ExportName
|
||||
@onready var _export_mode: OptionButton = %ExportMode
|
||||
@onready var _export_folder_opt: OptionButton = %ExportFolder
|
||||
@onready var _prefix_label: Label = %PrefixLabel
|
||||
@onready var _wire_opacity_slider: HSlider = %WireOpacity
|
||||
@onready var _wire_color_opt: OptionButton = %WireColor
|
||||
|
||||
var _char_preview: Node3D
|
||||
var _file_dialog: FileDialog
|
||||
|
||||
# ─── State ───────────────────────────────────────────────────────────────────
|
||||
var _mesh_data: Array = []
|
||||
var _inspected_mesh_name: String = ""
|
||||
var _is_rotating: bool = false
|
||||
var _last_mouse_pos: Vector2
|
||||
var _show_all_uvs: bool = false
|
||||
var _flip_uv: bool = false
|
||||
var _hide_ref: bool = false
|
||||
var _debug_wireframe: MeshInstance3D
|
||||
var _drawing_mask: bool = false
|
||||
var _current_mask_idx: int = -1
|
||||
var _mask_points: PackedVector2Array = PackedVector2Array()
|
||||
var _dragged_point_idx: int = -1
|
||||
var _char_cache: Dictionary = {}
|
||||
|
||||
var _gizmo_state: int = 0 # 0: None, 1: Move, 2: Scale, 3: Rotate
|
||||
var _gizmo_start_uv: Vector2
|
||||
var _gizmo_start_val: Variant
|
||||
|
||||
func _ready() -> void:
|
||||
for d in EXPORT_DIRS.values():
|
||||
if not DirAccess.dir_exists_absolute(d): DirAccess.make_dir_recursive_absolute(d)
|
||||
if not DirAccess.dir_exists_absolute(PRESET_DIR): DirAccess.make_dir_recursive_absolute(PRESET_DIR)
|
||||
|
||||
_setup_ui_connections()
|
||||
call_deferred("_update_character_preview")
|
||||
_update_export_prefix()
|
||||
|
||||
func _setup_ui_connections():
|
||||
# Presets
|
||||
%SavePresetBtn.pressed.connect(_on_save_preset)
|
||||
%LoadPresetBtn.pressed.connect(_on_load_preset)
|
||||
|
||||
# Character selection
|
||||
_character_opt.clear()
|
||||
for c in ["Oldpop", "Masbro", "Bob", "Gatot"]: _character_opt.add_item(c)
|
||||
_character_opt.selected = 0
|
||||
_character_opt.item_selected.connect(func(i): _on_char_changed(i); _update_export_prefix())
|
||||
|
||||
# UV Viewport
|
||||
_uv_overlay.draw.connect(_on_uv_overlay_draw)
|
||||
_uv_overlay.gui_input.connect(_on_uv_input)
|
||||
%ShowAllUVs.toggled.connect(func(v): _show_all_uvs = v; _uv_overlay.queue_redraw())
|
||||
%FlipUV.toggled.connect(func(v): _flip_uv = v; _update_character_preview())
|
||||
%HideRef.toggled.connect(func(v): _hide_ref = v; _uv_preview.visible = !v; _uv_overlay.queue_redraw())
|
||||
|
||||
# 3D Viewport
|
||||
%Viewport3D.get_parent().gui_input.connect(_on_3d_input)
|
||||
_setup_3d_scene()
|
||||
|
||||
# Zoom controls
|
||||
%ZoomMinus.pressed.connect(func(): _zoom_slider.value += 0.4)
|
||||
%ZoomPlus.pressed.connect(func(): _zoom_slider.value -= 0.4)
|
||||
_zoom_slider.value_changed.connect(_on_zoom_changed)
|
||||
|
||||
# Part Inspector
|
||||
_pi_cat_opt.clear(); _pi_cat_opt.add_item("None / Skin", -1)
|
||||
for ci in range(3): _pi_cat_opt.add_item(CAT_NAMES[ci], ci)
|
||||
_pi_cat_opt.item_selected.connect(_on_pi_cat_selected)
|
||||
_pi_color_btn.color_changed.connect(_on_pi_color_changed)
|
||||
|
||||
_pi_image_btn.pressed.connect(func(): _on_pick_image("sticker"))
|
||||
_pi_image_clear.pressed.connect(_on_clear_image)
|
||||
_pi_ref_btn.pressed.connect(func(): _on_pick_image("ref"))
|
||||
_pi_ref_clear.pressed.connect(_on_clear_ref)
|
||||
for s in [_pi_pos_x, _pi_pos_y, _pi_scale, _pi_rot]:
|
||||
s.value_changed.connect(_on_transform_changed)
|
||||
|
||||
|
||||
%FinishBtn.pressed.connect(_on_finish_mask)
|
||||
%ClearBtn.pressed.connect(_on_clear_mask)
|
||||
%AddMaskBtn.pressed.connect(_on_add_mask)
|
||||
|
||||
# Settings
|
||||
_wire_opacity_slider.value_changed.connect(func(_v): _uv_overlay.queue_redraw())
|
||||
|
||||
_wire_color_opt.clear()
|
||||
for c in ["Black", "White", "Yellow", "Green"]: _wire_color_opt.add_item(c)
|
||||
_wire_color_opt.selected = 0 # Black default
|
||||
_wire_color_opt.item_selected.connect(func(_i):
|
||||
_uv_overlay.queue_redraw()
|
||||
if _inspected_mesh_name != "": _select_mesh(_inspected_mesh_name)
|
||||
)
|
||||
|
||||
# Export
|
||||
_export_folder_opt.clear(); for key in EXPORT_DIRS.keys(): _export_folder_opt.add_item(key)
|
||||
_export_folder_opt.selected = 3; _export_folder_opt.item_selected.connect(func(_i): _update_export_prefix())
|
||||
_export_mode.clear(); _export_mode.add_item("Texture Only (.png)"); _export_mode.add_item("Texture + Material"); _export_mode.selected = 0
|
||||
%GenerateBtn.pressed.connect(_on_generate_pressed)
|
||||
|
||||
|
||||
func _update_export_prefix():
|
||||
var cname = _current_char_name().to_lower()
|
||||
var folder = _export_folder_opt.get_item_text(_export_folder_opt.selected)
|
||||
var cat_slug = "skin"
|
||||
if "Clothing" in folder: cat_slug = "cloth"
|
||||
elif "Gloves" in folder: cat_slug = "gloves"
|
||||
elif "Hat" in folder: cat_slug = "hat"
|
||||
_prefix_label.text = "%s_%s_" % [cname, cat_slug]
|
||||
|
||||
|
||||
# ─── 3D SCENE ────────────────────────────────────────────────────────────────
|
||||
func _setup_3d_scene():
|
||||
# world_3d is already unique because of 'own_world_3d = true' in tscn
|
||||
var cam := Camera3D.new(); cam.position = Vector3(0, 0.8, 1.8); cam.far = 100.0; _viewport_3d.add_child(cam); cam.call_deferred("look_at", Vector3(0,0.6,0))
|
||||
var sun := DirectionalLight3D.new(); sun.rotation_degrees = Vector3(-45,45,0); sun.light_energy = 1.2; _viewport_3d.add_child(sun)
|
||||
var env := WorldEnvironment.new(); env.environment = Environment.new(); env.environment.background_mode = Environment.BG_COLOR; env.environment.background_color = Color(0.12,0.12,0.12); env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR; env.environment.ambient_light_color = Color.WHITE; env.environment.ambient_light_energy = 0.4; _viewport_3d.add_child(env)
|
||||
|
||||
func _on_zoom_changed(v: float):
|
||||
var cam = _viewport_3d.get_camera_3d()
|
||||
if cam: cam.position.z = v; cam.call_deferred("look_at", Vector3(0,0.6,0))
|
||||
|
||||
func _on_3d_input(event: InputEvent):
|
||||
if event is InputEventMouseButton:
|
||||
if event.button_index == MOUSE_BUTTON_LEFT: _is_rotating = event.pressed; _last_mouse_pos = event.position
|
||||
elif event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: _inspect_at_pos(event.position)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_UP: _zoom_slider.value -= 0.2
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: _zoom_slider.value += 0.2
|
||||
if event is InputEventMouseMotion and _is_rotating:
|
||||
var delta = event.position - _last_mouse_pos; _last_mouse_pos = event.position
|
||||
if _char_preview: _char_preview.rotate_y(delta.x * 0.01)
|
||||
|
||||
func _inspect_at_pos(p: Vector2):
|
||||
var cam = _viewport_3d.get_camera_3d(); if not cam or not _char_preview: return
|
||||
var res = {"best": "", "min_dist": 1e10, "node": null}; _find_nearest_mesh(_char_preview, cam, p, res)
|
||||
if res.node: _select_mesh(res.node.name)
|
||||
|
||||
func _find_nearest_mesh(n, c, p, r):
|
||||
if n is MeshInstance3D and n.mesh and n.visible:
|
||||
var center = n.global_transform * n.mesh.get_aabb().get_center()
|
||||
if not c.is_position_behind(center):
|
||||
var sp = c.unproject_position(center); var d = p.distance_to(sp)
|
||||
if d < r.min_dist: r.min_dist = d; r.best = n.name; r.node = n
|
||||
for ch in n.get_children(): _find_nearest_mesh(ch, c, p, r)
|
||||
|
||||
# ─── CHARACTER PREVIEW ───────────────────────────────────────────────────────
|
||||
func _on_char_changed(_i): _update_character_preview()
|
||||
func _current_char_name() -> String: return _character_opt.get_item_text(_character_opt.selected).to_lower()
|
||||
|
||||
func _update_character_preview():
|
||||
if _char_preview: _char_preview.queue_free(); _char_preview = null
|
||||
if _debug_wireframe: _debug_wireframe.queue_free(); _debug_wireframe = null
|
||||
_inspected_mesh_name = ""; _inspected_label.text = ""
|
||||
var cname = _current_char_name()
|
||||
var path = "res://assets/characters/%s.glb" % cname.capitalize()
|
||||
if not FileAccess.file_exists(path): return
|
||||
var scene = load(path).instantiate(); _viewport_3d.add_child(scene); _char_preview = scene
|
||||
# Force T-Pose
|
||||
_force_t_pose(scene)
|
||||
if not _char_cache.has(cname): _char_cache[cname] = {}
|
||||
var tex = _get_embedded_texture(scene)
|
||||
if not tex: tex = load("res://assets/characters/skins/tex/%s_cloth_ori_pant.png" % cname)
|
||||
_uv_preview.texture = tex
|
||||
_mesh_data.clear(); _collect_mesh_data(scene)
|
||||
_build_scene_tree_ui(); _uv_overlay.queue_redraw()
|
||||
|
||||
func _collect_mesh_data(node: Node):
|
||||
if node is MeshInstance3D and node.mesh:
|
||||
var uvs := PackedVector2Array()
|
||||
var tris := PackedVector2Array()
|
||||
for s in node.mesh.get_surface_count():
|
||||
var arr = node.mesh.surface_get_arrays(s)
|
||||
var v_uvs = arr[Mesh.ARRAY_TEX_UV]; var idxs = arr[Mesh.ARRAY_INDEX]
|
||||
if v_uvs:
|
||||
var local_idxs = idxs if idxs else range(v_uvs.size())
|
||||
for i in range(0, local_idxs.size()-2, 3):
|
||||
var i0 = local_idxs[i]; var i1 = local_idxs[i+1]; var i2 = local_idxs[i+2]
|
||||
var u0 = v_uvs[i0]; var u1 = v_uvs[i1]; var u2 = v_uvs[i2]
|
||||
if _flip_uv: u0.y=1-u0.y; u1.y=1-u1.y; u2.y=1-u2.y
|
||||
tris.append(u0); tris.append(u1); tris.append(u2)
|
||||
|
||||
var cname = _current_char_name()
|
||||
var saved = _char_cache[cname].get(node.name, {})
|
||||
var masks_arr = saved.get("masks", [])
|
||||
if masks_arr.is_empty() and saved.has("mask"): masks_arr = [saved["mask"]] # Migration
|
||||
|
||||
var d = {
|
||||
"name": node.name, "tris": tris, "node": node,
|
||||
"category": saved.get("category", -1),
|
||||
"fill_color": saved.get("fill_color", Color.WHITE),
|
||||
"masks": masks_arr,
|
||||
"img_path": saved.get("img_path", ""),
|
||||
"img_pos": saved.get("img_pos", Vector2(0.5, 0.5)),
|
||||
"img_scale": saved.get("img_scale", 1.0),
|
||||
"img_rot": saved.get("img_rot", 0.0),
|
||||
"tex": null,
|
||||
"ref_path": saved.get("ref_path", ""),
|
||||
"ref_tex": null,
|
||||
"orig_tex": _get_material_texture(node)
|
||||
}
|
||||
if d.img_path != "": d.tex = load(d.img_path)
|
||||
if d.ref_path != "": d.ref_tex = load(d.ref_path)
|
||||
|
||||
var mat = ShaderMaterial.new(); mat.shader = load(SHADER_PATH); mat.set_shader_parameter("albedo_texture", _uv_preview.texture)
|
||||
node.set_surface_override_material(0, mat)
|
||||
_mesh_data.append(d)
|
||||
for c in node.get_children(): _collect_mesh_data(c)
|
||||
|
||||
func _get_mesh_data(name: String) -> Dictionary:
|
||||
for d in _mesh_data:
|
||||
if d.name == name: return d
|
||||
return {}
|
||||
|
||||
# ─── SCENE TREE UI ───────────────────────────────────────────────────────────
|
||||
func _build_scene_tree_ui():
|
||||
for c in _mesh_list_vbox.get_children(): c.queue_free()
|
||||
for d in _mesh_data:
|
||||
var h := HBoxContainer.new(); _mesh_list_vbox.add_child(h)
|
||||
var vis := Button.new(); vis.text = "👁"; vis.flat = true
|
||||
vis.pressed.connect(_on_vis_pressed.bind(d.node, vis, d.name)); h.add_child(vis)
|
||||
var col_dot := ColorRect.new(); col_dot.custom_minimum_size = Vector2(12, 20)
|
||||
col_dot.color = d.fill_color if d.category >= 0 else Color(0.3,0.3,0.3,0.5); h.add_child(col_dot)
|
||||
var btn := Button.new(); btn.text = d.name; btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL; btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
btn.pressed.connect(_select_mesh.bind(d.name)); h.add_child(btn)
|
||||
|
||||
func _on_vis_pressed(node: Node3D, vis_btn: Button, mesh_name: String):
|
||||
node.visible = !node.visible; vis_btn.modulate = Color.WHITE if node.visible else Color.GRAY
|
||||
if _inspected_mesh_name == mesh_name and _debug_wireframe: _debug_wireframe.visible = node.visible
|
||||
|
||||
func _select_mesh(mesh_name: String):
|
||||
_inspected_mesh_name = mesh_name; _inspected_label.text = "Selected: " + mesh_name
|
||||
if _debug_wireframe: _debug_wireframe.queue_free(); _debug_wireframe = null
|
||||
var d = _get_mesh_data(mesh_name)
|
||||
if d.is_empty(): return
|
||||
var wire := MeshInstance3D.new(); wire.mesh = d.node.mesh
|
||||
d.node.add_child(wire); _debug_wireframe = wire
|
||||
if d.node.skeleton != NodePath(""):
|
||||
var skel_node = d.node.get_node(d.node.skeleton)
|
||||
if skel_node: wire.skeleton = wire.get_path_to(skel_node)
|
||||
if "skin" in d.node: wire.skin = d.node.skin
|
||||
var wm := ShaderMaterial.new()
|
||||
wm.shader = shader_from_string("shader_type spatial; render_mode unshaded, wireframe; uniform vec4 albedo:source_color; void fragment() { ALBEDO = albedo.rgb; ALPHA = albedo.a; }")
|
||||
var col = _get_current_wire_color(); col.a = 0.5
|
||||
wm.set_shader_parameter("albedo", col)
|
||||
wire.set_surface_override_material(0, wm)
|
||||
wire.visible = d.node.visible
|
||||
_update_part_inspector()
|
||||
_uv_overlay.queue_redraw()
|
||||
|
||||
func _update_part_inspector():
|
||||
var d = _get_mesh_data(_inspected_mesh_name)
|
||||
if d.is_empty(): return
|
||||
_pi_name_lbl.text = _inspected_mesh_name
|
||||
_pi_cat_opt.selected = d.category + 1
|
||||
_pi_color_btn.color = d.fill_color
|
||||
_pi_image_btn.text = d.img_path.get_file() if d.img_path != "" else "None"
|
||||
_pi_ref_btn.text = d.ref_path.get_file() if d.ref_path != "" else "Default"
|
||||
_update_mask_list_ui()
|
||||
if d.ref_tex: _uv_preview.texture = d.ref_tex
|
||||
elif d.orig_tex: _uv_preview.texture = d.orig_tex
|
||||
else: _uv_preview.texture = null
|
||||
%TransformVBox.visible = (d.img_path != "")
|
||||
_pi_pos_x.value = d.img_pos.x; _pi_pos_y.value = d.img_pos.y
|
||||
_pi_scale.value = d.img_scale; _pi_rot.value = d.img_rot
|
||||
|
||||
# ─── PART INSPECTOR CALLBACKS ────────────────────────────────────────────────
|
||||
var _picking_mode: String = "sticker"
|
||||
func _save_mesh_cache(d: Dictionary):
|
||||
_char_cache[_current_char_name()][d.name] = {
|
||||
"category": d.category, "fill_color": d.fill_color,
|
||||
"masks": d.masks, "img_path": d.img_path, "ref_path": d.ref_path,
|
||||
"img_pos": d.img_pos, "img_scale": d.img_scale, "img_rot": d.img_rot
|
||||
}
|
||||
|
||||
func _on_pi_cat_selected(idx: int):
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.category = idx - 1; _save_mesh_cache(d); _build_scene_tree_ui(); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_pi_color_changed(col: Color):
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.fill_color = col; _save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_pick_image(mode: String):
|
||||
_picking_mode = mode
|
||||
if not _file_dialog:
|
||||
_file_dialog = FileDialog.new()
|
||||
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||
_file_dialog.access = FileDialog.ACCESS_RESOURCES
|
||||
_file_dialog.add_filter("*.png, *.jpg, *.webp", "Images")
|
||||
add_child(_file_dialog)
|
||||
if _file_dialog.file_selected.is_connected(_on_image_selected): _file_dialog.file_selected.disconnect(_on_image_selected)
|
||||
_file_dialog.file_selected.connect(_on_image_selected); _file_dialog.popup_centered_ratio(0.5)
|
||||
|
||||
func _on_image_selected(path: String):
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
if _picking_mode == "sticker": d.img_path = path; d.tex = load(path)
|
||||
else: d.ref_path = path; d.ref_tex = load(path)
|
||||
_update_part_inspector(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_clear_image():
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.img_path = ""; d.tex = null
|
||||
_update_part_inspector(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_clear_ref():
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.ref_path = ""; d.ref_tex = null
|
||||
_update_part_inspector(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_transform_changed(_v):
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.img_pos = Vector2(_pi_pos_x.value, _pi_pos_y.value)
|
||||
d.img_scale = _pi_scale.value; d.img_rot = _pi_rot.value
|
||||
_save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
# ─── MASK LAYERS ─────────────────────────────────────────────────────────────
|
||||
func _update_mask_list_ui():
|
||||
for c in %MaskList.get_children(): c.queue_free()
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
for i in range(d.masks.size()):
|
||||
var hb = HBoxContainer.new()
|
||||
var lbl = Label.new(); lbl.text = "Mask %s" % char(65 + i); lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var edit = Button.new(); edit.text = "Edit"; edit.pressed.connect(func(): _on_edit_mask(i))
|
||||
var del = Button.new(); del.text = "✕"; del.pressed.connect(func(): _on_delete_mask(i))
|
||||
hb.add_child(lbl); hb.add_child(edit); hb.add_child(del)
|
||||
%MaskList.add_child(hb)
|
||||
|
||||
func _on_add_mask():
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.masks.append(PackedVector2Array())
|
||||
_on_edit_mask(d.masks.size() - 1)
|
||||
|
||||
func _on_edit_mask(idx: int):
|
||||
_current_mask_idx = idx
|
||||
_drawing_mask = true
|
||||
var d = _get_mesh_data(_inspected_mesh_name)
|
||||
_mask_points = d.masks[idx]
|
||||
%DrawingToolbar.visible = true
|
||||
%DrawStatus.text = "Editing Mask %s..." % char(65 + idx)
|
||||
_uv_overlay.queue_redraw()
|
||||
|
||||
func _on_delete_mask(idx: int):
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
d.masks.remove_at(idx)
|
||||
if _current_mask_idx == idx: _on_finish_mask()
|
||||
else: _update_mask_list_ui(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_start_mask(): _on_add_mask()
|
||||
|
||||
func _on_finish_mask():
|
||||
_drawing_mask = false
|
||||
var d = _get_mesh_data(_inspected_mesh_name); if d.is_empty(): return
|
||||
if _current_mask_idx >= 0:
|
||||
d.masks.remove_at(_current_mask_idx)
|
||||
d.masks.insert(_current_mask_idx, _mask_points)
|
||||
%DrawingToolbar.visible = false
|
||||
_current_mask_idx = -1; _mask_points = PackedVector2Array()
|
||||
_update_mask_list_ui(); _save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_clear_mask():
|
||||
_mask_points = PackedVector2Array(); _uv_overlay.queue_redraw()
|
||||
|
||||
func _on_uv_input(event: InputEvent):
|
||||
if not _uv_preview or not _uv_preview.texture: return
|
||||
var vs = _uv_overlay.size; var ts = _uv_preview.texture.get_size()
|
||||
var sc = min(vs.x/ts.x, vs.y/ts.y); var rs = ts*sc; var off = (vs-rs)*0.5
|
||||
if _drawing_mask:
|
||||
var uv = (event.position - off) / rs
|
||||
if event is InputEventMouseButton:
|
||||
if event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if event.pressed:
|
||||
# Check for drag
|
||||
var best_dist = 0.05
|
||||
_dragged_point_idx = -1
|
||||
for i in range(_mask_points.size()):
|
||||
var d = uv.distance_to(_mask_points[i])
|
||||
if d < best_dist:
|
||||
best_dist = d; _dragged_point_idx = i
|
||||
|
||||
if _dragged_point_idx == -1:
|
||||
_mask_points.append(uv.clamp(Vector2.ZERO, Vector2.ONE))
|
||||
else:
|
||||
_dragged_point_idx = -1
|
||||
_uv_overlay.queue_redraw()
|
||||
elif event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
|
||||
# Delete point
|
||||
var best_dist = 0.05; var to_del = -1
|
||||
for i in range(_mask_points.size()):
|
||||
var d = uv.distance_to(_mask_points[i])
|
||||
if d < best_dist: best_dist = d; to_del = i
|
||||
if to_del != -1:
|
||||
_mask_points.remove_at(to_del)
|
||||
_uv_overlay.queue_redraw()
|
||||
elif event is InputEventMouseMotion:
|
||||
if _dragged_point_idx != -1:
|
||||
_mask_points[_dragged_point_idx] = uv.clamp(Vector2.ZERO, Vector2.ONE)
|
||||
_uv_overlay.queue_redraw()
|
||||
return
|
||||
var d = _get_mesh_data(_inspected_mesh_name)
|
||||
if d.is_empty() or not d.tex: return
|
||||
if event is InputEventMouseButton:
|
||||
var uv = (event.position - off) / rs
|
||||
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
var dist = uv.distance_to(d.img_pos)
|
||||
if dist < (0.1 / d.img_scale):
|
||||
_gizmo_state = 1
|
||||
if event.shift_pressed: _gizmo_state = 2
|
||||
elif event.ctrl_pressed: _gizmo_state = 3
|
||||
_gizmo_start_uv = uv
|
||||
_gizmo_start_val = [d.img_pos, d.img_scale, d.img_rot]
|
||||
else: _gizmo_state = 0
|
||||
if event is InputEventMouseMotion and _gizmo_state > 0:
|
||||
var uv = (event.position - off) / rs
|
||||
var delta = uv - _gizmo_start_uv
|
||||
if _gizmo_state == 1: d.img_pos = _gizmo_start_val[0] + delta
|
||||
elif _gizmo_state == 2: d.img_pos = _gizmo_start_val[0]; d.img_scale = max(0.01, _gizmo_start_val[1] + delta.x * 2.0)
|
||||
elif _gizmo_state == 3: d.img_rot = _gizmo_start_val[2] + delta.x * 180.0
|
||||
_pi_pos_x.value = d.img_pos.x; _pi_pos_y.value = d.img_pos.y
|
||||
_pi_scale.value = d.img_scale; _pi_rot.value = d.img_rot
|
||||
_save_mesh_cache(d); _uv_overlay.queue_redraw()
|
||||
|
||||
# ─── UV OVERLAY DRAW ─────────────────────────────────────────────────────────
|
||||
func _on_uv_overlay_draw():
|
||||
if not _uv_preview or not _uv_preview.texture: return
|
||||
var vs = _uv_overlay.size; var ts = _uv_preview.texture.get_size()
|
||||
var sc = min(vs.x/ts.x, vs.y/ts.y); var rs = ts*sc; var off = (vs-rs)*0.5
|
||||
if _hide_ref:
|
||||
for d in _mesh_data:
|
||||
if d.category < 0: continue
|
||||
var tris = d.tris
|
||||
for i in range(0, tris.size()-2, 3):
|
||||
var poly = PackedVector2Array([off + tris[i]*rs, off + tris[i+1]*rs, off + tris[i+2]*rs])
|
||||
_uv_overlay.draw_colored_polygon(poly, d.fill_color)
|
||||
else:
|
||||
if _show_all_uvs:
|
||||
for d in _mesh_data:
|
||||
if d.name == _inspected_mesh_name: continue
|
||||
_draw_mesh_wireframe(d, off, rs, Color(0.2, 0.2, 0.2, 0.3))
|
||||
for d in _mesh_data:
|
||||
if d.category < 0 or d.name == _inspected_mesh_name: continue
|
||||
_draw_mesh_filled(d, off, rs, Color(d.fill_color.r, d.fill_color.g, d.fill_color.b, 0.15))
|
||||
_draw_mesh_wireframe(d, off, rs, Color(d.fill_color.r, d.fill_color.g, d.fill_color.b, 0.4))
|
||||
if _inspected_mesh_name != "":
|
||||
var d = _get_mesh_data(_inspected_mesh_name)
|
||||
if not d.is_empty():
|
||||
_draw_mesh_filled(d, off, rs, Color(1, 1, 1, 0.03))
|
||||
_draw_mesh_wireframe(d, off, rs, Color(1, 0.8, 0.1, 1.0), 2.0)
|
||||
for m in d.masks:
|
||||
if m.size() >= 3:
|
||||
var ps = _to_screen(m, rs, off)
|
||||
if Geometry2D.triangulate_polygon(ps).size() > 0:
|
||||
_uv_overlay.draw_colored_polygon(ps, Color(0, 1, 0, 0.2))
|
||||
_uv_overlay.draw_polyline(ps, Color(0, 1, 0, 0.5), 1.5, true)
|
||||
if _current_mask_idx >= 0 and _mask_points.size() > 0:
|
||||
var ps = _to_screen(_mask_points, rs, off)
|
||||
var mouse_uv = (_uv_overlay.get_local_mouse_position() - off) / rs
|
||||
for i in range(ps.size()):
|
||||
var is_hover = mouse_uv.distance_to(_mask_points[i]) < 0.05
|
||||
_uv_overlay.draw_circle(ps[i], 5 if is_hover else 3, Color.CYAN if is_hover else Color.YELLOW)
|
||||
if ps.size() > 1:
|
||||
_uv_overlay.draw_polyline(ps, Color.YELLOW, 2.0)
|
||||
_uv_overlay.draw_line(ps[-1], ps[0], Color(1, 1, 0, 0.3), 1.0)
|
||||
for d in _mesh_data:
|
||||
if d.category >= 0 and d.tex:
|
||||
var rot_rad = deg_to_rad(d.img_rot)
|
||||
var rect = Rect2(off + (d.img_pos - Vector2(0.5,0.5)*d.img_scale)*rs, Vector2(d.img_scale, d.img_scale)*rs)
|
||||
_uv_overlay.draw_set_transform(rect.get_center(), rot_rad, Vector2.ONE)
|
||||
_uv_overlay.draw_texture_rect(d.tex, Rect2(-rect.size*0.5, rect.size), false)
|
||||
if d.name == _inspected_mesh_name:
|
||||
var g_col = Color.YELLOW if _gizmo_state > 0 else Color.WHITE
|
||||
_uv_overlay.draw_rect(Rect2(-rect.size*0.5, rect.size), g_col, false, 2.0)
|
||||
_uv_overlay.draw_set_transform(Vector2.ZERO, 0, Vector2.ONE)
|
||||
|
||||
func _draw_mesh_wireframe(d: Dictionary, off: Vector2, rs: Vector2, col: Color, width: float = 1.0):
|
||||
var tris = d.tris
|
||||
var draw_col = _get_current_wire_color(); draw_col.a *= _wire_opacity_slider.value
|
||||
for i in range(0, tris.size()-2, 3):
|
||||
_uv_overlay.draw_line(off + tris[i]*rs, off + tris[i+1]*rs, draw_col, width)
|
||||
_uv_overlay.draw_line(off + tris[i+1]*rs, off + tris[i+2]*rs, draw_col, width)
|
||||
_uv_overlay.draw_line(off + tris[i+2]*rs, off + tris[i]*rs, draw_col, width)
|
||||
|
||||
func _draw_mesh_filled(d: Dictionary, off: Vector2, rs: Vector2, col: Color):
|
||||
for i in range(0, d.tris.size()-2, 3):
|
||||
_uv_overlay.draw_colored_polygon(PackedVector2Array([off+d.tris[i]*rs, off+d.tris[i+1]*rs, off+d.tris[i+2]*rs]), col)
|
||||
|
||||
# ─── GENERATE & EXPORT ───────────────────────────────────────────────────────
|
||||
func _on_generate_pressed() -> void:
|
||||
var img = _uv_preview.texture.get_image()
|
||||
if img.is_compressed(): img.decompress()
|
||||
var w = img.get_width(); var h = img.get_height()
|
||||
# Transparent output — only assigned regions get color
|
||||
var out = Image.create(w, h, false, Image.FORMAT_RGBA8)
|
||||
|
||||
for d in _mesh_data:
|
||||
if d.category < 0: continue
|
||||
var fill: Color = d.fill_color
|
||||
var masks = d.masks
|
||||
var tris = d.tris
|
||||
var sticker: Image = null
|
||||
if d.tex: sticker = d.tex.get_image(); if sticker.is_compressed(): sticker.decompress()
|
||||
|
||||
for ti in range(0, tris.size()-2, 3):
|
||||
_rasterize_tri_aa(img, out, tris[ti], tris[ti+1], tris[ti+2], fill, masks, w, h, sticker, d)
|
||||
|
||||
var target_key = _export_folder_opt.get_item_text(_export_folder_opt.selected)
|
||||
var out_dir = EXPORT_DIRS.get(target_key, BASE_TEX_DIR)
|
||||
|
||||
for d in [out_dir, BASE_TEX_DIR]:
|
||||
if not DirAccess.dir_exists_absolute(d):
|
||||
DirAccess.make_dir_recursive_absolute(d)
|
||||
|
||||
var prefix = _prefix_label.text
|
||||
var ename = _export_name.text.strip_edges()
|
||||
if ename.is_empty(): ename = "unnamed"
|
||||
|
||||
var png_name = prefix + ename
|
||||
var png_path = BASE_TEX_DIR + png_name + ".png"
|
||||
var err = out.save_png(png_path)
|
||||
|
||||
var result_msg = ""
|
||||
if err == OK:
|
||||
result_msg = "✅ Saved: " + png_name + ".png"
|
||||
else:
|
||||
result_msg = "❌ PNG Save Error: " + str(err)
|
||||
_status_label.text = result_msg; _status_label.modulate = Color.RED; return
|
||||
|
||||
if _export_mode.selected == 1:
|
||||
var tex = ImageTexture.create_from_image(out)
|
||||
var mat := StandardMaterial3D.new(); mat.albedo_texture = tex
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR; mat.alpha_scissor_threshold = 0.5
|
||||
|
||||
var parts = prefix.split("_")
|
||||
var mat_name = parts[0] + "_mat_" + parts[1] + "_" + ename
|
||||
var mat_err = ResourceSaver.save(mat, out_dir + mat_name + ".tres")
|
||||
if mat_err == OK:
|
||||
result_msg += " + " + mat_name + ".tres"
|
||||
else:
|
||||
result_msg += " (❌ Mat Error: " + str(mat_err) + ")"
|
||||
|
||||
_status_label.text = result_msg; _status_label.modulate = Color.GREEN
|
||||
# Force filesystem refresh so they show up in editor
|
||||
if Engine.is_editor_hint():
|
||||
var ep = EditorInterface.get_resource_filesystem()
|
||||
if ep: ep.scan()
|
||||
|
||||
func _rasterize_tri_aa(src: Image, dst: Image, u0: Vector2, u1: Vector2, u2: Vector2, col: Color, masks: Array, w: int, h: int, sticker: Image = null, d: Dictionary = {}):
|
||||
var min_x = int(min(u0.x, min(u1.x, u2.x)) * w); var max_x = int(max(u0.x, max(u1.x, u2.x)) * w)
|
||||
var min_y = int(min(u0.y, min(u1.y, u2.y)) * h); var max_y = int(max(u0.y, max(u1.y, u2.y)) * h)
|
||||
min_x = clamp(min_x-1,0,w-1); max_x = clamp(max_x+1,0,w-1); min_y = clamp(min_y-1,0,h-1); max_y = clamp(max_y+1,0,h-1)
|
||||
|
||||
for y in range(min_y, max_y + 1):
|
||||
for x in range(min_x, max_x + 1):
|
||||
var uv = Vector2(float(x)/w, float(y)/h)
|
||||
if _point_in_triangle(uv, u0, u1, u2):
|
||||
var mask_alpha = 1.0
|
||||
if not masks.is_empty():
|
||||
# 4x4 Supersampling
|
||||
var hits = 0.0
|
||||
for sy in range(4):
|
||||
for sx in range(4):
|
||||
var sub_uv = uv + Vector2(sx-1.5, sy-1.5) / Vector2(w*4, h*4)
|
||||
var inside = false
|
||||
for m in masks:
|
||||
if _point_in_polygon(sub_uv, m): inside = true; break
|
||||
if inside: hits += 1.0
|
||||
mask_alpha = hits / 16.0
|
||||
|
||||
if mask_alpha > 0.001:
|
||||
var final_col = col
|
||||
if sticker:
|
||||
var s_uv = (uv - d.img_pos).rotated(-deg_to_rad(d.img_rot)) / d.img_scale + Vector2(0.5, 0.5)
|
||||
if s_uv.x >= 0 and s_uv.x <= 1 and s_uv.y >= 0 and s_uv.y <= 1:
|
||||
var sc = sticker.get_pixelv(s_uv * Vector2(sticker.get_width()-1, sticker.get_height()-1))
|
||||
final_col = col.lerp(sc, sc.a)
|
||||
|
||||
var old = dst.get_pixel(x, y)
|
||||
dst.set_pixel(x, y, old.lerp(final_col, mask_alpha))
|
||||
|
||||
func _point_in_triangle(p: Vector2, a: Vector2, b: Vector2, c: Vector2) -> bool:
|
||||
var d0 = (b-a).cross(p-a); var d1 = (c-b).cross(p-b); var d2 = (a-c).cross(p-c)
|
||||
return ((d0>=0 and d1>=0 and d2>=0) or (d0<=0 and d1<=0 and d2<=0))
|
||||
|
||||
func _point_in_polygon(p: Vector2, poly: PackedVector2Array) -> bool:
|
||||
var inside = false; var n = poly.size(); var j = n - 1
|
||||
for i in n:
|
||||
var pi = poly[i]; var pj = poly[j]
|
||||
if ((pi.y > p.y) != (pj.y > p.y)) and (p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x):
|
||||
inside = !inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
# ─── HELPERS ─────────────────────────────────────────────────────────────────
|
||||
func _get_embedded_texture(n):
|
||||
if n is MeshInstance3D and n.mesh:
|
||||
var m = n.get_surface_override_material(0); if not m: m = n.mesh.surface_get_material(0)
|
||||
if m and "albedo_texture" in m and m.albedo_texture: return m.albedo_texture
|
||||
for ch in n.get_children(): var t = _get_embedded_texture(ch); if t: return t
|
||||
return null
|
||||
|
||||
func _mk_label(t: String) -> Label:
|
||||
var l := Label.new(); l.text = t; return l
|
||||
|
||||
# ─── PRESETS ─────────────────────────────────────────────────────────────────
|
||||
func _on_save_preset():
|
||||
var cname = _current_char_name(); if cname == "": return
|
||||
var data = _char_cache[cname]
|
||||
var out_data = {}
|
||||
for mesh_name in data:
|
||||
var d = data[mesh_name]
|
||||
out_data[mesh_name] = {
|
||||
"category": d.category,
|
||||
"fill_color": d.fill_color.to_html(),
|
||||
"masks": d.masks.map(func(m): return Array(m)),
|
||||
"img_path": d.img_path,
|
||||
"ref_path": d.ref_path,
|
||||
"img_pos": [d.img_pos.x, d.img_pos.y],
|
||||
"img_scale": d.img_scale,
|
||||
"img_rot": d.img_rot
|
||||
}
|
||||
var f = FileAccess.open(PRESET_DIR + cname + "_preset.json", FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(out_data, "\t"))
|
||||
_status_label.text = "Preset Saved: " + cname + "_preset.json"; _status_label.modulate = Color.GREEN
|
||||
|
||||
func _on_load_preset():
|
||||
var cname = _current_char_name(); if cname == "": return
|
||||
var path = PRESET_DIR + cname + "_preset.json"
|
||||
if not FileAccess.file_exists(path): _status_label.text = "No preset found for " + cname; _status_label.modulate = Color.RED; return
|
||||
var f = FileAccess.open(path, FileAccess.READ)
|
||||
var raw = JSON.parse_string(f.get_as_text())
|
||||
if not raw: return
|
||||
|
||||
var cache = {}
|
||||
for mesh_name in raw:
|
||||
var d = raw[mesh_name]
|
||||
var masks_arr = d.get("masks", [])
|
||||
if masks_arr.is_empty() and d.has("mask"): masks_arr = [d["mask"]]
|
||||
|
||||
cache[mesh_name] = {
|
||||
"category": int(d.category),
|
||||
"fill_color": Color.html(d.fill_color),
|
||||
"masks": masks_arr.map(func(m): return PackedVector2Array(m)),
|
||||
"img_path": d.img_path,
|
||||
"ref_path": d.get("ref_path", ""),
|
||||
"img_pos": Vector2(d.img_pos[0], d.img_pos[1]),
|
||||
"img_scale": float(d.img_scale),
|
||||
"img_rot": float(d.img_rot)
|
||||
}
|
||||
_char_cache[cname] = cache
|
||||
_update_character_preview()
|
||||
_status_label.text = "Preset Loaded: " + cname; _status_label.modulate = Color.CYAN
|
||||
|
||||
func _force_t_pose(node: Node):
|
||||
if node is AnimationPlayer:
|
||||
if node.has_animation("RESET"):
|
||||
node.play("RESET")
|
||||
node.advance(0)
|
||||
node.stop()
|
||||
if node is Skeleton3D:
|
||||
for i in node.get_bone_count():
|
||||
node.reset_bone_pose(i)
|
||||
for c in node.get_children(): _force_t_pose(c)
|
||||
|
||||
func _get_material_texture(node: Node) -> Texture2D:
|
||||
if node is MeshInstance3D and node.mesh:
|
||||
var mat = node.get_surface_override_material(0)
|
||||
if not mat: mat = node.mesh.surface_get_material(0)
|
||||
if mat is StandardMaterial3D or mat is ORMMaterial3D:
|
||||
return mat.albedo_texture
|
||||
elif mat is ShaderMaterial:
|
||||
# Try to find a parameter named albedo or main_texture
|
||||
for p in ["albedo_texture", "main_texture", "texture_albedo"]:
|
||||
var t = mat.get_shader_parameter(p)
|
||||
if t is Texture2D: return t
|
||||
return null
|
||||
|
||||
func shader_from_string(code: String) -> Shader:
|
||||
var s = Shader.new(); s.code = code; return s
|
||||
|
||||
func _get_current_wire_color() -> Color:
|
||||
match _wire_color_opt.selected:
|
||||
0: return Color.BLACK
|
||||
1: return Color.WHITE
|
||||
2: return Color.YELLOW
|
||||
3: return Color.GREEN
|
||||
return Color.YELLOW
|
||||
|
||||
func _to_screen(pts: PackedVector2Array, rs: Vector2, off: Vector2) -> PackedVector2Array:
|
||||
var out := PackedVector2Array()
|
||||
for p in pts: out.append(p * rs + off)
|
||||
return out
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbcyurv4m7mka
|
||||
@@ -0,0 +1,137 @@
|
||||
extends Control
|
||||
## FragmentCraftPanel — Shows all craftable skins and their fragment requirements.
|
||||
## Linked from GachaPanel via the "🧩 Fragment Craft" button.
|
||||
|
||||
signal closed
|
||||
|
||||
# ─── Node refs ───────────────────────────────────────────────────────────────
|
||||
@onready var back_btn := %BackBtn as Button
|
||||
@onready var recipe_list := %RecipeList as VBoxContainer
|
||||
@onready var status_label := %StatusLabel as Label
|
||||
@onready var frag_balance := %FragBalance as Label
|
||||
|
||||
const FRAG_ICONS := {
|
||||
"frag_common": "⬜",
|
||||
"frag_uncommon": "🟩",
|
||||
"frag_rare": "🟦"
|
||||
}
|
||||
|
||||
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
func _ready() -> void:
|
||||
back_btn.pressed.connect(_on_close)
|
||||
|
||||
func show_panel() -> void:
|
||||
show()
|
||||
_refresh()
|
||||
|
||||
func _refresh() -> void:
|
||||
_update_frag_balance()
|
||||
_rebuild_recipe_list()
|
||||
|
||||
# ─── Fragment balance header ──────────────────────────────────────────────────
|
||||
func _update_frag_balance() -> void:
|
||||
var frags: Dictionary = UserProfileManager.fragments
|
||||
var parts: Array = []
|
||||
for fid in ["frag_common", "frag_uncommon", "frag_rare"]:
|
||||
var icon: String = FRAG_ICONS.get(fid, "?")
|
||||
var count: int = frags.get(fid, 0)
|
||||
parts.append("%s ×%d" % [icon, count])
|
||||
frag_balance.text = " ".join(parts)
|
||||
|
||||
# ─── Recipe cards ─────────────────────────────────────────────────────────────
|
||||
func _rebuild_recipe_list() -> void:
|
||||
for c in recipe_list.get_children(): c.queue_free()
|
||||
await get_tree().process_frame
|
||||
|
||||
var recipes: Dictionary = GachaManager.get_all_recipes()
|
||||
for recipe_id in recipes:
|
||||
var recipe: Dictionary = recipes[recipe_id]
|
||||
var card := _make_recipe_card(recipe_id, recipe)
|
||||
recipe_list.add_child(card)
|
||||
|
||||
func _make_recipe_card(recipe_id: String, recipe: Dictionary) -> PanelContainer:
|
||||
var can_craft: bool = GachaManager.can_craft(recipe_id)
|
||||
var frags: Dictionary = UserProfileManager.fragments
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 14)
|
||||
margin.add_theme_constant_override("margin_top", 10)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 10)
|
||||
panel.add_child(margin)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(hbox)
|
||||
|
||||
# Left info
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
hbox.add_child(vbox)
|
||||
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = recipe.get("name", recipe_id)
|
||||
name_lbl.add_theme_font_size_override("font_size", 16)
|
||||
name_lbl.add_theme_color_override("font_color", Color(0.9, 0.8, 0.3))
|
||||
vbox.add_child(name_lbl)
|
||||
|
||||
var char_cat_lbl := Label.new()
|
||||
char_cat_lbl.text = "%s • %s" % [recipe.get("character", "All"), recipe.get("category", "").capitalize()]
|
||||
char_cat_lbl.add_theme_font_size_override("font_size", 11)
|
||||
char_cat_lbl.add_theme_color_override("font_color", Color(0.65, 0.65, 0.65))
|
||||
vbox.add_child(char_cat_lbl)
|
||||
|
||||
# Fragment cost row
|
||||
var cost_hbox := HBoxContainer.new()
|
||||
cost_hbox.add_theme_constant_override("separation", 12)
|
||||
vbox.add_child(cost_hbox)
|
||||
|
||||
var cost: Dictionary = recipe.get("cost", {})
|
||||
for fid in ["frag_common", "frag_uncommon", "frag_rare"]:
|
||||
if not cost.has(fid): continue
|
||||
var needed: int = cost[fid]
|
||||
var have: int = frags.get(fid, 0)
|
||||
var icon: String = FRAG_ICONS.get(fid, "?")
|
||||
var cost_lbl := Label.new()
|
||||
cost_lbl.text = "%s %d/%d" % [icon, have, needed]
|
||||
cost_lbl.add_theme_font_size_override("font_size", 13)
|
||||
cost_lbl.add_theme_color_override("font_color",
|
||||
Color(0.4, 1.0, 0.5) if have >= needed else Color(1.0, 0.4, 0.4))
|
||||
cost_hbox.add_child(cost_lbl)
|
||||
|
||||
# Craft button
|
||||
var craft_btn := Button.new()
|
||||
craft_btn.text = "🔨 Craft"
|
||||
craft_btn.custom_minimum_size = Vector2(100, 40)
|
||||
craft_btn.disabled = not can_craft
|
||||
if not can_craft:
|
||||
craft_btn.modulate = Color(0.5, 0.5, 0.5, 0.7)
|
||||
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id, panel))
|
||||
hbox.add_child(craft_btn)
|
||||
|
||||
return panel
|
||||
|
||||
# ─── Craft action ─────────────────────────────────────────────────────────────
|
||||
func _on_craft_pressed(recipe_id: String, _card: PanelContainer) -> void:
|
||||
var ok := GachaManager.craft(recipe_id)
|
||||
if ok:
|
||||
var recipes := GachaManager.get_all_recipes()
|
||||
var name: String = recipes.get(recipe_id, {}).get("name", recipe_id)
|
||||
_set_status("✅ Crafted: %s!" % name, Color(0.4, 1.0, 0.4))
|
||||
_refresh()
|
||||
else:
|
||||
_set_status("❌ Not enough fragments.", Color(1.0, 0.4, 0.4))
|
||||
|
||||
func _set_status(msg: String, col: Color = Color.WHITE) -> void:
|
||||
status_label.add_theme_color_override("font_color", col)
|
||||
status_label.text = msg
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
if status_label.text == msg: status_label.text = ""
|
||||
|
||||
func _on_close() -> void:
|
||||
hide()
|
||||
closed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnenx8f7cftrs
|
||||
@@ -0,0 +1,212 @@
|
||||
extends Control
|
||||
## GachaPanel — Two-banner gacha interface.
|
||||
## Banners: Star (✦) and Gold (▤)
|
||||
## Pull results shown in animated card reveal.
|
||||
|
||||
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 balance_label := %BalanceLabel 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 Label
|
||||
@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
|
||||
|
||||
# ─── 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),
|
||||
"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 ✨"
|
||||
}
|
||||
|
||||
# ─── 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
|
||||
star_tab_btn.modulate = Color(1.3, 1.1, 0.3) if id == "star" else Color.WHITE
|
||||
gold_tab_btn.modulate = Color(1.3, 1.1, 0.3) if id == "gold" else Color.WHITE
|
||||
_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")
|
||||
balance_label.text = "%s %d" % [icon, bal]
|
||||
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))
|
||||
|
||||
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
|
||||
status_label.text = "Rolling..."
|
||||
var results: Array = await _run_pull(count)
|
||||
_pulling = false
|
||||
if results.is_empty():
|
||||
status_label.text = "❌ Not enough currency!"
|
||||
_refresh_ui()
|
||||
return
|
||||
status_label.text = ""
|
||||
_refresh_ui()
|
||||
_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)
|
||||
|
||||
# ─── Result display ───────────────────────────────────────────────────────────
|
||||
func _show_results(results: Array) -> void:
|
||||
# Clear old cards
|
||||
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)
|
||||
# Staggered reveal
|
||||
await get_tree().create_timer(0.08).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 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)
|
||||
|
||||
# Rarity icon
|
||||
var icon_lbl := Label.new()
|
||||
icon_lbl.text = _rarity_icon(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)
|
||||
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))
|
||||
vbox.add_child(rar_lbl)
|
||||
|
||||
# Flash animation
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
func _on_close() -> void:
|
||||
hide()
|
||||
closed.emit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://clkxaudy5hxfj
|
||||
@@ -33,6 +33,7 @@ signal profile_updated
|
||||
@onready var costume_tab_btn := %CostumeTabBtn as Button
|
||||
@onready var glove_tab_btn := %GloveTabBtn as Button
|
||||
@onready var acc_tab_btn := %AccTabBtn as Button
|
||||
@onready var frag_tab_btn := %FragTabBtn as Button
|
||||
@onready var drag_zone := %DragZone as Control
|
||||
@onready var character_root := %CharacterRoot as Node3D
|
||||
@onready var char_left_btn := %CharLeftBtn as Button
|
||||
@@ -173,6 +174,7 @@ func _connect_signals() -> void:
|
||||
costume_tab_btn.pressed.connect(func(): _on_category_tab_pressed("costume"))
|
||||
glove_tab_btn.pressed.connect(func(): _on_category_tab_pressed("glove"))
|
||||
acc_tab_btn.pressed.connect(func(): _on_category_tab_pressed("accessory"))
|
||||
frag_tab_btn.pressed.connect(func(): _on_category_tab_pressed("fragment"))
|
||||
|
||||
# Item grid slot buttons
|
||||
for i in _item_slots.size():
|
||||
@@ -248,13 +250,18 @@ func _highlight_active_tab() -> void:
|
||||
"head": head_tab_btn,
|
||||
"costume": costume_tab_btn,
|
||||
"glove": glove_tab_btn,
|
||||
"accessory": acc_tab_btn
|
||||
"accessory": acc_tab_btn,
|
||||
"fragment": frag_tab_btn
|
||||
}
|
||||
for cat: String in map:
|
||||
(map[cat] as Button).modulate = Color(1.3, 1.3, 0.4, 1) if cat == _current_category else Color.WHITE
|
||||
|
||||
func _rebuild_category_items() -> void:
|
||||
_category_items.clear()
|
||||
# Fragment tab handled separately
|
||||
if _current_category == "fragment":
|
||||
_rebuild_fragment_items()
|
||||
return
|
||||
var prefix := _current_category + "_"
|
||||
# Resolve the current character's node name (e.g. "Copper" → "Oldpop")
|
||||
var current_char_display: String = CHARACTERS[_loadout_index]
|
||||
@@ -283,6 +290,12 @@ func _rebuild_category_items() -> void:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Item grid
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
func _rebuild_fragment_items() -> void:
|
||||
var frags: Dictionary = UserProfileManager.fragments
|
||||
for fid in ["frag_common", "frag_uncommon", "frag_rare"]:
|
||||
var count: int = frags.get(fid, 0)
|
||||
if count > 0: _category_items.append(fid)
|
||||
|
||||
func _populate_item_grid() -> void:
|
||||
var start: int = _current_page * ITEMS_PER_PAGE
|
||||
var total: int = _category_items.size()
|
||||
|
||||
Reference in New Issue
Block a user