first commit

This commit is contained in:
2026-04-20 20:25:10 +04:00
commit 1fe2a72ef1
1926 changed files with 156731 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
## CharacterGeosetController WoW 3.3.5a (build 12340) character geoset manager.
##
## Attach to the root of an imported character GLB scene.
##
## Correct geoset ID ranges (from M2SkinMeshPartID, WotLK 3.3.5):
## 0 skin base body, always visible
## 1 34 hair hair style variants
## 101 124 facial1 beard
## 201 219 facial2 mustache
## 301 319 facial3 sideburns
## 401 405 gloves
## 501 510 boots
## 601 614 shirt
## 701 711 ears
## 801 804 wristbands (801 = bare wrists)
## 901 905 kneepads
## 1001 1004 chest
## 1101 1105 pants
## 1201 1204 tabard
## 1301 1303 legs
## 1401 1414 shirt_doublet
## 1501 1524 cape
## 1601 1614 facial_jewelry
## 1701 1705 eye_effects
## 1801 1804 belt
## 1901 1914 trail
## 2001 2008 feet
@tool
extends Node3D
const EYE_GLOW_SHADER := preload("res://scenes/eye_glow.gdshader")
## Customisation (0 = auto-select first available on _ready)
@export var hair_style: int = 0 : set = _set_hair
@export var facial1: int = 0 : set = _set_facial1
@export var facial2: int = 0 : set = _set_facial2
@export var facial3: int = 0 : set = _set_facial3
@export var ears: int = 0 : set = _set_ears
@export var eye_effects: int = 0 : set = _set_eye_effects
@export var eye_glow_intensity: float = 1.3
## Skin / face customisation forwarded to CharacterTextureCompositor sibling/child.
@export var skin_color: int = 0 : set = _set_skin_color
@export var face_style: int = 0 : set = _set_face_style
## Internal: available geoset IDs per category (populated in _ready from actual model).
var _available: Dictionary = {} # category_name -> Array[int] (sorted)
## Internal: valid ranges for texture-based customisation (from compositor).
var _skin_color_count: int = 0
var _face_style_count: int = 0
## Equipment toggles (off by default = naked character)
@export var show_gloves: bool = false : set = _set_gloves
@export var show_boots: bool = false : set = _set_boots
@export var show_shirt: bool = false : set = _set_shirt
## Wristband geoset ID: 801 = bare wrists (no bracer), 802+ = equipped bracers.
## 0 = auto (picks first available in model).
@export var wristband_style: int = 0 : set = _set_wristbands
@export var show_kneepads: bool = false : set = _set_kneepads
@export var show_chest: bool = false : set = _set_chest
@export var show_pants: bool = false : set = _set_pants
@export var show_tabard: bool = false : set = _set_tabard
@export var show_legs: bool = false : set = _set_legs
@export var show_cape: bool = false : set = _set_cape
@export var show_belt: bool = false : set = _set_belt
@export var show_feet: bool = false : set = _set_feet
# ── WoW 3.3.5 geoset ID ranges ───────────────────────────────────────────────
# Each entry: [start, end_exclusive, category_name]
const RANGES: Array = [
[0, 1, "skin"],
[1, 35, "hair"],
[101, 125, "facial1"],
[201, 220, "facial2"],
[301, 320, "facial3"],
[401, 406, "gloves"],
[501, 511, "boots"],
[601, 615, "shirt"],
[701, 712, "ears"],
[801, 805, "wristbands"],
[901, 906, "kneepads"],
[1001, 1005, "chest"],
[1101, 1106, "pants"],
[1201, 1205, "tabard"],
[1301, 1304, "legs"],
[1401, 1415, "shirt_doublet"],
[1501, 1525, "cape"],
[1601, 1615, "facial_jewelry"],
[1701, 1706, "eye_effects"],
[1801, 1805, "belt"],
[1901, 1915, "trail"],
[2001, 2009, "feet"],
]
func _ready() -> void:
var nodes := _geoset_nodes()
if nodes.is_empty():
return
# Build _available: actual geoset IDs present in the model, grouped by category.
_available.clear()
for child in nodes:
var gid := _geoset_id(child.name)
var cat := _category(gid)
if not _available.has(cat):
_available[cat] = []
_available[cat].append(gid)
for cat in _available:
(_available[cat] as Array).sort()
# Query compositor for skin/face counts (it knows what PNGs are on disk).
var comp := _get_compositor()
if comp:
_skin_color_count = comp.get_skin_color_count()
_face_style_count = comp.get_face_style_count()
# Clamp each export to the nearest valid value for this model.
hair_style = _clamp_geoset("hair", hair_style)
facial1 = _clamp_geoset("facial1", facial1)
facial2 = _clamp_geoset("facial2", facial2)
facial3 = _clamp_geoset("facial3", facial3)
ears = _clamp_geoset("ears", ears)
eye_effects = _clamp_geoset("eye_effects", eye_effects)
wristband_style = _clamp_geoset("wristbands", wristband_style)
skin_color = clampi(skin_color, 0, maxi(_skin_color_count - 1, 0))
face_style = clampi(face_style, 0, maxi(_face_style_count - 1, 0))
_apply_all(nodes)
_apply_eye_glow_shader(nodes)
# ── setters ───────────────────────────────────────────────────────────────────
func _set_hair(v): hair_style = _clamp_geoset("hair", v); _apply_variant("hair", hair_style)
func _set_facial1(v): facial1 = _clamp_geoset("facial1", v); _apply_variant("facial1", facial1)
func _set_facial2(v): facial2 = _clamp_geoset("facial2", v); _apply_variant("facial2", facial2)
func _set_facial3(v): facial3 = _clamp_geoset("facial3", v); _apply_variant("facial3", facial3)
func _set_ears(v): ears = _clamp_geoset("ears", v); _apply_variant("ears", ears)
func _set_eye_effects(v): eye_effects = _clamp_geoset("eye_effects", v); _apply_variant("eye_effects", eye_effects)
func _set_skin_color(v: int) -> void:
skin_color = clampi(v, 0, maxi(_skin_color_count - 1, 0))
var c := _get_compositor()
if c: c.skin_color = skin_color
func _set_face_style(v: int) -> void:
face_style = clampi(v, 0, maxi(_face_style_count - 1, 0))
var c := _get_compositor()
if c: c.face_style = face_style
func _get_compositor() -> Node:
# Look for CharacterTextureCompositor as a direct child first,
# then as a sibling (child of our parent).
for child in get_children():
if child.get_script() and child.has_method("refresh"):
return child
var p := get_parent()
if p:
for sibling in p.get_children():
if sibling != self and sibling.get_script() and sibling.has_method("refresh"):
return sibling
return null
func _set_gloves(v): show_gloves = v; _apply_toggle("gloves", v)
func _set_boots(v): show_boots = v; _apply_toggle("boots", v)
func _set_shirt(v): show_shirt = v; _apply_toggle("shirt", v)
func _set_wristbands(v): wristband_style = v; _apply_variant("wristbands", v)
func _set_kneepads(v): show_kneepads = v; _apply_toggle("kneepads", v)
func _set_chest(v): show_chest = v; _apply_toggle("chest", v)
func _set_pants(v): show_pants = v; _apply_toggle("pants", v)
func _set_tabard(v): show_tabard = v; _apply_toggle("tabard", v)
func _set_legs(v): show_legs = v; _apply_toggle("legs", v)
func _set_cape(v): show_cape = v; _apply_toggle("cape", v)
func _set_belt(v): show_belt = v; _apply_toggle("belt", v)
func _set_feet(v): show_feet = v; _apply_toggle("feet", v)
# ── visibility ────────────────────────────────────────────────────────────────
## Show the geoset matching exact ID `wanted`; hide all others in the category.
func _apply_variant(cat: String, wanted: int) -> void:
for child in _geoset_nodes():
var gid := _geoset_id(child.name)
if _category(gid) == cat:
child.visible = (gid == wanted)
## Show or hide all geosets in a category.
func _apply_toggle(cat: String, on: bool) -> void:
for child in _geoset_nodes():
if _category(_geoset_id(child.name)) == cat:
child.visible = on
func _apply_all(nodes: Array[Node] = []) -> void:
if nodes.is_empty():
nodes = _geoset_nodes()
for child in nodes:
var gid := _geoset_id(child.name)
var cat := _category(gid)
match cat:
"skin": child.visible = true
"hair": child.visible = (gid == hair_style)
"facial1": child.visible = (gid == facial1)
"facial2": child.visible = (gid == facial2)
"facial3": child.visible = (gid == facial3)
"ears": child.visible = (gid == ears)
"eye_effects": child.visible = (gid == eye_effects)
"gloves": child.visible = show_gloves
"boots": child.visible = show_boots
"shirt": child.visible = show_shirt
"wristbands": child.visible = (gid == wristband_style)
"kneepads": child.visible = show_kneepads
"chest": child.visible = show_chest
"pants": child.visible = show_pants
"tabard": child.visible = show_tabard
"legs": child.visible = show_legs
"cape": child.visible = show_cape
"belt": child.visible = show_belt
"feet": child.visible = show_feet
# shirt_doublet, facial_jewelry, trail: follow shirt / facial / trail
"shirt_doublet": child.visible = show_shirt
"facial_jewelry":child.visible = (gid == facial1 + 1500) # rough guess
"trail": child.visible = true
_: child.visible = true # unknown leave visible
# ── helpers ───────────────────────────────────────────────────────────────────
func _geoset_nodes() -> Array[Node]:
var result: Array[Node] = []
_collect(self, result)
return result
func _collect(node: Node, result: Array[Node]) -> void:
for child in node.get_children():
if child.name.begins_with("geoset_") and child is MeshInstance3D:
result.append(child)
else:
_collect(child, result)
## Extract geoset_id from node name "geoset_NNNN_category_vNN"
func _geoset_id(node_name: String) -> int:
var parts := node_name.split("_")
if parts.size() >= 2:
return parts[1].to_int()
return -1
## Map a geoset ID to its category name using the WoW 3.3.5 ranges.
func _category(gid: int) -> String:
for r in RANGES:
if gid >= r[0] and gid < r[1]:
return r[2]
return "unknown"
## Replace materials on eye-glow geosets with the multiplicative glow shader.
## Detects eye-glow blend in two ways (in priority order):
## 1. GLTF node extras["blend_mode"] == 4 (Mod) or 3 (Add) from M2 material data
## 2. Fallback: geoset ID in the eye_effects range 1701-1705
## Call once after the scene loads.
func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void:
if nodes.is_empty():
nodes = _geoset_nodes()
# Per-race glow colour defaults (Mod blending: tints the eye behind it).
# With blend_mul: result = ALBEDO * background.
# Use saturated hues so the tint is strong; 1.0 on the dominant channel
# keeps that channel of the background intact while suppressing others.
var race_colors := {
"bloodelf": Color(0.1, 1.0, 0.2), # green
"nightelf": Color(0.6, 0.9, 1.0), # silver-blue
"deathknight": Color(0.1, 0.4, 1.0), # icy blue
"scourge": Color(0.1, 0.4, 1.0), # icy blue (undead DKs)
}
# Guess race from scene/model name
var scene_name := get_name().to_lower()
var glow_color := Color(0.2, 1.0, 0.3) # default green
for race in race_colors:
if race in scene_name:
glow_color = race_colors[race]
break
for child in nodes:
var mesh_inst := child as MeshInstance3D
if not mesh_inst:
continue
# Determine whether this geoset uses WoW eye-glow blending.
# Primary: check blend_mode from M2 material stored in GLTF extras.
# blend_mode=4 → Mod (DST_COLOR * src) — most races (Blood Elf, Night Elf, etc.)
# blend_mode=3 → Add (SRC_ALPHA + ONE) — pure additive (rare; DK particles etc.)
# Fallback: geoset ID in the eye_effects range (1701-1705) for older GLBs.
var is_eye_glow := false
if mesh_inst.has_meta("extras"):
var extras = mesh_inst.get_meta("extras")
if extras is Dictionary and extras.has("blend_mode"):
var bm: int = extras["blend_mode"]
is_eye_glow = (bm == 4 or bm == 3)
if not is_eye_glow:
is_eye_glow = (_category(_geoset_id(child.name)) == "eye_effects")
if not is_eye_glow:
continue
# Replace every surface material with the glow shader
for si in range(mesh_inst.get_surface_override_material_count()):
var orig_mat := mesh_inst.mesh.surface_get_material(si)
var shader_mat := ShaderMaterial.new()
shader_mat.shader = EYE_GLOW_SHADER
shader_mat.set_shader_parameter("glow_color", Vector3(glow_color.r, glow_color.g, glow_color.b))
shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity)
# Carry over the original albedo texture if it was a StandardMaterial3D
if orig_mat is StandardMaterial3D:
var std := orig_mat as StandardMaterial3D
if std.albedo_texture:
shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture)
mesh_inst.set_surface_override_material(si, shader_mat)
## Returns sorted list of geoset IDs available for a given category.
## After _ready() this uses the cached _available dict; before _ready() it scans nodes.
func get_ids(cat: String) -> Array[int]:
if not _available.is_empty():
var ids: Array = _available.get(cat, [])
var result: Array[int] = []
for id in ids:
result.append(id)
return result
# Fallback: scan live (called before _ready in edge cases)
var result: Array[int] = []
for child in _geoset_nodes():
var gid := _geoset_id(child.name)
if _category(gid) == cat and not gid in result:
result.append(gid)
result.sort()
return result
## Returns valid skin colour count for this model (0 if compositor not found yet).
func get_skin_color_count() -> int: return _skin_color_count
## Returns valid face style count for this model (0 if compositor not found yet).
func get_face_style_count() -> int: return _face_style_count
## Clamp/snap v to the nearest available geoset ID in cat.
## Returns v unchanged if _available is not populated yet or cat is missing.
func _clamp_geoset(cat: String, v: int) -> int:
if not _available.has(cat):
return v
var ids: Array = _available[cat] # already sorted
if ids.is_empty():
return v
if v in ids:
return v
# Pick the nearest available ID
var best: int = ids[0]
var best_dist: int = abs(v - best)
for id in ids:
var d: int = abs(v - id)
if d < best_dist:
best_dist = d
best = id
return best