rnd(M02): extract character presenters
Work-Package: M02-RND-CHARACTER-PRESENTATION-001 Agent: sindo-main-codex Tests: presentation/input/movement/terrain/camera regressions; coordinate/streaming/docs/coordination gates; renderer dry-run 7/7 Fidelity: preserves current sandbox model/outfit and Stand/Idle/Run/Walk behavior; build-12340 parity remains unverified
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
class_name CharacterAnimationPresenter
|
||||
extends Node
|
||||
|
||||
## Selects and plays the current sandbox idle or moving animation.
|
||||
## Animation import, movement simulation and model composition remain external.
|
||||
|
||||
const MOVING_ANIMATION_CANDIDATES: Array[String] = ["Run", "Walk"]
|
||||
const STATIONARY_ANIMATION_CANDIDATES: Array[String] = ["Stand", "Idle"]
|
||||
|
||||
## Cross-fade time retained from the current sandbox controller.
|
||||
@export var animation_blend_time_seconds: float = 0.15
|
||||
|
||||
var active_animation_name: String:
|
||||
get:
|
||||
return _active_animation_name
|
||||
|
||||
var has_animation_player: bool:
|
||||
get:
|
||||
return _animation_player != null and is_instance_valid(_animation_player)
|
||||
|
||||
var _animation_player: AnimationPlayer
|
||||
var _active_animation_name := ""
|
||||
|
||||
|
||||
## Finds the first AnimationPlayer below a newly composed character root.
|
||||
## Binding null or a root without animations clears the current presentation.
|
||||
func bind_character_root(character_root: Node) -> bool:
|
||||
_animation_player = (
|
||||
_find_animation_player(character_root)
|
||||
if character_root != null and is_instance_valid(character_root)
|
||||
else null
|
||||
)
|
||||
_active_animation_name = ""
|
||||
if _animation_player == null:
|
||||
return false
|
||||
_prepare_animation_player(_animation_player)
|
||||
present_locomotion(false)
|
||||
return true
|
||||
|
||||
|
||||
## Presents the current moving/stationary locomotion state.
|
||||
## Returns true only when a different animation starts playing.
|
||||
func present_locomotion(is_moving: bool) -> bool:
|
||||
if _animation_player == null or not is_instance_valid(_animation_player):
|
||||
_animation_player = null
|
||||
_active_animation_name = ""
|
||||
return false
|
||||
var candidates := MOVING_ANIMATION_CANDIDATES if is_moving else STATIONARY_ANIMATION_CANDIDATES
|
||||
var selected_animation := _choose_animation(candidates)
|
||||
if selected_animation.is_empty() or selected_animation == _active_animation_name:
|
||||
return false
|
||||
_active_animation_name = selected_animation
|
||||
_animation_player.play(selected_animation, animation_blend_time_seconds)
|
||||
return true
|
||||
|
||||
|
||||
func _prepare_animation_player(animation_player: AnimationPlayer) -> void:
|
||||
animation_player.playback_default_blend_time = animation_blend_time_seconds
|
||||
for animation_name in animation_player.get_animation_list():
|
||||
var lowercase_name := String(animation_name).to_lower()
|
||||
if (
|
||||
lowercase_name == "stand"
|
||||
or lowercase_name == "idle"
|
||||
or lowercase_name == "run"
|
||||
or lowercase_name == "walk"
|
||||
or lowercase_name.contains("stand")
|
||||
or lowercase_name.contains("run")
|
||||
or lowercase_name.contains("walk")
|
||||
):
|
||||
var animation := animation_player.get_animation(animation_name)
|
||||
if animation != null:
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _choose_animation(candidates: Array[String]) -> String:
|
||||
for candidate in candidates:
|
||||
if _animation_player.has_animation(candidate):
|
||||
return candidate
|
||||
var animation_names := _animation_player.get_animation_list()
|
||||
for candidate in candidates:
|
||||
var lowercase_candidate := candidate.to_lower()
|
||||
for animation_name in animation_names:
|
||||
if String(animation_name).to_lower().contains(lowercase_candidate):
|
||||
return String(animation_name)
|
||||
return ""
|
||||
|
||||
|
||||
func _find_animation_player(root: Node) -> AnimationPlayer:
|
||||
if root is AnimationPlayer:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var animation_player := _find_animation_player(child)
|
||||
if animation_player != null:
|
||||
return animation_player
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2jnn0e3mmymc
|
||||
@@ -0,0 +1,150 @@
|
||||
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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhqbo1ftrt3a3
|
||||
Reference in New Issue
Block a user