class_name PlayerController extends RigidBody3D @export var enabled: bool = false: set(value): if enabled == value: return enabled = value _apply_enabled_state() func _apply_enabled_state() -> void: # Kill any existing jitter tween to prevent stacking if jitter_tween: jitter_tween.kill() if enabled: camera.make_current() get_viewport().gui_release_focus() Input.mouse_mode = Input.MOUSE_MODE_CAPTURED jitter_tween = create_tween() jitter_tween.tween_property(self, "jitter_strength", 1.0, 1.0) if has_entered: ui_exited.emit() # Show hand cursor when player is enabled if hand_cursor: hand_cursor.visible = true else: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE jitter_tween = create_tween() jitter_tween.tween_property(self, "jitter_strength", 0.0, 0.5) if has_entered: ui_exited.emit() # Hide hand cursor when player is disabled if hand_cursor: hand_cursor.visible = false sleeping = not enabled @export var mouse_sensitivity: Vector2 = Vector2(6, 5) @export var initial_pitch: float = 50 @export_range (0.0, 10.0) var max_speed: float = 3 @export_range (0.0, 10.0) var max_acceleration: float = 5 @export_range (0.0, 20.0) var damp: float = 10 @export_range (0.1, 1.0) var mouse_jerk:float = 0.5 @export_range (10.0, 100.0) var gamepad_response:float = 50.0 @export_range (50.0, 500.0) var mouse_jerk_rejection:float = 200.0 @export var max_angle:float = 75.0 @export var camera_jitter_speed:float = 3 @export var angular_jitter:Vector3 = Vector3(0.1, 0, 0.05) @export var angular_jitter_speed:Vector3 = Vector3(2, 1, 1) @export var location_jitter:Vector3 = Vector3(0.05, 0.05, 0.05) @export var location_jitter_speed:Vector3 = Vector3(2, 0.3, 1) var jitter_strength: float = 0 var jitter_tween: Tween = null var loc_noise_spot: Vector3 = Vector3.ZERO var rot_noise_spot: Vector3 = Vector3.ZERO var rotation_speed: Vector2 = Vector2.ZERO var current_mouse_rotation: Vector2 = Vector2.ZERO var noise: Noise = FastNoiseLite.new() var crouched:bool = false: set(set_crouching): if !is_node_ready(): return if set_crouching and not crouched: if State.reduce_motion: $PlayerAnimationPlayer.play("reduced_crouch") elif trigger_slow_crouch: $PlayerAnimationPlayer.play("crouch") trigger_slow_crouch = false else: $PlayerAnimationPlayer.play("fast_crouch") crouched = set_crouching elif (not set_crouching and crouched) and not crouch_held: if can_stand_up(): if State.reduce_motion: $PlayerAnimationPlayer.play("reduced_stand_up") elif trigger_slow_crouch: $PlayerAnimationPlayer.play("stand_up") trigger_slow_crouch = false else: $PlayerAnimationPlayer.play("fast_stand_up") crouched = set_crouching @onready var yaw: Node3D = $Yaw @onready var pitch: Node3D = $Yaw/Pitch @onready var mount: Node3D = $Yaw/Pitch/Mount @onready var camera: Camera3D = $Yaw/Pitch/Mount/Camera3D @onready var focus_ray: RayCast3D = $Yaw/Pitch/Mount/Camera3D/RayCast3D @onready var ui_prober: Area3D = $Yaw/Pitch/Mount/Camera3D/UiProber @onready var hand_cursor: TextureRect = %Cursor # Cursor textures (preloaded for performance) const cursor_default: Texture2D = preload("res://import/interface-elements/cursor_point.png") const cursor_point: Texture2D = preload("res://import/interface-elements/cursor_grab.png") @onready var base_fov := camera.fov var zoomed:bool = false: set(zoom): if zoomed != zoom: if zoom: var zoom_tween := create_tween() zoom_tween.tween_property(camera, "fov", base_fov*0.5, 0.5) else: var zoom_tween := create_tween() zoom_tween.tween_property(camera, "fov", base_fov, 0.5) zoomed = zoom signal ui_entered signal ui_exited func _ready(): State.player = self State.player_view = %Camera3D # Connect to central player enable signal. Scenes.player_enable.connect(_on_player_enable) _handle_jitter(0) pitch.rotation_degrees.x = initial_pitch ui_prober.area_entered.connect(_on_ray_entered) ui_prober.area_exited.connect(_on_ray_exited) $CrouchDetector.area_entered.connect(enter_crouch) $CrouchDetector.area_exited.connect(exit_crouch) # Setup hand cursor _setup_hand_cursor() # Apply exported enabled state now that nodes are ready _apply_enabled_state() func _on_player_enable(enable: bool) -> void: enabled = enable ## Setup the hand cursor in the center of the screen func _setup_hand_cursor() -> void: # Configure the existing TextureRect for cursor display hand_cursor.texture = cursor_default # Start with default cursor hand_cursor.visible = false ## Restores player position and camera rotation from save game func restore_from_save(save: SaveGame) -> void: if save.player_position != Vector3.ZERO: global_position = save.player_position if save.player_yaw != 0: yaw.rotation.y = save.player_yaw if save.player_pitch != 0: pitch.rotation.x = save.player_pitch func _process(_delta) -> void: if not enabled: return if not has_entered: camera.fov = base_fov / (1 + Input.get_action_raw_strength("zoom_in_controller")) var has_entered:bool = false: set(val): if val != has_entered: if val: ui_entered.emit() else: ui_exited.emit() has_entered = val if not has_entered: delay_passed = false var delay_passed:bool = false func _on_ray_entered(_area : Area3D) -> void: var parent := _area.get_parent() as Interactable if not parent.visible: return assert(parent != null, "Ray entered non-interactable area!") #printt("ray entered", parent.name, parent) parent.hover = true # Switch to pointing hand cursor when hovering over interactable if hand_cursor: hand_cursor.texture = cursor_point func _on_ray_exited(_area : Area3D) -> void: var parent := _area.get_parent() as Interactable if not parent.visible: return #printt("ray exited", parent.name, parent) parent.hover = false # Switch back to default cursor when not hovering if hand_cursor: hand_cursor.texture = cursor_default func _physics_process(delta: float): if enabled: _handle_movement(delta) _handle_rotation(delta) if jitter_strength > 0 and not State.reduce_motion: _handle_jitter(delta) func _handle_movement(_delta:float): var input:Vector2 = Vector2(Input.get_action_strength("player_right") - Input.get_action_strength("player_left"), Input.get_action_strength("player_backwards")*0.8 - Input.get_action_strength("player_forwards")) if input.length()>1: input = input.normalized() var direction: Vector3 = Vector3(input.x, 0, input.y) direction = yaw.global_transform.basis.x * direction.x + transform.basis.y * direction.y + yaw.global_transform.basis.z * direction.z linear_damp = damp * max(0.5, 1 - input.length()) apply_central_force(direction*max_acceleration) func _handle_rotation(delta:float): var smoothness = min(3, 60.0/Engine.get_frames_per_second()) var input_speed := Vector2( Input.get_action_strength("look_right")-Input.get_action_strength("look_left"), Input.get_action_strength("look_up")-Input.get_action_strength("look_down")) * gamepad_response # secretly, inverted y axis is the default if not State.inverty_y_axis: input_speed *= Vector2(1, -1) if current_mouse_rotation.length()>0: input_speed = current_mouse_rotation current_mouse_rotation = Vector2.ZERO rotation_speed = rotation_speed * (1-mouse_jerk*smoothness) + input_speed * mouse_jerk * smoothness if rotation_speed.y > 0 and pitch.rotation_degrees.x < 0: rotation_speed.y *= 1-pow(pitch.rotation_degrees.x/-max_angle, 4) elif rotation_speed.y < 0 and pitch.rotation_degrees.x > 0 : rotation_speed.y *= 1-pow(pitch.rotation_degrees.x/max_angle, 4) yaw.rotate_y(deg_to_rad(-rotation_speed.x * delta * mouse_sensitivity.x * State.input_sensitivity)) pitch.rotate_x(deg_to_rad(-rotation_speed.y * delta * mouse_sensitivity.y * State.input_sensitivity)) func _handle_jitter(delta): loc_noise_spot += Vector3(delta * camera_jitter_speed * location_jitter_speed) rot_noise_spot += Vector3(delta * camera_jitter_speed * angular_jitter_speed) pitch.position = Vector3( noise.get_noise_1d(loc_noise_spot.x), noise.get_noise_1d(loc_noise_spot.y), noise.get_noise_1d(loc_noise_spot.z) ) * location_jitter * jitter_strength if not State.reduce_motion: mount.rotation = Vector3( noise.get_noise_1d(rot_noise_spot.x), noise.get_noise_1d(rot_noise_spot.y), noise.get_noise_1d(rot_noise_spot.z) ) * angular_jitter * jitter_strength func _handle_mouse_input(event:InputEventMouseMotion): if event.relative.length() < mouse_jerk_rejection: current_mouse_rotation = event.relative # Variables to keep track of crouch state. var trigger_slow_crouch: bool = false var crouch_held: bool = false var crouch_toggled: bool = false var crouch_start_time: float = 0 func _input(event: InputEvent) -> void: if not enabled: return if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _handle_mouse_input(event) get_viewport().set_input_as_handled() if event is InputEventMouseButton and event.pressed: if Input.is_action_just_pressed("zoom_in_mouse"): zoomed = true elif Input.is_action_just_pressed("zoom_out_mouse"): zoomed = false if event.is_action_pressed("collect_memento_ui") or event.is_action_pressed("option_memento_ui"): if focus_ray.is_colliding(): var collider := focus_ray.get_collider() if collider is InteractiveSprite: collider.handle(event) get_viewport().set_input_as_handled() if event.is_action_pressed("crouch"): crouch_start_time = Time.get_unix_time_from_system() if crouch_toggled: crouch_start_time = 0 else: crouch_held = true crouched = true elif event.is_action_released("crouch"): crouch_held = false if Time.get_unix_time_from_system() > crouch_start_time + 0.5: if crouched and can_stand_up(): crouch_toggled = false crouched = false else: crouch_toggled = true func _on_bed_enter(_body): if not crouched: trigger_slow_crouch = true crouched = true func _on_bed_exit(_body): if crouched and not crouch_held: trigger_slow_crouch = true crouched = false var inside_crouch_volume: Array[CrouchVolume] = [] #returns true, if the player character can stand upright. func can_stand_up() -> bool: for area: Area3D in $CrouchDetector.get_overlapping_areas(): if area is CrouchVolume: return false return true func enter_crouch(body): if body is CrouchVolume: crouched = true func exit_crouch(body): if body is CrouchVolume: crouched = false