+class_name Player
+extends CharacterBody3D
+
+
+# signals
+signal camera_lockon
+
+
+# player settings
+@export_group("Movement")
+@export var walk_speed := 5.0
+@export var jog_speed := 10.0
+@export var air_speed := 3.0
+@export var acceleration := 30.0
+@export var jump_speed := 6.0
+@export var rotation_speed := 10.0
+@export var fall_speed := 1.2
+@export var idle_timeout := 5.0
+@export var hard_landing_limit := 10.0
+
+
+@export_group("Camera")
+@export_range(1.0, 10.0) var camera_distance := 2.0
+@export_range(0.0, 100.0) var camera_lockon_radius := 40.0
+@export_enum("locked", "unlocked") var camera_mode: String
+@export_range(0.0, 50.0) var lockon_sensitivity := 0.2
+@export_range(0.0, 1.0) var mouse_sensitivity := 0.25
+@export_range(0.0, 10.0) var joystick_sensitivity_x := 4.0
+@export_range(0.0, 10.0) var joystick_sensitivity_y := 2.0
+
+
+@export_group("Expressions")
+@export_range(1.0, 100.0) var head_rotation_speed := 5.0
+
+
+@onready var _camera_pivot: Node3D = $cameraPivot
+@onready var _camera_spring: SpringArm3D = $cameraPivot/SpringArm3D
+@onready var _camera: Camera3D = $cameraPivot/SpringArm3D/Camera3D
+@onready var _skin: WilliamSkin = %WilliamSkin
+@onready var _debug: CanvasLayer = %DebugOverlay
+
+
+# class variables
+var _player_speed := walk_speed
+var _camera_input_direction := Vector2.ZERO
+var _last_movement_direction := rotation
+var _idle_time := 0.0
+var _camera_lockon_node: Node3D = null
+var _lockon_direction: Vector3 = Vector3.ZERO
+var _lockon_indicator_scene = load("res://ux/lockon_indicator.tscn")
+var _lockon_instance: Node3D = _lockon_indicator_scene.instantiate()
+
+var _head_track_arr: Array[Node3D] = []
+
+enum {LOCKON_LEFT, LOCKON_CENTER, LOCKON_RIGHT}
+var _lockon_shift := LOCKON_CENTER
+
+enum {CAMERA_MOUSE_INPUT, CAMERA_JOYSTICK_INPUT}
+var _camera_input_method := CAMERA_MOUSE_INPUT
+
+enum _states {FREE, POSING, TALKING}
+var _player_state := _states.FREE
+
+
+# inherited functions
+func _ready() -> void:
+ _camera_spring.spring_length = camera_distance
+
+ # debug
+ _debug.draw.add_vector(self, "velocity", 1, 3, Color(0,1,0,1))
+ _debug.draw.add_vector(self, "_lockon_direction", 1, 2, Color(1,0,0,1))
+ _debug.draw.add_vector(_camera_pivot, "rotation", 1, 3, Color(1,0,1,1))
+ _debug.draw.add_vector(self, "_last_movement_direction", 1, 3, Color(1,0,0,1))
+
+ _debug.stats.add_property(self, "velocity", "")
+ _debug.stats.add_property(self, "_idle_time", "round")
+ _debug.stats.add_property(self, "_lockon_shift", "round")
+ _debug.stats.add_property(self, "_head_track_to", "")
+ _debug.stats.add_property(self, "_head_track_arr", "")
+
+
+func _input(event: InputEvent) -> void:
+ if event.is_action_pressed("camera-lockon"):
+ camera_lockon.emit()
+ elif event.is_action_pressed("player-run"):
+ _player_speed = jog_speed
+ elif event.is_action_released("player-run"):
+ _player_speed = walk_speed
+ elif event.is_action_pressed("player-pose"):
+ _player_state = _states.POSING
+ elif event.is_action_released("player-pose"):
+ _player_state = _states.FREE
+ elif event.is_action_pressed("player-action"):
+ # TODO: contextual action
+ if _player_state == _states.FREE:
+ _player_state = _states.TALKING
+ velocity = Vector3.ZERO
+ elif _player_state == _states.TALKING:
+ _player_state = _states.FREE
+
+
+func _unhandled_input(event: InputEvent) -> void:
+ # If user clicks on the window, capture the mouse and direct the camera with it
+ if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
+ return
+
+ #_camera_input_direction *= mouse_sensitivity
+ if event is InputEventMouseMotion:
+ _camera_input_method = CAMERA_MOUSE_INPUT
+ _camera_input_direction = event.screen_relative * mouse_sensitivity
+ elif event is InputEventJoypadMotion:
+ _camera_input_method = CAMERA_JOYSTICK_INPUT
+ _camera_input_direction = Input.get_vector("camera-left", "camera-right", "camera-up", "camera-down")
+ if _camera_input_direction == Vector2.ZERO:
+ _lockon_shift = LOCKON_CENTER
+ _camera_input_direction *= Vector2(joystick_sensitivity_x, -joystick_sensitivity_y)
+
+
+# class functions
+func _physics_process(delta: float) -> void:
+ _process_player(delta)
+ _process_camera(delta)
+
+
+func is_camera_locked() -> bool:
+ return camera_mode == "locked"
+
+
+func set_camera_lockon(arr: Array[Node]) -> void:
+ if _camera_lockon_node:
+ _camera_lockon_node.remove_child(_lockon_instance)
+
+ if not is_camera_locked() or _lockon_shift != LOCKON_CENTER:
+ # pick closest lockon point to center of camera view
+ # must be within lockon radius
+ var shortest_target_angle = _camera.fov / 360 * 2 * PI
+ for n: Node3D in arr:
+ if _camera_lockon_node == n:
+ continue
+
+ var target_dir := _camera_pivot.global_position - n.global_position
+ if target_dir.length() < camera_lockon_radius:
+ var target_angle = shortest_target_angle
+ if _lockon_shift == LOCKON_CENTER:
+ var camera_dir = _camera.global_position - _camera_pivot.global_position
+ target_angle = target_dir.angle_to(camera_dir)
+ else:
+ # check to make sure if lockon switch is a node to the left if player pressed left,
+ # else lockon switch to something to the right
+ target_angle = target_dir.signed_angle_to(_lockon_direction, Vector3.UP)
+ if _lockon_shift == LOCKON_RIGHT and target_angle <= 0:
+ continue
+ if _lockon_shift == LOCKON_LEFT and target_angle >= 0:
+ continue
+
+ target_angle = abs(target_angle)
+ if target_angle < shortest_target_angle:
+ shortest_target_angle = target_angle
+ _camera_lockon_node = n
+
+ if _camera_lockon_node:
+ _camera_lockon_node.add_child(_lockon_instance)
+ camera_mode = "locked"
+ else:
+ camera_mode = "unlocked"
+ _camera_lockon_node = null
+ _lockon_direction = Vector3.ZERO
+
+ # TODO: move all skin stuff to its own reset function
+ _skin.move_forward()
+ _skin.set_hips_direction(0)
+
+ # always reset shift to center!
+ _lockon_shift = LOCKON_CENTER
+
+
+func _process_camera(delta: float) -> void:
+ if not is_camera_locked():
+ # vertical camera rotation
+ _camera_pivot.rotation.x += _camera_input_direction.y * delta
+ _camera_pivot.rotation.x = clamp(_camera_pivot.rotation.x, -PI / 6, PI / 3)
+
+ # horizontal camera rotation
+ _camera_pivot.rotation.y -= _camera_input_direction.x * delta
+
+ # reset mouse movement vector if mouse input
+ if _camera_input_method == CAMERA_MOUSE_INPUT:
+ _camera_input_direction = Vector2.ZERO
+ else:
+ # camera should lerp to a set height
+ _camera_pivot.rotation.x = lerp(_camera_pivot.rotation.x, 0.0, delta)
+ _camera_pivot.rotation.z = lerp(_camera_pivot.rotation.z, 0.0, delta)
+
+ # keep camera pivot pointed towards locked object
+ if _camera_lockon_node:
+ # calculate target vector between node position and player
+ var lock_vector := _camera_pivot.global_position - _camera_lockon_node.global_position
+ _lockon_direction = lock_vector
+ lock_vector.y = 0
+ var target_angle = Vector3.BACK.signed_angle_to(lock_vector, Vector3.UP)
+ _camera_pivot.global_rotation.y = lerp_angle(_camera_pivot.global_rotation.y, target_angle, rotation_speed * delta)
+
+ # if player indicates right or left, emit lockon signal again to collect array and split it
+ if _camera_input_direction.x < -lockon_sensitivity and _lockon_shift != LOCKON_LEFT:
+ _lockon_shift = LOCKON_LEFT
+ camera_lockon.emit()
+ elif _camera_input_direction.x > lockon_sensitivity and _lockon_shift != LOCKON_RIGHT:
+ _lockon_shift = LOCKON_RIGHT
+ camera_lockon.emit()
+
+
+# Get the XZ input direction based on player's input relative to the camera
+func _get_player_move_direction() -> Vector3:
+ var input_dir := Input.get_vector("player-left", "player-right", "player-forward", "player-backward")
+ var forward := _camera.global_basis.z
+ var right := _camera.global_basis.x
+ var move_direction := (forward * input_dir.y + right * input_dir.x).normalized()
+ move_direction.y = 0
+ return move_direction
+
+
+func _process_player_floor(move_direction: Vector3, delta: float) -> void:
+ # if player is landing, then just return
+ if _skin.current_state() == "landing":
+ return
+ elif _skin.current_state() == "fall":
+ if velocity.length() > hard_landing_limit:
+ velocity = Vector3.ZERO
+ _skin.landing()
+ return
+ else:
+ _skin.move_forward()
+
+ # if we're not stuck, then it's okay to set the velocity
+ velocity = velocity.move_toward(move_direction * _player_speed, acceleration * delta)
+
+ # if player jumps, then we're done
+ if Input.is_action_just_pressed("player-jump"):
+ velocity.y = jump_speed
+ _idle_time = 0.0
+ _skin.jump()
+ return
+
+ # also, if we're moving, we're not idle
+ # last movement direction required for skin orientation
+ if move_direction.length() < 0.2:
+ if velocity == Vector3.ZERO:
+ _idle_time += delta
+ if _idle_time > idle_timeout:
+ _skin.idle()
+ return
+ else:
+ _last_movement_direction = move_direction
+ _idle_time = 0.0
+
+ # now handle skin rotation and animation
+ var movement_speed := Vector3(velocity.x, 0, velocity.z).length()
+ _skin.movement_speed(movement_speed)
+
+ # if camera is unlocked, rotate whole skin to face movement direction
+ # else, rotate to face camera pivot global Y direction
+ var skin_target_angle := Vector3.BACK.signed_angle_to(_last_movement_direction, Vector3.UP)
+
+ # lean into momentum just a little bit
+ _skin.rotation.z = lerp_angle(
+ _skin.rotation.z,
+ clamp(_last_movement_direction.signed_angle_to(velocity, Vector3.UP) * movement_speed * 0.08, -PI/4, PI/ 4),
+ rotation_speed * delta * 0.25
+ )
+
+ if not is_camera_locked():
+ _skin.move_forward()
+
+ _skin.global_rotation.y = lerp_angle(
+ _skin.global_rotation.y,
+ skin_target_angle,
+ rotation_speed * delta
+ )
+ else: # camera is locked
+ var movement_angle := _skin.global_rotation.y - skin_target_angle
+ if abs(movement_angle) < (PI / 2):
+ _skin.move_forward()
+ else:
+ _skin.move_backward()
+
+ _skin.global_rotation.y = lerp_angle(
+ _skin.global_rotation.y,
+ _camera_pivot.global_rotation.y - PI,
+ rotation_speed * delta)
+
+ # hips rotate towards the direction of movement
+ # else hips rotate toward skin global Y direction
+ # if moving backwards, then target_angle is inverted
+ if movement_speed > 0.1:
+ var target_hips_dir := lerp_angle(
+ _skin.global_rotation.y + _skin.get_hips_direction(),
+ skin_target_angle if abs(movement_angle) < (PI / 2) else (skin_target_angle - PI),
+ rotation_speed * delta
+ ) - _skin.global_rotation.y
+ _skin.set_hips_direction(target_hips_dir)
+ else:
+ _skin.set_hips_direction(lerp_angle(_skin.get_hips_direction(), 0, rotation_speed * delta))
+
+
+func _process_player_falling(move_direction: Vector3, delta: float) -> void:
+ velocity += get_gravity() * fall_speed * delta
+ velocity += move_direction * air_speed * delta
+ _skin.fall()
+
+
+func _process_player(delta: float) -> void:
+ var move_direction := _get_player_move_direction()
+
+ if is_on_floor():
+ if _player_state == _states.POSING:
+ _skin.pose()
+ elif _player_state == _states.TALKING:
+ _skin.talk()
+ else:
+ _process_player_floor(move_direction, delta)
+ else:
+ _process_player_falling(move_direction, delta)
+
+ move_and_slide()
+
+
+func _on_head_turn_area_entered(area: Area3D) -> void:
+ for node in area.get_parent().get_children().filter(func(c): return c.is_in_group("player_head_turn")):
+ var i = _head_track_arr.find(node)
+ if i < 0:
+ _head_track_arr.append(node)
+
+
+func _on_head_turn_area_exited(area: Area3D) -> void:
+ for node in area.get_parent().get_children().filter(func(c): return c.is_in_group("player_head_turn")):
+ var i = _head_track_arr.find(node)
+ if i >= 0:
+ _head_track_arr.remove_at(i)