6bd2e84048
Work-Package: M02-GMP-INPUT-001 Agent: sindo-main-codex Tests: verify_player_input; renderer baseline dry-run; coordinate, streaming, documentation and coordination gates Fidelity: preserves current sandbox bindings and movement/camera behavior; exact build-12340 semantics remain unverified
414 lines
15 KiB
GDScript
414 lines
15 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 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
|