2 extends CharacterBody3D
9 @export_group("Movement")
10 @export var walk_speed := 5.0
11 @export var jog_speed := 10.0
12 @export var air_speed := 3.0
13 @export var acceleration := 30.0
14 @export var jump_speed := 6.0
15 @export var rotation_speed := 10.0
16 @export var fall_speed := 1.2
17 @export var idle_timeout := 5.0
18 @export var hard_landing_limit := 10.0
21 @export_group("Camera")
22 @export_range(1.0, 10.0) var camera_distance := 2.0
23 @export_range(0.0, 100.0) var camera_lockon_radius := 40.0
24 @export_enum("locked", "unlocked") var camera_mode: String
25 @export_range(0.0, 50.0) var lockon_sensitivity := 0.2
26 @export_range(0.0, 1.0) var mouse_sensitivity := 0.15
27 @export_range(0.0, 10.0) var joystick_sensitivity_x := 4.0
28 @export_range(0.0, 10.0) var joystick_sensitivity_y := 2.0
31 @export_group("Expressions")
32 @export_range(1.0, 100.0) var head_rotation_speed := 5.0
35 @onready var _camera_pivot: Node3D = $cameraPivot
36 @onready var _camera_spring: SpringArm3D = $cameraPivot/SpringArm3D
37 @onready var _camera: Camera3D = $cameraPivot/SpringArm3D/Camera3D
38 @onready var _skin: WilliamSkin = %WilliamSkin
39 @onready var _debug: CanvasLayer = %DebugOverlay
43 var _player_speed := walk_speed
44 var _camera_input_direction := Vector2.ZERO
45 var _last_movement_direction := -rotation
47 var _camera_lockon_node: Node3D = null
48 var _lockon_direction: Vector3 = Vector3.ZERO
49 var _lockon_indicator_scene = load("res://ux/lockon_indicator.tscn")
50 var _lockon_instance: Node3D = _lockon_indicator_scene.instantiate()
52 var _head_track_target: Node3D = null
53 var _head_track_arr: Array[Node3D] = []
55 enum {LOCKON_LEFT, LOCKON_CENTER, LOCKON_RIGHT}
56 var _lockon_shift := LOCKON_CENTER
58 enum {CAMERA_MOUSE_INPUT, CAMERA_JOYSTICK_INPUT}
59 var _camera_input_method := CAMERA_MOUSE_INPUT
61 enum _states {FREE, POSING, TALKING}
62 var _player_state := _states.FREE
66 func _ready() -> void:
67 _camera_spring.spring_length = camera_distance
70 _debug.draw.add_vector(self, "velocity", 1, 3, Color(0,1,0,1))
71 _debug.draw.add_vector(self, "_lockon_direction", 1, 2, Color(1,0,0,1))
72 _debug.draw.add_vector(_camera_pivot, "rotation", 1, 3, Color(1,0,1,1))
73 _debug.draw.add_vector(self, "_last_movement_direction", 1, 3, Color(1,0,0,1))
75 _debug.stats.add_property(self, "velocity", "")
76 _debug.stats.add_property(self, "_idle_time", "round")
77 _debug.stats.add_property(self, "_lockon_shift", "round")
78 _debug.stats.add_property(self, "_head_track_arr", "")
79 _debug.stats.add_property(self, "_head_track_target", "")
82 func _input(event: InputEvent) -> void:
83 if event.is_action_pressed("camera-lockon"):
85 elif event.is_action_pressed("player-run"):
86 _player_speed = jog_speed
87 elif event.is_action_released("player-run"):
88 _player_speed = walk_speed
89 elif event.is_action_pressed("player-pose"):
90 _player_state = _states.POSING
91 elif event.is_action_released("player-pose"):
92 _player_state = _states.FREE
93 elif event.is_action_pressed("player-action"):
94 # TODO: contextual action
95 if _player_state == _states.FREE:
96 _player_state = _states.TALKING
97 velocity = Vector3.ZERO
98 elif _player_state == _states.TALKING:
99 _player_state = _states.FREE
102 func _unhandled_input(event: InputEvent) -> void:
103 # If user clicks on the window, capture the mouse and direct the camera with it
104 if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
107 #_camera_input_direction *= mouse_sensitivity
108 if event is InputEventMouseMotion:
109 _camera_input_method = CAMERA_MOUSE_INPUT
110 _camera_input_direction = event.screen_relative * mouse_sensitivity
111 elif event is InputEventJoypadMotion:
112 _camera_input_method = CAMERA_JOYSTICK_INPUT
113 _camera_input_direction = Input.get_vector("camera-left", "camera-right", "camera-up", "camera-down")
114 _camera_input_direction *= Vector2(joystick_sensitivity_x, -joystick_sensitivity_y)
116 if _camera_input_direction == Vector2.ZERO:
117 _lockon_shift = LOCKON_CENTER
121 func _physics_process(delta: float) -> void:
122 _process_player(delta)
123 _process_camera(delta)
126 func is_camera_locked() -> bool:
127 return camera_mode == "locked"
130 func set_camera_lockon(arr: Array[Node]) -> void:
131 if _camera_lockon_node:
132 _camera_lockon_node.remove_child(_lockon_instance)
134 if not is_camera_locked() or _lockon_shift != LOCKON_CENTER:
135 # pick closest lockon point to center of camera view
136 # must be within lockon radius
137 var shortest_target_angle = _camera.fov / 360 * 2 * PI
138 for n: Node3D in arr:
139 if _camera_lockon_node == n:
142 var target_dir := _camera_pivot.global_position - n.global_position
143 if target_dir.length() < camera_lockon_radius:
144 var target_angle = shortest_target_angle
145 if _lockon_shift == LOCKON_CENTER:
146 var camera_dir = _camera.global_position - _camera_pivot.global_position
147 target_angle = target_dir.angle_to(camera_dir)
149 # check to make sure if lockon switch is a node to the left if player pressed left,
150 # else lockon switch to something to the right
151 target_angle = target_dir.signed_angle_to(_lockon_direction, Vector3.UP)
152 if _lockon_shift == LOCKON_RIGHT and target_angle <= 0:
154 if _lockon_shift == LOCKON_LEFT and target_angle >= 0:
157 target_angle = abs(target_angle)
158 if target_angle < shortest_target_angle:
159 shortest_target_angle = target_angle
160 _camera_lockon_node = n
162 if _camera_lockon_node:
163 _camera_lockon_node.add_child(_lockon_instance)
164 camera_mode = "locked"
166 camera_mode = "unlocked"
167 _camera_lockon_node = null
168 _lockon_direction = Vector3.ZERO
170 # TODO: move all skin stuff to its own reset function
172 _skin.set_hips_direction(0)
174 # always reset shift to center!
175 _lockon_shift = LOCKON_CENTER
178 func _process_camera(delta: float) -> void:
179 if not is_camera_locked():
180 # vertical camera rotation
181 _camera_pivot.rotation.x += _camera_input_direction.y * delta
182 _camera_pivot.rotation.x = clamp(_camera_pivot.rotation.x, -PI / 6, PI / 3)
184 # horizontal camera rotation
185 _camera_pivot.rotation.y -= _camera_input_direction.x * delta
187 # reset mouse movement vector if mouse input
188 if _camera_input_method == CAMERA_MOUSE_INPUT:
189 _camera_input_direction = Vector2.ZERO
191 # camera should lerp to a set height
192 _camera_pivot.rotation.x = lerp(_camera_pivot.rotation.x, 0.0, delta)
193 _camera_pivot.rotation.z = lerp(_camera_pivot.rotation.z, 0.0, delta)
195 # keep camera pivot pointed towards locked object
196 if _camera_lockon_node:
197 # calculate target vector between node position and player
198 var lock_vector := _camera_pivot.global_position - _camera_lockon_node.global_position
199 _lockon_direction = lock_vector
201 var target_angle = Vector3.BACK.signed_angle_to(lock_vector, Vector3.UP)
202 _camera_pivot.global_rotation.y = lerp_angle(_camera_pivot.global_rotation.y, target_angle, rotation_speed * delta)
204 # if player indicates right or left, emit lockon signal again to collect array and split it
205 if _camera_input_direction.x < -lockon_sensitivity and _lockon_shift != LOCKON_LEFT:
206 _lockon_shift = LOCKON_LEFT
208 elif _camera_input_direction.x > lockon_sensitivity and _lockon_shift != LOCKON_RIGHT:
209 _lockon_shift = LOCKON_RIGHT
213 # Get the XZ input direction based on player's input relative to the camera
214 func _get_player_move_direction() -> Vector3:
215 var input_dir := Input.get_vector("player-left", "player-right", "player-forward", "player-backward")
216 var forward := _camera.global_basis.z
217 var right := _camera.global_basis.x
218 var move_direction := (forward * input_dir.y + right * input_dir.x).normalized()
220 return move_direction
223 func _process_player_floor(move_direction: Vector3, delta: float) -> void:
224 # if player is landing, then just return
225 if _skin.current_state() == "landing":
227 elif _skin.current_state() == "fall":
228 if velocity.length() > hard_landing_limit:
229 velocity = Vector3.ZERO
235 # if we're not stuck, then it's okay to set the velocity
236 velocity = velocity.move_toward(move_direction * _player_speed, acceleration * delta)
238 # if player jumps, then we're done
239 if Input.is_action_just_pressed("player-jump"):
240 velocity.y = jump_speed
245 # also, if we're moving, we're not idle
246 # last movement direction required for skin orientation
247 if move_direction.length() < 0.2:
248 if velocity == Vector3.ZERO:
250 if _idle_time > idle_timeout:
254 _last_movement_direction = move_direction
257 # now handle skin rotation and animation
258 var movement_speed := Vector3(velocity.x, 0, velocity.z).length()
259 _skin.movement_speed(movement_speed)
261 # if camera is unlocked, rotate whole skin to face movement direction
262 # else, rotate to face camera pivot global Y direction
263 var skin_target_angle := Vector3.BACK.signed_angle_to(_last_movement_direction, Vector3.UP)
265 # lean into momentum just a little bit
266 _skin.rotation.z = lerp_angle(
268 clamp(_last_movement_direction.signed_angle_to(velocity, Vector3.UP) * movement_speed * 0.08, -PI/4, PI/ 4),
269 rotation_speed * delta * 0.25
272 if not is_camera_locked():
275 _skin.global_rotation.y = lerp_angle(
276 _skin.global_rotation.y,
278 rotation_speed * delta
280 else: # camera is locked
281 var movement_angle := _skin.global_rotation.y - skin_target_angle
282 if abs(movement_angle) < (PI / 2):
285 _skin.move_backward()
287 _skin.global_rotation.y = lerp_angle(
288 _skin.global_rotation.y,
289 _camera_pivot.global_rotation.y - PI,
290 rotation_speed * delta)
292 # hips rotate towards the direction of movement
293 # else hips rotate toward skin global Y direction
294 # if moving backwards, then target_angle is inverted
295 if movement_speed > 0.1:
296 var target_hips_dir := lerp_angle(
297 _skin.global_rotation.y + _skin.get_hips_direction(),
298 skin_target_angle if abs(movement_angle) < (PI / 2) else (skin_target_angle - PI),
299 rotation_speed * delta
300 ) - _skin.global_rotation.y
301 _skin.set_hips_direction(target_hips_dir)
303 _skin.set_hips_direction(lerp_angle(_skin.get_hips_direction(), 0, rotation_speed * delta))
306 func _process_player_falling(move_direction: Vector3, delta: float) -> void:
307 velocity += get_gravity() * fall_speed * delta
308 velocity += move_direction * air_speed * delta
312 func _process_player(delta: float) -> void:
313 var move_direction := _get_player_move_direction()
316 if _player_state == _states.POSING:
318 elif _player_state == _states.TALKING:
321 _process_player_floor(move_direction, delta)
323 _process_player_falling(move_direction, delta)
329 # TODO: choose the closest head track thing to my position
330 # tell skin to track it
331 func _pick_head_track_target() -> void:
332 if _head_track_arr.is_empty():
333 _head_track_target = null
334 _skin.set_head_target(null)
335 _skin.set_eyes_target(null)
337 var target: Node3D = _head_track_arr.front()
338 _head_track_target = target
339 _skin.set_head_target(target)
340 _skin.set_eyes_target(target)
343 # if we find a head tracking obj, add it to the array unless it's already there
344 func _on_head_turn_area_entered(area: Area3D) -> void:
345 for node in area.get_parent().get_children().filter(func(c): return c.is_in_group("player-headTrack")):
346 var i = _head_track_arr.find(node)
348 _head_track_arr.append(node)
349 _pick_head_track_target()
352 func _on_head_turn_area_exited(area: Area3D) -> void:
353 for node in area.get_parent().get_children().filter(func(c): return c.is_in_group("player-headTrack")):
354 var i = _head_track_arr.find(node)
356 _head_track_arr.remove_at(i)
357 _pick_head_track_target()