class_name CharacterAppearancePresenter extends Node3D ## Composes one sandbox character model, texture compositor and starter outfit. ## The presenter owns only nodes below the existing Visual scene boundary. 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") signal character_appearance_ready(character_root: Node3D) ## Packed character scene used by the current render sandbox. @export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb" ## Character class used when resolving the current starter outfit. @export var character_class_id: int = 1 ## Uniform scale applied to the composed character root. @export var character_scale: float = 1.0 ## Model-space yaw correction retained from the current sandbox. @export var character_yaw_offset_degrees: float = 90.0 ## Root containing extracted DBC and item texture inputs. @export var extracted_data_directory: String = "res://data/extracted" var character_root: Node3D: get: return _character_root var _character_root: Node3D ## Loads and composes the configured character model below this Visual node. ## A non-empty directory argument keeps the player's terrain/content root aligned. ## Returns false immediately when no model is configured or the resource is invalid. func load_character_appearance(content_root_override: String = "") -> bool: if not content_root_override.is_empty(): extracted_data_directory = content_root_override _clear_character_root() if character_model_path.is_empty(): return false var character_scene := load(character_model_path) as PackedScene if character_scene == null: push_warning("CharacterAppearancePresenter: cannot load character model: %s" % character_model_path) return false if is_class("MeshInstance3D"): set("mesh", null) position = Vector3.ZERO _character_root = Node3D.new() _character_root.name = "CharacterModel" _character_root.set_script(GEOSET_CONTROLLER_SCRIPT) _character_root.scale = Vector3.ONE * maxf(character_scale, 0.001) _character_root.rotation_degrees.y = character_yaw_offset_degrees var character_instance := character_scene.instantiate() character_instance.name = character_model_path.get_file().get_basename() _character_root.add_child(character_instance) var texture_compositor := Node.new() texture_compositor.name = "CharacterTextureCompositor" texture_compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT) texture_compositor.set("textures_dir", _character_textures_directory(character_model_path)) texture_compositor.set("extracted_dir", extracted_data_directory) _character_root.add_child(texture_compositor) add_child(_character_root) _complete_character_composition.call_deferred(_character_root, texture_compositor) return true func _complete_character_composition(expected_character_root: Node3D, texture_compositor: Node) -> void: if expected_character_root != _character_root or not is_instance_valid(expected_character_root): return _fit_character_to_ground(expected_character_root) _apply_character_starter_outfit(expected_character_root, texture_compositor) character_appearance_ready.emit(expected_character_root) func _clear_character_root() -> void: if _character_root == null or not is_instance_valid(_character_root): _character_root = null return remove_child(_character_root) _character_root.queue_free() _character_root = null func _fit_character_to_ground(root: Node3D) -> void: var character_bounds := _calculate_global_bounds(root) if character_bounds.size == Vector3.ZERO: return var local_minimum_y := character_bounds.position.y - root.global_position.y root.position.y -= local_minimum_y func _apply_character_starter_outfit(root: Node3D, texture_compositor: Node) -> void: if texture_compositor == null: return var outfit_resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new() if not outfit_resolver.call("load_dbcs", extracted_data_directory): return var inferred_identity: Dictionary = outfit_resolver.call( "infer_race_gender_from_model_path", character_model_path ) var race_name := String(inferred_identity.get("race", "Human")) var gender_name := String(inferred_identity.get("gender", "Male")) var starter_outfit: Dictionary = outfit_resolver.call( "resolve_start_outfit", race_name, gender_name, character_class_id ) if starter_outfit.is_empty(): return if texture_compositor.has_method("set_equipment_components"): texture_compositor.call( "set_equipment_components", starter_outfit.get("textures", PackedStringArray()) ) if root.has_method("apply_outfit_defaults"): root.call("apply_outfit_defaults", starter_outfit) func _calculate_global_bounds(root: Node) -> AABB: var combined_bounds := AABB() var has_bounds := false for mesh_instance in _mesh_instances_below(root): if mesh_instance.mesh == null: continue var global_bounds := mesh_instance.global_transform * mesh_instance.mesh.get_aabb() combined_bounds = global_bounds if not has_bounds else combined_bounds.merge(global_bounds) has_bounds = true return combined_bounds if has_bounds else AABB() func _mesh_instances_below(root: Node) -> Array[MeshInstance3D]: var mesh_instances: Array[MeshInstance3D] = [] _collect_mesh_instances(root, mesh_instances) return mesh_instances func _collect_mesh_instances(node: Node, mesh_instances: Array[MeshInstance3D]) -> void: if node is MeshInstance3D: mesh_instances.append(node) for child in node.get_children(): _collect_mesh_instances(child, mesh_instances) func _character_textures_directory(model_path: String) -> String: return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")