8c1cf9be36
Work-Package: M02-RND-CAMERA-001
273 lines
11 KiB
GDScript
273 lines
11 KiB
GDScript
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")
|
|
const ADT_TILE_LOCAL_POSITION_SCRIPT := preload("res://src/domain/coordinates/adt_tile_local_position.gd")
|
|
const PLAYER_INPUT_SOURCE_SCRIPT := preload("res://src/gameplay/input/player_input_source.gd")
|
|
const PLAYER_INPUT_ACTIONS_SCRIPT := preload("res://src/gameplay/input/player_input_actions.gd")
|
|
const LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT := preload("res://src/gameplay/movement/local_player_movement_controller.gd")
|
|
const ADT_TERRAIN_QUERY_SCRIPT := preload("res://src/gameplay/terrain/adt_terrain_query.gd")
|
|
|
|
@export var extracted_dir: String = "res://data/extracted"
|
|
@export var map_name: String = "Azeroth"
|
|
@export var spawn_tile_x: int = 42
|
|
@export var spawn_tile_y: int = 28
|
|
@export var spawn_at_tile_center: bool = true
|
|
|
|
@export var run_speed: float = 7.0
|
|
@export var backward_speed: float = 4.5
|
|
@export var strafe_speed: float = 4.5
|
|
@export var sprint_multiplier: float = 6.0
|
|
@export var flight_vertical_speed: float = 7.0
|
|
@export var acceleration: float = 28.0
|
|
@export var ground_offset: float = 0.05
|
|
@export var ground_snap_speed: float = 24.0
|
|
|
|
@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
|
|
|
|
var _camera_rig: ThirdPersonCameraRig
|
|
var _visual: Node3D
|
|
var _character_root: Node3D
|
|
var _animation_player: AnimationPlayer
|
|
var _active_animation := ""
|
|
var _player_input_source: PlayerInputSource
|
|
var _local_movement_controller: LocalPlayerMovementController
|
|
var _terrain_query: TerrainQuery
|
|
|
|
|
|
## Replaces the ground-height backend without changing player movement or presentation.
|
|
## Passing null is a composition error and leaves the current query unchanged.
|
|
func set_terrain_query(terrain_query: TerrainQuery) -> void:
|
|
if terrain_query == null:
|
|
push_error("ThirdPersonWowController: terrain query cannot be null")
|
|
return
|
|
_terrain_query = terrain_query
|
|
|
|
|
|
func _ready() -> void:
|
|
_player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new()
|
|
if _terrain_query == null:
|
|
_terrain_query = ADT_TERRAIN_QUERY_SCRIPT.new(extracted_dir, map_name)
|
|
_local_movement_controller = LOCAL_PLAYER_MOVEMENT_CONTROLLER_SCRIPT.new(
|
|
run_speed,
|
|
backward_speed,
|
|
strafe_speed,
|
|
flight_vertical_speed,
|
|
acceleration,
|
|
sprint_multiplier
|
|
)
|
|
_camera_rig = get_node_or_null(camera_pivot_path) as ThirdPersonCameraRig
|
|
_visual = get_node_or_null(visual_path) as Node3D
|
|
if _camera_rig == null:
|
|
push_error("ThirdPersonWowController: ThirdPersonCameraRig not found at %s" % camera_pivot_path)
|
|
else:
|
|
_camera_rig.initialize_for_character(self)
|
|
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
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if _camera_rig != null:
|
|
_camera_rig.handle_camera_input(event)
|
|
if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo():
|
|
_local_movement_controller.toggle_sandbox_flight()
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
var move_intent := _player_input_source.sample_move_intent()
|
|
var godot_world_movement_basis := global_basis
|
|
if _local_movement_controller.is_flight_enabled and _camera_rig != null:
|
|
godot_world_movement_basis = _camera_rig.godot_world_flight_movement_basis()
|
|
global_position += _local_movement_controller.advance(move_intent, godot_world_movement_basis, delta)
|
|
|
|
if not _local_movement_controller.is_flight_enabled:
|
|
var ground_sample := _sample_ground_height(global_position)
|
|
if ground_sample.is_available:
|
|
var target_y := ground_sample.height_units + ground_offset
|
|
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
|
|
|
|
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)
|
|
|
|
_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)
|
|
|
|
|
|
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 _sample_ground_height(godot_world_position: Vector3) -> TerrainGroundSample:
|
|
return _terrain_query.sample_ground_height(
|
|
GODOT_WORLD_POSITION_SCRIPT.new(
|
|
godot_world_position.x,
|
|
godot_world_position.y,
|
|
godot_world_position.z
|
|
)
|
|
)
|