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 CHUNK_SIZE := COORDINATE_MAPPER_SCRIPT.ADT_CHUNK_SIZE_YARDS const UNIT_SIZE := CHUNK_SIZE / 8.0 @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 mouse_sensitivity: float = 0.003 @export var camera_pitch_min: float = deg_to_rad(-65.0) @export var camera_pitch_max: float = deg_to_rad(35.0) @export var camera_height: float = 1.7 @export var camera_distance: float = 8.0 @export var camera_min_distance: float = 2.0 @export var camera_max_distance: float = 18.0 @export var camera_zoom_step: float = 1.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 camera_path: NodePath = NodePath("CameraPivot/Camera3D") @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_pivot: Node3D var _camera: Camera3D var _visual: Node3D var _character_root: Node3D var _animation_player: AnimationPlayer var _active_animation := "" var _captured := false var _yaw := 0.0 var _pitch := deg_to_rad(-18.0) var _horizontal_velocity := Vector3.ZERO var _flight_enabled := false var _adt_cache: Dictionary = {} var _player_input_source: PlayerInputSource func _ready() -> void: _player_input_source = PLAYER_INPUT_SOURCE_SCRIPT.new() _camera_pivot = get_node_or_null(camera_pivot_path) as Node3D _camera = get_node_or_null(camera_path) as Camera3D _visual = get_node_or_null(visual_path) as Node3D if _camera: _camera.current = true _camera.far = 50000.0 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 ground := _sample_ground_height(global_position) if is_finite(ground): global_position.y = ground + ground_offset _yaw = rotation.y _apply_camera_transform() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE): _captured = true Input.mouse_mode = Input.MOUSE_MODE_CAPTURED elif event.is_action_released(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ROTATE): _captured = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_IN): camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance) _apply_camera_transform() elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.CAMERA_ZOOM_OUT): camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance) _apply_camera_transform() elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.RELEASE_CURSOR): _captured = false Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE elif event.is_action_pressed(PLAYER_INPUT_ACTIONS_SCRIPT.DEBUG_TOGGLE_FLIGHT) and not event.is_echo(): _flight_enabled = not _flight_enabled _horizontal_velocity = Vector3.ZERO if _captured and event is InputEventMouseMotion: _yaw -= event.relative.x * mouse_sensitivity _pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max) rotation.y = _yaw _apply_camera_transform() func _physics_process(delta: float) -> void: var move_intent := _player_input_source.sample_move_intent() var target_velocity := Vector3.ZERO if move_intent.has_translation(): target_velocity = _movement_vector(move_intent) if _flight_enabled: target_velocity.y += move_intent.vertical_axis * flight_vertical_speed * _debug_speed_multiplier(move_intent) _horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta) global_position += _horizontal_velocity * delta if not _flight_enabled: var ground := _sample_ground_height(global_position) if is_finite(ground): var target_y := ground + ground_offset global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z) if _visual and horizontal_motion.length_squared() > 0.01: _visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z) _update_character_animation(horizontal_motion.length_squared() > 0.04) _apply_camera_transform() func _movement_vector(move_intent: MoveIntent) -> Vector3: var forward := -global_basis.z var right := global_basis.x if _flight_enabled and _camera_pivot: forward = -_camera_pivot.global_basis.z right = _camera_pivot.global_basis.x else: forward.y = 0.0 right.y = 0.0 forward = forward.normalized() right = right.normalized() var speed_z := run_speed if move_intent.forward_axis > 0.0 else backward_speed var speed_x := strafe_speed return ( forward * move_intent.forward_axis * speed_z + right * move_intent.strafe_axis * speed_x ) * _debug_speed_multiplier(move_intent) func _debug_speed_multiplier(move_intent: MoveIntent) -> float: return sprint_multiplier if move_intent.is_sprint_requested else 1.0 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 _apply_camera_transform() -> void: if _camera_pivot: _camera_pivot.position = Vector3(0.0, camera_height, 0.0) _camera_pivot.rotation.x = _pitch if _camera: _camera.position = Vector3(0.0, 0.0, camera_distance) _camera.rotation = Vector3.ZERO func _sample_ground_height(world_pos: Vector3) -> float: var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(world_pos.x, world_pos.y, world_pos.z) var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position) var tile_local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position) var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, tile_local_position) var data := _load_adt(tile_coordinate.tile_x, tile_coordinate.tile_y) if data.is_empty(): return NAN var tile_origin := _get_tile_origin(data) var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15) var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15) var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin) if chunk.is_empty(): return NAN var origin: Vector3 = chunk.get("origin", Vector3.ZERO) var chunk_local := world_pos - origin var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0) var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0) var x0 := clampi(int(floor(gx)), 0, 8) var z0 := clampi(int(floor(gz)), 0, 8) var x1 := mini(x0 + 1, 8) var z1 := mini(z0 + 1, 8) var fx := gx - float(x0) var fz := gz - float(z0) var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array()) if heights.size() < 145: return NAN var h00 := heights[_outer(z0, x0)] var h10 := heights[_outer(z0, x1)] var h01 := heights[_outer(z1, x0)] var h11 := heights[_outer(z1, x1)] var hx0 := lerpf(h00, h10, fx) var hx1 := lerpf(h01, h11, fx) return origin.y + lerpf(hx0, hx1, fz) func _load_adt(tx: int, ty: int) -> Dictionary: var key := "%d_%d" % [tx, ty] if _adt_cache.has(key): return _adt_cache[key] if not ClassDB.class_exists("ADTLoader"): _adt_cache[key] = {} return {} var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty]) var abs_path := ProjectSettings.globalize_path(res_path) if not FileAccess.file_exists(abs_path): _adt_cache[key] = {} return {} var loader = ClassDB.instantiate("ADTLoader") var data: Dictionary = loader.call("load_adt", abs_path) if loader else {} _adt_cache[key] = data return data func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary: for chunk in data.get("chunks", []) as Array: if chunk.is_empty(): continue var origin: Vector3 = chunk.get("origin", Vector3.ZERO) var local := origin - tile_origin var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15) var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15) if cx == chunk_x and cy == chunk_y: return chunk return {} func _get_tile_origin(data: Dictionary) -> Vector3: var found := false var min_x := 0.0 var min_z := 0.0 for chunk in data.get("chunks", []) as Array: if chunk.is_empty(): continue var origin: Vector3 = chunk.get("origin", Vector3.ZERO) if not found: min_x = origin.x min_z = origin.z found = true else: min_x = minf(min_x, origin.x) min_z = minf(min_z, origin.z) return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO func _outer(row: int, col: int) -> int: return row * 17 + col