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 ) )