Files
open-wc/scenes/character_geoset_controller.gd
T
2026-04-20 20:26:14 +04:00

360 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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