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.15 @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_target: Node3D = null 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_arr", "") _debug.stats.add_property(self, "_head_track_target", "") 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") _camera_input_direction *= Vector2(joystick_sensitivity_x, -joystick_sensitivity_y) if _camera_input_direction == Vector2.ZERO: _lockon_shift = LOCKON_CENTER # 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() # head tracking # TODO: choose the closest head track thing to my position # tell skin to track it func _pick_head_track_target() -> void: if _head_track_arr.is_empty(): _head_track_target = null _skin.set_head_target(null) _skin.set_eyes_target(null) else: var target: Node3D = _head_track_arr.front() _head_track_target = target _skin.set_head_target(target) _skin.set_eyes_target(target) # if we find a head tracking obj, add it to the array unless it's already there func _on_head_turn_area_entered(area: Area3D) -> void: for node in area.get_children().filter(func(c: Node3D): return c.is_in_group("player-headTrack")): var i = _head_track_arr.find(node) if i < 0: _head_track_arr.append(node) _pick_head_track_target() func _on_head_turn_area_exited(area: Area3D) -> void: for node in area.get_children().filter(func(c: Node3D): return c.is_in_group("player-headTrack")): var i = _head_track_arr.find(node) if i >= 0: _head_track_arr.remove_at(i) _pick_head_track_target()