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:
@@ -1,8 +1,5 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
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 COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
|
||||
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
||||
const ADT_TILE_COORDINATE_SCRIPT := preload("res://src/domain/coordinates/adt_tile_coordinate.gd")
|
||||
@@ -29,17 +26,11 @@ const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrai
|
||||
|
||||
@export var camera_pivot_path: NodePath = NodePath("CameraPivot")
|
||||
@export var visual_path: NodePath = NodePath("Visual")
|
||||
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
|
||||
@export var character_class_id: int = 1
|
||||
@export var character_scale: float = 1.0
|
||||
@export var character_yaw_offset_degrees: float = 90.0
|
||||
@export var animation_blend_time: float = 0.15
|
||||
@export var animation_presenter_path: NodePath = NodePath("Visual/AnimationPresenter")
|
||||
|
||||
var _camera_rig: ThirdPersonCameraRig
|
||||
var _visual: Node3D
|
||||
var _character_root: Node3D
|
||||
var _animation_player: AnimationPlayer
|
||||
var _active_animation := ""
|
||||
var _appearance_presenter: CharacterAppearancePresenter
|
||||
var _animation_presenter: CharacterAnimationPresenter
|
||||
var _player_input_source: PlayerInputSource
|
||||
var _local_movement_controller: LocalPlayerMovementController
|
||||
var _terrain_query: TerrainQuery
|
||||
@@ -67,18 +58,25 @@ func _ready() -> void:
|
||||
sprint_multiplier
|
||||
)
|
||||
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
|
||||
_visual = get_node_or_null(visual_path) as Node3D
|
||||
_appearance_presenter = get_node_or_null(visual_path) as CharacterAppearancePresenter
|
||||
_animation_presenter = get_node_or_null(animation_presenter_path) as CharacterAnimationPresenter
|
||||
if _camera_rig == null:
|
||||
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
|
||||
else:
|
||||
_camera_rig.initialize_for_character(self)
|
||||
if _appearance_presenter == null:
|
||||
push_error("ThirdPersonWowController: CharacterAppearancePresenter not found at %s" % visual_path)
|
||||
else:
|
||||
_appearance_presenter.character_appearance_ready.connect(_on_character_appearance_ready)
|
||||
_appearance_presenter.load_character_appearance(extracted_dir)
|
||||
if _animation_presenter == null:
|
||||
push_error("ThirdPersonWowController: CharacterAnimationPresenter not found at %s" % animation_presenter_path)
|
||||
if spawn_at_tile_center:
|
||||
var spawn_tile = ADT_TILE_COORDINATE_SCRIPT.new(spawn_tile_x, spawn_tile_y)
|
||||
var half_tile_size := COORDINATE_MAPPER_SCRIPT.ADT_TILE_SIZE_YARDS * 0.5
|
||||
var spawn_local = ADT_TILE_LOCAL_POSITION_SCRIPT.new(half_tile_size, global_position.y, half_tile_size)
|
||||
var spawn_position = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_godot(spawn_tile, spawn_local)
|
||||
global_position = Vector3(spawn_position.x_units, spawn_position.y_units, spawn_position.z_units)
|
||||
_load_character_visual()
|
||||
var spawn_ground_sample := _sample_ground_height(global_position)
|
||||
if spawn_ground_sample.is_available:
|
||||
global_position.y = spawn_ground_sample.height_units + ground_offset
|
||||
@@ -106,160 +104,16 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
var movement_velocity := _local_movement_controller.godot_world_velocity_units_per_second
|
||||
var horizontal_motion := Vector2(movement_velocity.x, movement_velocity.z)
|
||||
if _visual and horizontal_motion.length_squared() > 0.01:
|
||||
_visual.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
|
||||
if _appearance_presenter != null and horizontal_motion.length_squared() > 0.01:
|
||||
_appearance_presenter.global_rotation.y = atan2(-movement_velocity.x, -movement_velocity.z)
|
||||
|
||||
_update_character_animation(horizontal_motion.length_squared() > 0.04)
|
||||
|
||||
func _load_character_visual() -> void:
|
||||
if character_model_path.is_empty() or _visual == null:
|
||||
return
|
||||
if _visual is MeshInstance3D:
|
||||
(_visual as MeshInstance3D).mesh = null
|
||||
_visual.position = Vector3.ZERO
|
||||
|
||||
if _character_root and is_instance_valid(_character_root):
|
||||
_character_root.queue_free()
|
||||
_character_root = null
|
||||
_animation_player = null
|
||||
_active_animation = ""
|
||||
|
||||
var scene: PackedScene = load(character_model_path)
|
||||
if scene == null:
|
||||
push_warning("ThirdPersonWowController: cannot load character model: %s" % character_model_path)
|
||||
return
|
||||
|
||||
_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 instance := scene.instantiate()
|
||||
instance.name = character_model_path.get_file().get_basename()
|
||||
_character_root.add_child(instance)
|
||||
|
||||
var compositor := Node.new()
|
||||
compositor.name = "CharacterTextureCompositor"
|
||||
compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
|
||||
compositor.set("textures_dir", _character_textures_dir(character_model_path))
|
||||
compositor.set("extracted_dir", extracted_dir)
|
||||
_character_root.add_child(compositor)
|
||||
|
||||
_visual.add_child(_character_root)
|
||||
await get_tree().process_frame
|
||||
_fit_character_to_ground()
|
||||
_apply_character_starter_outfit(compositor)
|
||||
_animation_player = _find_animation_player(_character_root)
|
||||
if _animation_player:
|
||||
_prepare_animation_player(_animation_player)
|
||||
_update_character_animation(false)
|
||||
if _animation_presenter != null:
|
||||
_animation_presenter.present_locomotion(horizontal_motion.length_squared() > 0.04)
|
||||
|
||||
|
||||
func _fit_character_to_ground() -> void:
|
||||
if _character_root == null:
|
||||
return
|
||||
var aabb := _calculate_aabb(_character_root)
|
||||
if aabb.size == Vector3.ZERO:
|
||||
return
|
||||
var local_min_y := aabb.position.y - _character_root.global_position.y
|
||||
_character_root.position.y -= local_min_y
|
||||
|
||||
|
||||
func _apply_character_starter_outfit(compositor: Node) -> void:
|
||||
if _character_root == null or compositor == null:
|
||||
return
|
||||
var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
|
||||
if not resolver.call("load_dbcs", extracted_dir):
|
||||
return
|
||||
var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path)
|
||||
var race := String(inferred.get("race", "Human"))
|
||||
var gender := String(inferred.get("gender", "Male"))
|
||||
var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id)
|
||||
if outfit.is_empty():
|
||||
return
|
||||
if compositor.has_method("set_equipment_components"):
|
||||
compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
|
||||
if _character_root.has_method("apply_outfit_defaults"):
|
||||
_character_root.call("apply_outfit_defaults", outfit)
|
||||
|
||||
|
||||
func _update_character_animation(is_moving: bool) -> void:
|
||||
if _animation_player == null:
|
||||
return
|
||||
var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"])
|
||||
if wanted.is_empty() or wanted == _active_animation:
|
||||
return
|
||||
_active_animation = wanted
|
||||
_animation_player.play(wanted, animation_blend_time)
|
||||
|
||||
|
||||
func _prepare_animation_player(player: AnimationPlayer) -> void:
|
||||
player.playback_default_blend_time = animation_blend_time
|
||||
for animation_name in player.get_animation_list():
|
||||
var lower := String(animation_name).to_lower()
|
||||
if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"):
|
||||
var animation := player.get_animation(animation_name)
|
||||
if animation:
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _choose_animation(candidates: Array[String]) -> String:
|
||||
if _animation_player == null:
|
||||
return ""
|
||||
for candidate in candidates:
|
||||
if _animation_player.has_animation(candidate):
|
||||
return candidate
|
||||
var list := _animation_player.get_animation_list()
|
||||
for candidate in candidates:
|
||||
var lower := candidate.to_lower()
|
||||
for animation_name in list:
|
||||
if String(animation_name).to_lower().contains(lower):
|
||||
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 found := _find_animation_player(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
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 _character_textures_dir(model_path: String) -> String:
|
||||
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
|
||||
func _on_character_appearance_ready(character_root: Node3D) -> void:
|
||||
if _animation_presenter != null:
|
||||
_animation_presenter.bind_character_root(character_root)
|
||||
|
||||
|
||||
func _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
||||
|
||||
Reference in New Issue
Block a user