победа над водопадами, шейдеры m2
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
extends Control
|
||||
|
||||
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
|
||||
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
|
||||
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
|
||||
const CHARACTER_ROOT := "res://src/resources/characters"
|
||||
const EXTRACTED_DIR := "res://data/extracted"
|
||||
const CHARACTER_YAW_OFFSET_DEGREES := 90.0
|
||||
|
||||
const CLASS_IDS := {
|
||||
"Warrior": 1,
|
||||
"Paladin": 2,
|
||||
"Hunter": 3,
|
||||
"Rogue": 4,
|
||||
"Priest": 5,
|
||||
"Death Knight": 6,
|
||||
"Shaman": 7,
|
||||
"Mage": 8,
|
||||
"Warlock": 9,
|
||||
"Druid": 11,
|
||||
}
|
||||
|
||||
const GEOSET_CONTROLS := [
|
||||
{"cat": "hair", "label": "Hair"},
|
||||
{"cat": "facial1", "label": "Facial 1"},
|
||||
{"cat": "facial2", "label": "Facial 2"},
|
||||
{"cat": "facial3", "label": "Facial 3"},
|
||||
{"cat": "ears", "label": "Ears"},
|
||||
{"cat": "eye_effects", "label": "Eyes"},
|
||||
{"cat": "wristbands", "label": "Wrist"},
|
||||
]
|
||||
|
||||
const TOGGLE_CONTROLS := [
|
||||
{"property": "show_gloves", "label": "Gloves"},
|
||||
{"property": "show_boots", "label": "Boots"},
|
||||
{"property": "show_shirt", "label": "Shirt"},
|
||||
{"property": "show_kneepads", "label": "Knees"},
|
||||
{"property": "show_chest", "label": "Chest"},
|
||||
{"property": "show_pants", "label": "Pants"},
|
||||
{"property": "show_tabard", "label": "Tabard"},
|
||||
{"property": "show_legs", "label": "Legs"},
|
||||
{"property": "show_cape", "label": "Cape"},
|
||||
{"property": "show_belt", "label": "Belt"},
|
||||
{"property": "show_feet", "label": "Feet"},
|
||||
]
|
||||
|
||||
var _models: Dictionary = {}
|
||||
var _race_names: Array[String] = []
|
||||
var _selected_race := ""
|
||||
var _selected_gender := ""
|
||||
var _selected_model: Dictionary = {}
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _preview_anchor: Node3D
|
||||
var _camera: Camera3D
|
||||
var _model_root: Node3D
|
||||
var _controller: Node
|
||||
var _compositor: Node
|
||||
var _outfit_resolver: RefCounted
|
||||
var _current_outfit: Dictionary = {}
|
||||
|
||||
var _race_option: OptionButton
|
||||
var _gender_option: OptionButton
|
||||
var _model_option: OptionButton
|
||||
var _class_option: OptionButton
|
||||
var _skin_spin: SpinBox
|
||||
var _face_spin: SpinBox
|
||||
var _hair_color_spin: SpinBox
|
||||
var _flags_spin: SpinBox
|
||||
var _rotation_slider: HSlider
|
||||
var _status_label: Label
|
||||
var _control_container: VBoxContainer
|
||||
var _geoset_options: Dictionary = {}
|
||||
var _toggle_checks: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_outfit_resolver = OUTFIT_RESOLVER_SCRIPT.new()
|
||||
if not _outfit_resolver.call("load_dbcs", EXTRACTED_DIR):
|
||||
push_warning("CharacterCreator: CharStartOutfit/ItemDisplayInfo DBCs not loaded")
|
||||
_scan_models()
|
||||
_build_ui()
|
||||
_populate_races()
|
||||
if not _race_names.is_empty():
|
||||
_select_race(_race_names[0])
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _preview_anchor:
|
||||
_preview_anchor.rotation.y = deg_to_rad(float(_rotation_slider.value))
|
||||
|
||||
|
||||
func _scan_models() -> void:
|
||||
_models.clear()
|
||||
_race_names.clear()
|
||||
var root := DirAccess.open(CHARACTER_ROOT)
|
||||
if root == null:
|
||||
push_warning("CharacterCreator: missing character root: %s" % CHARACTER_ROOT)
|
||||
return
|
||||
|
||||
root.list_dir_begin()
|
||||
var race_dir := root.get_next()
|
||||
while not race_dir.is_empty():
|
||||
if root.current_is_dir() and not race_dir.begins_with("."):
|
||||
_scan_race(race_dir)
|
||||
race_dir = root.get_next()
|
||||
root.list_dir_end()
|
||||
_race_names.sort_custom(func(a: String, b: String) -> bool: return _display_name(a) < _display_name(b))
|
||||
|
||||
|
||||
func _scan_race(race: String) -> void:
|
||||
var race_path := CHARACTER_ROOT.path_join(race)
|
||||
var race_access := DirAccess.open(race_path)
|
||||
if race_access == null:
|
||||
return
|
||||
|
||||
var race_data: Dictionary = {}
|
||||
race_access.list_dir_begin()
|
||||
var gender_dir := race_access.get_next()
|
||||
while not gender_dir.is_empty():
|
||||
if race_access.current_is_dir() and not gender_dir.begins_with("."):
|
||||
var models := _scan_gender_models(race, gender_dir)
|
||||
if not models.is_empty():
|
||||
race_data[gender_dir] = models
|
||||
gender_dir = race_access.get_next()
|
||||
race_access.list_dir_end()
|
||||
|
||||
if not race_data.is_empty():
|
||||
_models[race] = race_data
|
||||
_race_names.append(race)
|
||||
|
||||
|
||||
func _scan_gender_models(race: String, gender: String) -> Array:
|
||||
var result: Array = []
|
||||
var gender_path := CHARACTER_ROOT.path_join(race).path_join(gender)
|
||||
var access := DirAccess.open(gender_path)
|
||||
if access == null:
|
||||
return result
|
||||
|
||||
access.list_dir_begin()
|
||||
var file := access.get_next()
|
||||
while not file.is_empty():
|
||||
if not access.current_is_dir() and file.get_extension().to_lower() == "glb":
|
||||
var glb_path := gender_path.path_join(file)
|
||||
result.append({
|
||||
"race": race,
|
||||
"gender": gender,
|
||||
"name": file.get_basename(),
|
||||
"path": glb_path,
|
||||
"textures_dir": gender_path.path_join(file.get_basename() + "_textures"),
|
||||
})
|
||||
file = access.get_next()
|
||||
access.list_dir_end()
|
||||
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return String(a["name"]) < String(b["name"]))
|
||||
return result
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
var root := HSplitContainer.new()
|
||||
root.name = "Layout"
|
||||
root.split_offset = 780
|
||||
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(root)
|
||||
|
||||
var viewport_container := SubViewportContainer.new()
|
||||
viewport_container.name = "Preview"
|
||||
viewport_container.stretch = true
|
||||
viewport_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
root.add_child(viewport_container)
|
||||
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
_viewport.size = Vector2i(900, 720)
|
||||
viewport_container.add_child(_viewport)
|
||||
_build_preview_world()
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "Controls"
|
||||
panel.custom_minimum_size = Vector2(360, 0)
|
||||
root.add_child(panel)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
panel.add_child(scroll)
|
||||
|
||||
_control_container = VBoxContainer.new()
|
||||
_control_container.add_theme_constant_override("separation", 8)
|
||||
scroll.add_child(_control_container)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Character Creator"
|
||||
title.add_theme_font_size_override("font_size", 22)
|
||||
_control_container.add_child(title)
|
||||
|
||||
_race_option = _add_option("Race", _on_race_selected)
|
||||
_gender_option = _add_option("Gender", _on_gender_selected)
|
||||
_model_option = _add_option("Model", _on_model_selected)
|
||||
_class_option = _add_option("Class", _on_class_selected)
|
||||
for class_label in CLASS_IDS.keys():
|
||||
_class_option.add_item(class_label)
|
||||
_class_option.set_item_metadata(_class_option.item_count - 1, int(CLASS_IDS[class_label]))
|
||||
|
||||
_skin_spin = _add_spin("Skin", 0, 0, 1, _on_skin_changed)
|
||||
_face_spin = _add_spin("Face", 0, 0, 1, _on_face_changed)
|
||||
_hair_color_spin = _add_spin("Hair Color", 0, 15, 1, _on_hair_color_changed)
|
||||
_flags_spin = _add_spin("Flags", 0, 31, 1, _on_flags_changed)
|
||||
|
||||
for spec in GEOSET_CONTROLS:
|
||||
var option := _add_option(String(spec["label"]), _on_geoset_selected.bind(String(spec["cat"])))
|
||||
_geoset_options[String(spec["cat"])] = option
|
||||
|
||||
var equipment_label := Label.new()
|
||||
equipment_label.text = "Equipment Geosets"
|
||||
equipment_label.add_theme_font_size_override("font_size", 16)
|
||||
_control_container.add_child(equipment_label)
|
||||
|
||||
var toggle_grid := GridContainer.new()
|
||||
toggle_grid.columns = 2
|
||||
_control_container.add_child(toggle_grid)
|
||||
for spec in TOGGLE_CONTROLS:
|
||||
var check := CheckBox.new()
|
||||
check.text = String(spec["label"])
|
||||
check.toggled.connect(_on_toggle_changed.bind(String(spec["property"])))
|
||||
toggle_grid.add_child(check)
|
||||
_toggle_checks[String(spec["property"])] = check
|
||||
|
||||
_rotation_slider = _add_slider("Rotation", -180.0, 180.0, 0.0)
|
||||
|
||||
var reload := Button.new()
|
||||
reload.text = "Rescan Models"
|
||||
reload.pressed.connect(_on_rescan_pressed)
|
||||
_control_container.add_child(reload)
|
||||
|
||||
var starter := Button.new()
|
||||
starter.text = "Apply Starter Outfit"
|
||||
starter.pressed.connect(_apply_starter_outfit)
|
||||
_control_container.add_child(starter)
|
||||
|
||||
_status_label = Label.new()
|
||||
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_status_label.text = ""
|
||||
_control_container.add_child(_status_label)
|
||||
|
||||
|
||||
func _build_preview_world() -> void:
|
||||
var world := Node3D.new()
|
||||
world.name = "PreviewWorld"
|
||||
_viewport.add_child(world)
|
||||
|
||||
var env := WorldEnvironment.new()
|
||||
var environment := Environment.new()
|
||||
environment.background_mode = Environment.BG_COLOR
|
||||
environment.background_color = Color(0.04, 0.045, 0.055)
|
||||
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
environment.ambient_light_color = Color(0.55, 0.58, 0.62)
|
||||
environment.ambient_light_energy = 1.0
|
||||
env.environment = environment
|
||||
world.add_child(env)
|
||||
|
||||
var key_light := DirectionalLight3D.new()
|
||||
key_light.rotation_degrees = Vector3(-35, -35, 0)
|
||||
key_light.light_energy = 2.4
|
||||
world.add_child(key_light)
|
||||
|
||||
var fill_light := OmniLight3D.new()
|
||||
fill_light.position = Vector3(-2.5, 1.8, 2.2)
|
||||
fill_light.light_energy = 1.2
|
||||
fill_light.omni_range = 5.0
|
||||
world.add_child(fill_light)
|
||||
|
||||
_preview_anchor = Node3D.new()
|
||||
_preview_anchor.name = "CharacterAnchor"
|
||||
world.add_child(_preview_anchor)
|
||||
|
||||
_camera = Camera3D.new()
|
||||
_camera.name = "Camera"
|
||||
_camera.position = Vector3(0, 1.25, 3.2)
|
||||
_camera.rotation_degrees = Vector3(-7, 0, 0)
|
||||
_camera.fov = 38.0
|
||||
_camera.current = true
|
||||
world.add_child(_camera)
|
||||
|
||||
|
||||
func _add_option(label_text: String, callback: Callable) -> OptionButton:
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
_control_container.add_child(label)
|
||||
var option := OptionButton.new()
|
||||
option.item_selected.connect(callback)
|
||||
_control_container.add_child(option)
|
||||
return option
|
||||
|
||||
|
||||
func _add_spin(label_text: String, min_value: float, max_value: float, step: float, callback: Callable) -> SpinBox:
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
_control_container.add_child(label)
|
||||
var spin := SpinBox.new()
|
||||
spin.min_value = min_value
|
||||
spin.max_value = max_value
|
||||
spin.step = step
|
||||
spin.value_changed.connect(callback)
|
||||
_control_container.add_child(spin)
|
||||
return spin
|
||||
|
||||
|
||||
func _add_slider(label_text: String, min_value: float, max_value: float, value: float) -> HSlider:
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
_control_container.add_child(label)
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = min_value
|
||||
slider.max_value = max_value
|
||||
slider.step = 1.0
|
||||
slider.value = value
|
||||
_control_container.add_child(slider)
|
||||
return slider
|
||||
|
||||
|
||||
func _populate_races() -> void:
|
||||
_race_option.clear()
|
||||
for race in _race_names:
|
||||
_race_option.add_item(_display_name(race))
|
||||
_race_option.set_item_metadata(_race_option.item_count - 1, race)
|
||||
|
||||
|
||||
func _select_race(race: String) -> void:
|
||||
_selected_race = race
|
||||
_gender_option.clear()
|
||||
var genders: Array[String] = []
|
||||
for gender in (_models.get(race, {}) as Dictionary).keys():
|
||||
genders.append(String(gender))
|
||||
genders.sort()
|
||||
for gender in genders:
|
||||
_gender_option.add_item(_display_name(gender))
|
||||
_gender_option.set_item_metadata(_gender_option.item_count - 1, gender)
|
||||
if not genders.is_empty():
|
||||
_select_gender(genders[0])
|
||||
|
||||
|
||||
func _select_gender(gender: String) -> void:
|
||||
_selected_gender = gender
|
||||
_model_option.clear()
|
||||
var models: Array = (_models.get(_selected_race, {}) as Dictionary).get(gender, [])
|
||||
for model in models:
|
||||
_model_option.add_item(String(model["name"]))
|
||||
_model_option.set_item_metadata(_model_option.item_count - 1, model)
|
||||
if not models.is_empty():
|
||||
_select_model(models[0])
|
||||
|
||||
|
||||
func _select_model(model: Dictionary) -> void:
|
||||
_selected_model = model
|
||||
_load_model(model)
|
||||
|
||||
|
||||
func _load_model(model: Dictionary) -> void:
|
||||
if _model_root and is_instance_valid(_model_root):
|
||||
_model_root.queue_free()
|
||||
_model_root = null
|
||||
_controller = null
|
||||
_compositor = null
|
||||
|
||||
var scene: PackedScene = load(String(model["path"]))
|
||||
if scene == null:
|
||||
_status_label.text = "Failed to load model: %s" % String(model["path"])
|
||||
return
|
||||
|
||||
_model_root = Node3D.new()
|
||||
_model_root.name = "%s_%s" % [String(model["race"]), String(model["gender"])]
|
||||
_model_root.set_script(GEOSET_CONTROLLER_SCRIPT)
|
||||
_model_root.rotation_degrees.y = CHARACTER_YAW_OFFSET_DEGREES
|
||||
|
||||
var instance := scene.instantiate()
|
||||
instance.name = String(model["name"])
|
||||
_model_root.add_child(instance)
|
||||
|
||||
var compositor := Node.new()
|
||||
compositor.name = "CharacterTextureCompositor"
|
||||
compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
|
||||
compositor.set("textures_dir", String(model["textures_dir"]))
|
||||
compositor.set("extracted_dir", EXTRACTED_DIR)
|
||||
_model_root.add_child(compositor)
|
||||
|
||||
_preview_anchor.add_child(_model_root)
|
||||
_controller = _model_root
|
||||
_compositor = compositor
|
||||
_fit_camera_to_model()
|
||||
await get_tree().process_frame
|
||||
_refresh_dynamic_controls()
|
||||
_apply_all_controls()
|
||||
_apply_starter_outfit()
|
||||
_update_status()
|
||||
|
||||
|
||||
func _fit_camera_to_model() -> void:
|
||||
if _model_root == null:
|
||||
return
|
||||
var aabb := _calculate_aabb(_model_root)
|
||||
if aabb.size == Vector3.ZERO:
|
||||
_camera.position = Vector3(0, 1.25, 3.2)
|
||||
return
|
||||
var center := aabb.get_center()
|
||||
_model_root.position = Vector3(-center.x, -aabb.position.y, -center.z)
|
||||
var height := maxf(aabb.size.y, 1.0)
|
||||
_camera.position = Vector3(0, height * 0.55, height * 1.85)
|
||||
_camera.look_at(Vector3(0, height * 0.52, 0), Vector3.UP)
|
||||
|
||||
|
||||
func _calculate_aabb(root: Node) -> AABB:
|
||||
var result := AABB()
|
||||
var has_aabb := false
|
||||
for mesh_inst in _iter_mesh_instances(root):
|
||||
if mesh_inst.mesh == null:
|
||||
continue
|
||||
var local_aabb := mesh_inst.mesh.get_aabb()
|
||||
var global_aabb := mesh_inst.global_transform * local_aabb
|
||||
if not has_aabb:
|
||||
result = global_aabb
|
||||
has_aabb = true
|
||||
else:
|
||||
result = result.merge(global_aabb)
|
||||
return result if has_aabb else AABB()
|
||||
|
||||
|
||||
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
|
||||
var result: Array[MeshInstance3D] = []
|
||||
_collect_mesh_instances(root, result)
|
||||
return result
|
||||
|
||||
|
||||
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
|
||||
if node is MeshInstance3D:
|
||||
result.append(node)
|
||||
for child in node.get_children():
|
||||
_collect_mesh_instances(child, result)
|
||||
|
||||
|
||||
func _refresh_dynamic_controls() -> void:
|
||||
if _controller == null:
|
||||
return
|
||||
|
||||
_skin_spin.max_value = maxi(0, int(_controller.call("get_skin_color_count")) - 1)
|
||||
_face_spin.max_value = maxi(0, int(_controller.call("get_face_style_count")) - 1)
|
||||
_skin_spin.value = clampf(_skin_spin.value, _skin_spin.min_value, _skin_spin.max_value)
|
||||
_face_spin.value = clampf(_face_spin.value, _face_spin.min_value, _face_spin.max_value)
|
||||
|
||||
for spec in GEOSET_CONTROLS:
|
||||
var cat := String(spec["cat"])
|
||||
var option: OptionButton = _geoset_options[cat]
|
||||
option.clear()
|
||||
var ids: Array = _controller.call("get_ids", cat)
|
||||
for id in ids:
|
||||
option.add_item(str(id))
|
||||
option.set_item_metadata(option.item_count - 1, int(id))
|
||||
option.disabled = ids.is_empty()
|
||||
if not ids.is_empty():
|
||||
option.select(0)
|
||||
|
||||
|
||||
func _apply_all_controls() -> void:
|
||||
_on_skin_changed(_skin_spin.value)
|
||||
_on_face_changed(_face_spin.value)
|
||||
_on_hair_color_changed(_hair_color_spin.value)
|
||||
_on_flags_changed(_flags_spin.value)
|
||||
for cat in _geoset_options.keys():
|
||||
var option: OptionButton = _geoset_options[cat]
|
||||
if option.item_count > 0:
|
||||
_apply_geoset(cat, int(option.get_item_metadata(option.selected)))
|
||||
for property in _toggle_checks.keys():
|
||||
var check: CheckBox = _toggle_checks[property]
|
||||
_set_controller_property(property, check.button_pressed)
|
||||
|
||||
|
||||
func _on_race_selected(index: int) -> void:
|
||||
_select_race(String(_race_option.get_item_metadata(index)))
|
||||
|
||||
|
||||
func _on_gender_selected(index: int) -> void:
|
||||
_select_gender(String(_gender_option.get_item_metadata(index)))
|
||||
|
||||
|
||||
func _on_model_selected(index: int) -> void:
|
||||
_select_model(_model_option.get_item_metadata(index))
|
||||
|
||||
|
||||
func _on_class_selected(_index: int) -> void:
|
||||
_apply_starter_outfit()
|
||||
_update_status()
|
||||
|
||||
|
||||
func _on_skin_changed(value: float) -> void:
|
||||
_set_controller_property("skin_color", int(value))
|
||||
_update_status()
|
||||
|
||||
|
||||
func _on_face_changed(value: float) -> void:
|
||||
_set_controller_property("face_style", int(value))
|
||||
_update_status()
|
||||
|
||||
|
||||
func _on_hair_color_changed(_value: float) -> void:
|
||||
_update_status()
|
||||
|
||||
|
||||
func _on_flags_changed(_value: float) -> void:
|
||||
_update_status()
|
||||
|
||||
|
||||
func _on_geoset_selected(index: int, cat: String) -> void:
|
||||
var option: OptionButton = _geoset_options[cat]
|
||||
if index < 0 or index >= option.item_count:
|
||||
return
|
||||
_apply_geoset(cat, int(option.get_item_metadata(index)))
|
||||
_update_status()
|
||||
|
||||
|
||||
func _on_toggle_changed(pressed: bool, property: String) -> void:
|
||||
_set_controller_property(property, pressed)
|
||||
|
||||
|
||||
func _on_rescan_pressed() -> void:
|
||||
_scan_models()
|
||||
_populate_races()
|
||||
if _models.has(_selected_race):
|
||||
_select_race(_selected_race)
|
||||
elif not _race_names.is_empty():
|
||||
_select_race(_race_names[0])
|
||||
|
||||
|
||||
func _apply_starter_outfit() -> void:
|
||||
_current_outfit.clear()
|
||||
if _outfit_resolver == null or _controller == null or _compositor == null:
|
||||
return
|
||||
var class_id := int(_class_option.get_item_metadata(maxi(0, _class_option.selected)))
|
||||
var outfit: Dictionary = _outfit_resolver.call(
|
||||
"resolve_start_outfit",
|
||||
_selected_race,
|
||||
_selected_gender,
|
||||
class_id)
|
||||
if outfit.is_empty():
|
||||
_update_status()
|
||||
return
|
||||
_current_outfit = outfit
|
||||
if _compositor.has_method("set_equipment_components"):
|
||||
_compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
|
||||
if _controller.has_method("apply_outfit_defaults"):
|
||||
_controller.call("apply_outfit_defaults", outfit)
|
||||
_sync_outfit_controls()
|
||||
_update_status()
|
||||
|
||||
|
||||
func _sync_outfit_controls() -> void:
|
||||
if _controller == null:
|
||||
return
|
||||
for property in _toggle_checks.keys():
|
||||
var check: CheckBox = _toggle_checks[property]
|
||||
check.set_pressed_no_signal(bool(_controller.get(property)))
|
||||
var wrist: int = int(_controller.get("wristband_style"))
|
||||
if _geoset_options.has("wristbands"):
|
||||
_select_option_by_metadata(_geoset_options["wristbands"], wrist)
|
||||
|
||||
|
||||
func _apply_geoset(cat: String, id: int) -> void:
|
||||
match cat:
|
||||
"hair":
|
||||
_set_controller_property("hair_style", id)
|
||||
"facial1":
|
||||
_set_controller_property("facial1", id)
|
||||
"facial2":
|
||||
_set_controller_property("facial2", id)
|
||||
"facial3":
|
||||
_set_controller_property("facial3", id)
|
||||
"ears":
|
||||
_set_controller_property("ears", id)
|
||||
"eye_effects":
|
||||
_set_controller_property("eye_effects", id)
|
||||
"wristbands":
|
||||
_set_controller_property("wristband_style", id)
|
||||
|
||||
|
||||
func _set_controller_property(property: String, value: Variant) -> void:
|
||||
if _controller == null:
|
||||
return
|
||||
_controller.set(property, value)
|
||||
|
||||
|
||||
func _update_status() -> void:
|
||||
if _status_label == null:
|
||||
return
|
||||
var packed := _pack_appearance()
|
||||
var equipment := _pack_equipment()
|
||||
var outfit_display_count := 0
|
||||
var outfit_texture_count := 0
|
||||
if not _current_outfit.is_empty():
|
||||
outfit_display_count = (_current_outfit.get("display_ids", PackedInt32Array()) as PackedInt32Array).size()
|
||||
for rel_path in (_current_outfit.get("textures", PackedStringArray()) as PackedStringArray):
|
||||
if not String(rel_path).is_empty():
|
||||
outfit_texture_count += 1
|
||||
_status_label.text = "Appearance 0x%08X\nEquipment 0x%08X\nStarter outfit: %d displays, %d textures\n%s / %s / %s" % [
|
||||
packed,
|
||||
equipment,
|
||||
outfit_display_count,
|
||||
outfit_texture_count,
|
||||
_display_name(_selected_race),
|
||||
_display_name(_selected_gender),
|
||||
String(_selected_model.get("name", "")),
|
||||
]
|
||||
|
||||
|
||||
func _pack_appearance() -> int:
|
||||
var skin := int(_skin_spin.value) & 0x1f
|
||||
var face := int(_face_spin.value) & 0x1f
|
||||
var hair := _selected_local_geoset_id("hair", 1) & 0x1f
|
||||
var hair_color := int(_hair_color_spin.value) & 0x0f
|
||||
var facial := _selected_local_geoset_id("facial1", 101) & 0x0f
|
||||
var class_id := int(_class_option.get_item_metadata(maxi(0, _class_option.selected))) & 0x0f
|
||||
var flags := int(_flags_spin.value) & 0x1f
|
||||
return skin | (face << 5) | (hair << 10) | (hair_color << 15) | (facial << 19) | (class_id << 23) | (flags << 27)
|
||||
|
||||
|
||||
func _pack_equipment() -> int:
|
||||
var upper := 1 if bool((_toggle_checks.get("show_chest") as CheckBox).button_pressed) else 0
|
||||
var lower := 1 if bool((_toggle_checks.get("show_pants") as CheckBox).button_pressed) else 0
|
||||
var hands := 1 if bool((_toggle_checks.get("show_gloves") as CheckBox).button_pressed) else 0
|
||||
var feet := 1 if bool((_toggle_checks.get("show_boots") as CheckBox).button_pressed) else 0
|
||||
return upper | (lower << 8) | (hands << 16) | (feet << 24)
|
||||
|
||||
|
||||
func _selected_local_geoset_id(cat: String, base_id: int) -> int:
|
||||
if not _geoset_options.has(cat):
|
||||
return 0
|
||||
var option: OptionButton = _geoset_options[cat]
|
||||
if option.item_count <= 0 or option.selected < 0:
|
||||
return 0
|
||||
return maxi(0, int(option.get_item_metadata(option.selected)) - base_id)
|
||||
|
||||
|
||||
func _select_option_by_metadata(option: OptionButton, metadata: int) -> void:
|
||||
for i in option.item_count:
|
||||
if int(option.get_item_metadata(i)) == metadata:
|
||||
option.select(i)
|
||||
return
|
||||
|
||||
|
||||
func _display_name(value: String) -> String:
|
||||
var s := value.replace("_", " ").to_lower()
|
||||
var parts := s.split(" ", false)
|
||||
for i in parts.size():
|
||||
parts[i] = String(parts[i]).capitalize()
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwvvg0npx5vlr
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_creator.gd" id="1_creator"]
|
||||
|
||||
[node name="CharacterCreator" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_creator")
|
||||
@@ -181,7 +181,7 @@ 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)
|
||||
child.visible = (gid == wanted) and _geoset_renderable(child, cat)
|
||||
|
||||
## Show or hide all geosets in a category.
|
||||
func _apply_toggle(cat: String, on: bool) -> void:
|
||||
@@ -204,7 +204,7 @@ func _apply_all(nodes: Array[Node] = []) -> void:
|
||||
"facial2": child.visible = (gid == facial2)
|
||||
"facial3": child.visible = (gid == facial3)
|
||||
"ears": child.visible = (gid == ears)
|
||||
"eye_effects": child.visible = (gid == eye_effects)
|
||||
"eye_effects": child.visible = (gid == eye_effects) and _geoset_renderable(child, cat)
|
||||
"gloves": child.visible = show_gloves
|
||||
"boots": child.visible = show_boots
|
||||
"shirt": child.visible = show_shirt
|
||||
@@ -301,18 +301,20 @@ func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void:
|
||||
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)
|
||||
if not _geoset_renderable(mesh_inst, "eye_effects"):
|
||||
mesh_inst.visible = false
|
||||
continue
|
||||
|
||||
for si in range(mesh_inst.mesh.get_surface_count()):
|
||||
var orig_mat := _surface_material(mesh_inst, si)
|
||||
if not _material_has_albedo_texture(orig_mat):
|
||||
continue
|
||||
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)
|
||||
var std := orig_mat as StandardMaterial3D
|
||||
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.
|
||||
@@ -338,6 +340,34 @@ 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
|
||||
|
||||
|
||||
func apply_outfit_defaults(outfit: Dictionary = {}) -> void:
|
||||
show_gloves = true
|
||||
show_boots = true
|
||||
show_shirt = false
|
||||
show_kneepads = true
|
||||
show_chest = false
|
||||
show_pants = false
|
||||
show_tabard = false
|
||||
show_legs = (int(outfit.get("flags", 0)) & 0x4) == 0
|
||||
show_cape = true
|
||||
show_belt = false
|
||||
show_feet = false
|
||||
wristband_style = _clamp_geoset("wristbands", 802)
|
||||
_apply_exact_or_first("gloves", 401)
|
||||
_apply_exact_or_first("boots", 501)
|
||||
_apply_exact_or_first("ears", 702)
|
||||
_apply_exact_or_first("wristbands", wristband_style)
|
||||
_apply_exact_or_first("kneepads", 902)
|
||||
_apply_exact_or_first("legs", 1301)
|
||||
_apply_exact_or_first("cape", 1501)
|
||||
_apply_toggle("shirt", false)
|
||||
_apply_toggle("chest", false)
|
||||
_apply_toggle("pants", false)
|
||||
_apply_toggle("tabard", false)
|
||||
_apply_toggle("belt", false)
|
||||
_apply_toggle("feet", false)
|
||||
|
||||
## 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:
|
||||
@@ -357,3 +387,31 @@ func _clamp_geoset(cat: String, v: int) -> int:
|
||||
best_dist = d
|
||||
best = id
|
||||
return best
|
||||
|
||||
|
||||
func _apply_exact_or_first(cat: String, wanted: int) -> void:
|
||||
var id := _clamp_geoset(cat, wanted)
|
||||
_apply_variant(cat, id)
|
||||
|
||||
|
||||
func _geoset_renderable(node: Node, cat: String) -> bool:
|
||||
if cat != "eye_effects":
|
||||
return true
|
||||
var mesh_inst := node as MeshInstance3D
|
||||
if mesh_inst == null or mesh_inst.mesh == null:
|
||||
return false
|
||||
for si in range(mesh_inst.mesh.get_surface_count()):
|
||||
if _material_has_albedo_texture(_surface_material(mesh_inst, si)):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _surface_material(mesh_inst: MeshInstance3D, surface: int) -> Material:
|
||||
var mat := mesh_inst.get_surface_override_material(surface)
|
||||
if mat == null and mesh_inst.mesh != null:
|
||||
mat = mesh_inst.mesh.surface_get_material(surface)
|
||||
return mat
|
||||
|
||||
|
||||
func _material_has_albedo_texture(mat: Material) -> bool:
|
||||
return mat is StandardMaterial3D and (mat as StandardMaterial3D).albedo_texture != null
|
||||
|
||||
@@ -20,6 +20,16 @@ const NAKED_TORSO_POS := Vector2i(0, 0)
|
||||
const NAKED_PELVIS_POS := Vector2i(0, 128)
|
||||
const FACE_UPPER_POS := Vector2i(0, 320)
|
||||
const FACE_LOWER_POS := Vector2i(0, 384)
|
||||
const COMPONENT_RECTS := [
|
||||
Rect2i(0, 0, 256, 128),
|
||||
Rect2i(0, 128, 256, 128),
|
||||
Rect2i(0, 256, 256, 64),
|
||||
Rect2i(256, 0, 256, 128),
|
||||
Rect2i(256, 128, 256, 64),
|
||||
Rect2i(256, 192, 256, 128),
|
||||
Rect2i(256, 320, 256, 128),
|
||||
Rect2i(256, 448, 256, 64),
|
||||
]
|
||||
|
||||
# ── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,6 +37,9 @@ const FACE_LOWER_POS := Vector2i(0, 384)
|
||||
## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures"
|
||||
@export var textures_dir: String = "" : set = _set_textures_dir
|
||||
|
||||
## Root folder containing extracted WoW data. Used for DBC item component BLPs.
|
||||
@export var extracted_dir: String = "res://data/extracted"
|
||||
|
||||
## Skin colour index (selects skin_XX.png / naked_torso_XX.png /
|
||||
## naked_pelvis_XX.png).
|
||||
@export var skin_color: int = 0 : set = _set_skin_color
|
||||
@@ -38,6 +51,8 @@ const FACE_LOWER_POS := Vector2i(0, 384)
|
||||
|
||||
# MeshInstance3D surfaces to override: Array of {node, surface_idx}
|
||||
var _skin_surfaces: Array = []
|
||||
var _equipment_components := PackedStringArray()
|
||||
var _blp_image_cache: Dictionary = {}
|
||||
|
||||
# True once _ready has run (prevents setters from firing before init)
|
||||
var _ready_done := false
|
||||
@@ -111,7 +126,7 @@ func _scan_skin_surfaces() -> void:
|
||||
|
||||
## A surface is a "skin surface" if:
|
||||
## - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow)
|
||||
## - AND its albedo texture is 512x512 (the composited body skin)
|
||||
## - AND its albedo texture is square body texture, usually 256x256 or 512x512
|
||||
func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
|
||||
# Prefer surface override, fall back to mesh material
|
||||
var mat := mesh_inst.get_surface_override_material(si)
|
||||
@@ -123,11 +138,10 @@ func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
|
||||
var tex := std.albedo_texture
|
||||
if tex == null:
|
||||
return false
|
||||
# Skin texture is 512x512; hair/other textures are smaller
|
||||
var img := tex.get_image()
|
||||
if img == null:
|
||||
return false
|
||||
return img.get_width() == 512 and img.get_height() == 512
|
||||
return img.get_width() == img.get_height() and img.get_width() >= 256
|
||||
|
||||
|
||||
# ── Compositing ──────────────────────────────────────────────────────────────
|
||||
@@ -151,13 +165,20 @@ func _build_skin_texture() -> ImageTexture:
|
||||
return null
|
||||
|
||||
# 2. Naked overlays (underwear)
|
||||
_blit(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS)
|
||||
_blit(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
|
||||
_blit_scaled(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS)
|
||||
_blit_scaled(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
|
||||
|
||||
# 3. Face
|
||||
_blit(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
|
||||
_blit(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)
|
||||
_blit_scaled(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
|
||||
_blit_scaled(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)
|
||||
|
||||
for slot in mini(_equipment_components.size(), COMPONENT_RECTS.size()):
|
||||
var rel_path := String(_equipment_components[slot])
|
||||
if rel_path.is_empty():
|
||||
continue
|
||||
_blit_reference_rect(base, _load_blp_layer(rel_path), COMPONENT_RECTS[slot])
|
||||
|
||||
base.generate_mipmaps()
|
||||
return ImageTexture.create_from_image(base)
|
||||
|
||||
|
||||
@@ -184,10 +205,15 @@ func _apply_texture(tex: ImageTexture) -> void:
|
||||
|
||||
func _load_layer(filename: String) -> Image:
|
||||
var path := textures_dir.path_join(filename)
|
||||
if not FileAccess.file_exists(path):
|
||||
if not ResourceLoader.exists(path) and not FileAccess.file_exists(path):
|
||||
return null
|
||||
var img := Image.load_from_file(path)
|
||||
return img
|
||||
var texture := ResourceLoader.load(path, "Texture2D", ResourceLoader.CACHE_MODE_REUSE) as Texture2D
|
||||
if texture != null:
|
||||
var image := texture.get_image()
|
||||
return image.duplicate() if image != null else null
|
||||
if FileAccess.file_exists(path):
|
||||
return Image.load_from_file(path)
|
||||
return null
|
||||
|
||||
|
||||
func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
|
||||
@@ -196,6 +222,58 @@ func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
|
||||
dst.blend_rect(src, Rect2i(Vector2i.ZERO, src.get_size()), pos)
|
||||
|
||||
|
||||
func _blit_scaled(dst: Image, src: Image, reference_pos: Vector2i) -> void:
|
||||
if src == null:
|
||||
return
|
||||
var scale := float(dst.get_width()) / 512.0
|
||||
var target_pos := Vector2i(
|
||||
int(round(float(reference_pos.x) * scale)),
|
||||
int(round(float(reference_pos.y) * scale)))
|
||||
if is_equal_approx(scale, 1.0):
|
||||
_blit(dst, src, target_pos)
|
||||
return
|
||||
var target_size := Vector2i(
|
||||
maxi(1, int(round(float(src.get_width()) * scale))),
|
||||
maxi(1, int(round(float(src.get_height()) * scale))))
|
||||
var scaled := src.duplicate()
|
||||
scaled.resize(target_size.x, target_size.y, Image.INTERPOLATE_LANCZOS)
|
||||
_blit(dst, scaled, target_pos)
|
||||
|
||||
|
||||
func _blit_reference_rect(dst: Image, src: Image, reference_rect: Rect2i) -> void:
|
||||
if src == null:
|
||||
return
|
||||
var scale_x := float(dst.get_width()) / 512.0
|
||||
var scale_y := float(dst.get_height()) / 512.0
|
||||
var target_rect := Rect2i(
|
||||
Vector2i(
|
||||
int(round(float(reference_rect.position.x) * scale_x)),
|
||||
int(round(float(reference_rect.position.y) * scale_y))),
|
||||
Vector2i(
|
||||
maxi(1, int(round(float(reference_rect.size.x) * scale_x))),
|
||||
maxi(1, int(round(float(reference_rect.size.y) * scale_y)))))
|
||||
var scaled := src.duplicate()
|
||||
scaled.resize(target_rect.size.x, target_rect.size.y, Image.INTERPOLATE_LANCZOS)
|
||||
_blit(dst, scaled, target_rect.position)
|
||||
|
||||
|
||||
func _load_blp_layer(rel_path: String) -> Image:
|
||||
if rel_path.is_empty():
|
||||
return null
|
||||
if _blp_image_cache.has(rel_path):
|
||||
return (_blp_image_cache[rel_path] as Image).duplicate() if _blp_image_cache[rel_path] != null else null
|
||||
if not ClassDB.class_exists("BLPLoader"):
|
||||
_blp_image_cache[rel_path] = null
|
||||
return null
|
||||
var abs_path := ProjectSettings.globalize_path(extracted_dir.trim_suffix("/").path_join(rel_path.replace("\\", "/")))
|
||||
if not FileAccess.file_exists(abs_path):
|
||||
_blp_image_cache[rel_path] = null
|
||||
return null
|
||||
var img: Image = ClassDB.instantiate("BLPLoader").call("load_image", abs_path)
|
||||
_blp_image_cache[rel_path] = img
|
||||
return img.duplicate() if img != null else null
|
||||
|
||||
|
||||
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
|
||||
var result: Array[MeshInstance3D] = []
|
||||
_collect_meshes(root, result)
|
||||
@@ -217,6 +295,11 @@ func refresh() -> void:
|
||||
_recomposite()
|
||||
|
||||
|
||||
func set_equipment_components(components: PackedStringArray) -> void:
|
||||
_equipment_components = components
|
||||
_recomposite()
|
||||
|
||||
|
||||
## Returns the number of skin surfaces found (useful for debugging).
|
||||
func get_skin_surface_count() -> int:
|
||||
return _skin_surfaces.size()
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
extends RefCounted
|
||||
|
||||
const SLOT_UPPER_ARM := 0
|
||||
const SLOT_LOWER_ARM := 1
|
||||
const SLOT_HAND := 2
|
||||
const SLOT_UPPER_TORSO := 3
|
||||
const SLOT_LOWER_TORSO := 4
|
||||
const SLOT_UPPER_LEG := 5
|
||||
const SLOT_LOWER_LEG := 6
|
||||
const SLOT_FOOT := 7
|
||||
const SLOT_COUNT := 8
|
||||
|
||||
const COMPONENT_FOLDERS := [
|
||||
"ArmUpperTexture",
|
||||
"ArmLowerTexture",
|
||||
"HandTexture",
|
||||
"TorsoUpperTexture",
|
||||
"TorsoLowerTexture",
|
||||
"LegUpperTexture",
|
||||
"LegLowerTexture",
|
||||
"FootTexture",
|
||||
]
|
||||
|
||||
const RACE_IDS := {
|
||||
"human": 1,
|
||||
"orc": 2,
|
||||
"dwarf": 3,
|
||||
"nightelf": 4,
|
||||
"scourge": 5,
|
||||
"undead": 5,
|
||||
"tauren": 6,
|
||||
"gnome": 7,
|
||||
"troll": 8,
|
||||
"bloodelf": 10,
|
||||
"draenei": 11,
|
||||
}
|
||||
|
||||
var extracted_dir := "res://data/extracted"
|
||||
var _char_start := {}
|
||||
var _item_display := {}
|
||||
var _item_display_by_id := {}
|
||||
var _texture_cache := {}
|
||||
|
||||
|
||||
func load_dbcs(p_extracted_dir: String) -> bool:
|
||||
extracted_dir = p_extracted_dir.trim_suffix("/")
|
||||
var base := extracted_dir.path_join("DBFilesClient")
|
||||
_char_start = _load_wdbc(base.path_join("CharStartOutfit.dbc"))
|
||||
_item_display = _load_wdbc(base.path_join("ItemDisplayInfo.dbc"))
|
||||
_index_item_display()
|
||||
return not _char_start.is_empty() and not _item_display.is_empty()
|
||||
|
||||
|
||||
func resolve_start_outfit(race_name: String, gender_name: String, class_id: int) -> Dictionary:
|
||||
if _char_start.is_empty() or _item_display.is_empty():
|
||||
return {}
|
||||
var race_id := race_id_for_name(race_name)
|
||||
var gender_id := gender_id_for_name(gender_name)
|
||||
if race_id <= 0 or gender_id < 0:
|
||||
return {}
|
||||
|
||||
var wanted_key := race_id | ((class_id & 0xff) << 8) | ((gender_id & 0xff) << 16)
|
||||
for i in int(_char_start["records"]):
|
||||
if int(_dbc_u32(_char_start, i, 1)) != wanted_key:
|
||||
continue
|
||||
return _resolve_start_record(i, gender_id)
|
||||
for i in int(_char_start["records"]):
|
||||
var race_class_gender := int(_dbc_u32(_char_start, i, 1))
|
||||
if (race_class_gender & 0xff) == race_id and ((race_class_gender >> 16) & 0xff) == gender_id:
|
||||
return _resolve_start_record(i, gender_id)
|
||||
return {}
|
||||
|
||||
|
||||
func _resolve_start_record(record: int, gender_id: int) -> Dictionary:
|
||||
var display_start := _char_start_display_field()
|
||||
var display_count := mini(24, int(_char_start["fields"]) - display_start)
|
||||
var display_ids := PackedInt32Array()
|
||||
for n in display_count:
|
||||
var display_id := int(_dbc_u32(_char_start, record, display_start + n))
|
||||
if display_id > 0 and display_id != 0xffffffff:
|
||||
display_ids.append(display_id)
|
||||
var outfit := resolve_display_ids(display_ids, gender_id)
|
||||
outfit["source_record"] = record
|
||||
outfit["source_class"] = (int(_dbc_u32(_char_start, record, 1)) >> 8) & 0xff
|
||||
return outfit
|
||||
|
||||
|
||||
func resolve_display_ids(display_ids: PackedInt32Array, gender_id: int) -> Dictionary:
|
||||
var textures := PackedStringArray()
|
||||
textures.resize(SLOT_COUNT)
|
||||
var geosets := PackedInt32Array()
|
||||
geosets.resize(3)
|
||||
var flags := 0
|
||||
var used_display_ids := PackedInt32Array()
|
||||
|
||||
var texture_base := _item_display_texture_base()
|
||||
var geoset_base := _item_display_geoset_base()
|
||||
var flags_field := _item_display_flags_field()
|
||||
for display_id in display_ids:
|
||||
var record := int(_item_display_by_id.get(display_id, -1))
|
||||
if record < 0:
|
||||
continue
|
||||
used_display_ids.append(display_id)
|
||||
for i in 3:
|
||||
var geoset_group := int(_dbc_u32(_item_display, record, geoset_base + i))
|
||||
if geoset_group != 0:
|
||||
geosets[i] = geoset_group
|
||||
flags |= int(_dbc_u32(_item_display, record, flags_field))
|
||||
for slot in SLOT_COUNT:
|
||||
var stem := _dbc_string(_item_display, record, texture_base + slot)
|
||||
if stem.is_empty():
|
||||
continue
|
||||
var rel_path := component_texture_path(stem, slot, gender_id)
|
||||
if not rel_path.is_empty():
|
||||
textures[slot] = rel_path
|
||||
|
||||
return {
|
||||
"display_ids": used_display_ids,
|
||||
"textures": textures,
|
||||
"geosets": geosets,
|
||||
"flags": flags,
|
||||
}
|
||||
|
||||
|
||||
func component_texture_path(stem: String, slot: int, gender_id: int) -> String:
|
||||
if stem.is_empty() or slot < 0 or slot >= SLOT_COUNT:
|
||||
return ""
|
||||
var suffix := "F" if gender_id == 1 else "M"
|
||||
var folder := String(COMPONENT_FOLDERS[slot])
|
||||
var candidates := [
|
||||
"ITEM/TEXTURECOMPONENTS/%s/%s_%s.blp" % [folder, stem, suffix],
|
||||
"Item/TextureComponents/%s/%s_%s.blp" % [folder, stem, suffix],
|
||||
"ITEM/TEXTURECOMPONENTS/%s/%s_U.blp" % [folder, stem],
|
||||
"Item/TextureComponents/%s/%s_U.blp" % [folder, stem],
|
||||
]
|
||||
for rel_path in candidates:
|
||||
if _texture_exists(rel_path):
|
||||
return rel_path
|
||||
return candidates[0]
|
||||
|
||||
|
||||
func race_id_for_name(race_name: String) -> int:
|
||||
var normalized := race_name.replace("_", "").replace(" ", "").to_lower()
|
||||
return int(RACE_IDS.get(normalized, 0))
|
||||
|
||||
|
||||
func gender_id_for_name(gender_name: String) -> int:
|
||||
var normalized := gender_name.to_lower()
|
||||
if normalized == "male" or normalized == "m":
|
||||
return 0
|
||||
if normalized == "female" or normalized == "f":
|
||||
return 1
|
||||
return -1
|
||||
|
||||
|
||||
func infer_race_gender_from_model_path(model_path: String) -> Dictionary:
|
||||
var parts := model_path.replace("\\", "/").split("/", false)
|
||||
for i in range(parts.size() - 2):
|
||||
if String(parts[i]).to_lower() == "characters":
|
||||
return {"race": String(parts[i + 1]), "gender": String(parts[i + 2])}
|
||||
return {}
|
||||
|
||||
|
||||
func _index_item_display() -> void:
|
||||
_item_display_by_id.clear()
|
||||
if _item_display.is_empty():
|
||||
return
|
||||
for i in int(_item_display["records"]):
|
||||
var display_id := int(_dbc_u32(_item_display, i, 0))
|
||||
if display_id > 0:
|
||||
_item_display_by_id[display_id] = i
|
||||
|
||||
|
||||
func _char_start_display_field() -> int:
|
||||
if int(_char_start.get("fields", 0)) >= 77:
|
||||
return 26
|
||||
return 14
|
||||
|
||||
|
||||
func _item_display_texture_base() -> int:
|
||||
return 15 if int(_item_display.get("fields", 0)) >= 25 else 14
|
||||
|
||||
|
||||
func _item_display_geoset_base() -> int:
|
||||
return 7 if int(_item_display.get("fields", 0)) >= 25 else 6
|
||||
|
||||
|
||||
func _item_display_flags_field() -> int:
|
||||
return 10 if int(_item_display.get("fields", 0)) >= 25 else 9
|
||||
|
||||
|
||||
func _texture_exists(rel_path: String) -> bool:
|
||||
if _texture_cache.has(rel_path):
|
||||
return bool(_texture_cache[rel_path])
|
||||
var exists := FileAccess.file_exists(ProjectSettings.globalize_path(extracted_dir.path_join(rel_path)))
|
||||
_texture_cache[rel_path] = exists
|
||||
return exists
|
||||
|
||||
|
||||
func _load_wdbc(path: String) -> Dictionary:
|
||||
var abs_path := ProjectSettings.globalize_path(path)
|
||||
if not FileAccess.file_exists(abs_path):
|
||||
return {}
|
||||
var file := FileAccess.open(abs_path, FileAccess.READ)
|
||||
if not file:
|
||||
return {}
|
||||
var bytes := file.get_buffer(file.get_length())
|
||||
if bytes.size() < 20 or bytes[0] != 0x57 or bytes[1] != 0x44 or bytes[2] != 0x42 or bytes[3] != 0x43:
|
||||
return {}
|
||||
var records := int(bytes.decode_u32(4))
|
||||
var fields := int(bytes.decode_u32(8))
|
||||
var record_size := int(bytes.decode_u32(12))
|
||||
var string_size := int(bytes.decode_u32(16))
|
||||
var required := 20 + records * record_size + string_size
|
||||
if records < 0 or fields <= 0 or record_size <= 0 or required > bytes.size():
|
||||
return {}
|
||||
return {
|
||||
"bytes": bytes,
|
||||
"records": records,
|
||||
"fields": fields,
|
||||
"record_size": record_size,
|
||||
"records_offset": 20,
|
||||
"strings_offset": 20 + records * record_size,
|
||||
"string_size": string_size,
|
||||
}
|
||||
|
||||
|
||||
func _dbc_u32(dbc: Dictionary, record: int, field: int) -> int:
|
||||
if record < 0 or record >= int(dbc["records"]) or field < 0:
|
||||
return 0
|
||||
var record_size := int(dbc["record_size"])
|
||||
var field_offset := field * 4
|
||||
if field_offset + 4 > record_size:
|
||||
return 0
|
||||
var bytes: PackedByteArray = dbc["bytes"]
|
||||
return int(bytes.decode_u32(int(dbc["records_offset"]) + record * record_size + field_offset))
|
||||
|
||||
|
||||
func _dbc_string(dbc: Dictionary, record: int, field: int) -> String:
|
||||
var offset := _dbc_u32(dbc, record, field)
|
||||
var string_size := int(dbc.get("string_size", 0))
|
||||
if offset <= 0 or offset >= string_size:
|
||||
return ""
|
||||
var bytes: PackedByteArray = dbc["bytes"]
|
||||
var pos := int(dbc["strings_offset"]) + offset
|
||||
var end := pos
|
||||
var max_end := int(dbc["strings_offset"]) + string_size
|
||||
while end < max_end and bytes[end] != 0:
|
||||
end += 1
|
||||
if end <= pos:
|
||||
return ""
|
||||
return bytes.slice(pos, end).get_string_from_utf8()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3e8tcysnsliv
|
||||
Reference in New Issue
Block a user