class_name Dart extends RigidBody3D ## A single dart. Dragged from the tray, thrown at the board. ## ## Lifecycle: FROZEN -> DRAGGING -> IN_FLIGHT -> LANDED ## The scene root (main.gd) routes mouse input here; the dart does not ## hunt for the camera or raycast for itself. signal landed(dart: Dart, hit_point: Vector3) enum State { IDLE, DRAGGING, IN_FLIGHT, LANDED } # --- Tuning ------------------------------------------------------------- @export_group("Drag") ## How hard the dart chases the cursor while held. @export var drag_follow_speed: float = 20.0 ## Clamp so a fast flick doesn't launch it across the room mid-drag. @export var max_drag_speed: float = 42.0 ## Tilt of the drag plane relative to the camera, in degrees. @export_range(-180.0, 180.0) var drag_plane_angle: float = 15.0 @export var drag_linear_damp: float = 5.0 @export var drag_angular_damp: float = 10.0 @export_group("Throw") @export var forward_impulse: float = 8.0 ## Scale the raw flick velocity down. Raw mouse speed is usually too fast. @export var throw_force_multiplier: float = 0.5 @export var max_throw_speed: float = 42.0 ## Higher = more visible arc. Real gravity looks too floaty at dart scale. @export var flight_gravity_scale: float = 2.0 @export_group("Flight") ## How fast the nose swings to face the direction of travel. @export var nose_align_speed: float = 1.0 ## Which local axis is the dart's tip. Check this against the model. @export var forward_axis: Vector3 = Vector3.FORWARD ## Below this speed the dart is considered landed. @export var landed_speed_threshold: float = 0.5 # --- State -------------------------------------------------------------- var state: State = State.IDLE var _drag_plane: Plane var _drag_offset: Vector3 = Vector3.ZERO var _target_pos: Vector3 = Vector3.ZERO ## Sampled positions, used to compute throw velocity on release. const HISTORY_LENGTH := 6 var _pos_history: Array[Vector3] = [] var _time_history: Array[float] = [] func _ready() -> void: freeze_mode = RigidBody3D.FREEZE_MODE_STATIC freeze = true add_to_group(&"dart") # --- Public API (called by main.gd) -------------------------------------- func begin_drag(camera: Camera3D, hit_point: Vector3) -> void: state = State.DRAGGING freeze = false gravity_scale = 0.0 # don't sag while held linear_damp = drag_linear_damp angular_damp = drag_angular_damp # The plane the dart slides along while dragged. Tilted off the camera's # view axis so pulling back also pulls the dart down, which feels natural. var base_normal := camera.global_transform.basis.z var tilted := base_normal.rotated( camera.global_transform.basis.x, deg_to_rad(drag_plane_angle) ) _drag_plane = Plane(tilted.normalized(), hit_point) _drag_offset = global_position - hit_point # Seed the target to the current position. Without this the dart drops # to the plane origin until the mouse first moves. _target_pos = global_position _pos_history.clear() _time_history.clear() func update_drag(camera: Camera3D, mouse_pos: Vector2) -> void: if state != State.DRAGGING: return var ray_origin := camera.project_ray_origin(mouse_pos) var ray_dir := camera.project_ray_normal(mouse_pos) var hit = _drag_plane.intersects_ray(ray_origin, ray_dir) if hit != null: _target_pos = hit + _drag_offset func release_drag() -> void: if state != State.DRAGGING: return state = State.IN_FLIGHT gravity_scale = flight_gravity_scale linear_damp = 0.0 angular_damp = 0.0 # The flick gives aim (up/down/left/right). It CANNOT give forward speed — # the drag plane faces the camera, so motion toward the board is ~zero. var flick := _calculate_throw_velocity() * throw_force_multiplier # The forward component has to come from somewhere. Use the camera's # forward, so "throw" always means "toward what you're looking at". var cam := get_viewport().get_camera_3d() var forward := -cam.global_transform.basis.z * forward_impulse var throw := flick + forward if throw.length() > max_throw_speed: throw = throw.normalized() * max_throw_speed linear_velocity = throw var raw := _calculate_throw_velocity() print("flick: %s forward: %s final: %s (%.1f m/s)" % [flick, forward, throw, throw.length()]) # --- Physics ------------------------------------------------------------ func _physics_process(delta: float) -> void: match state: State.DRAGGING: _process_drag() State.IN_FLIGHT: _process_flight(delta) func _process_drag() -> void: # Chase the target with velocity rather than teleporting, so the dart # still collides with things and carries momentum into the throw. var to_target := _target_pos - global_position var desired := to_target * drag_follow_speed if desired.length() > max_drag_speed: desired = desired.normalized() * max_drag_speed linear_velocity = desired angular_velocity = angular_velocity.lerp(Vector3.ZERO, 0.2) _pos_history.append(global_position) _time_history.append(Time.get_ticks_msec() / 1000.0) if _pos_history.size() > HISTORY_LENGTH: _pos_history.pop_front() _time_history.pop_front() func _process_flight(delta: float) -> void: if linear_velocity.length() > landed_speed_threshold: _align_to_velocity(delta) else: _on_landed() func _align_to_velocity(delta: float) -> void: var vel_dir := linear_velocity.normalized() var current_forward := global_transform.basis * forward_axis var axis := current_forward.cross(vel_dir) if axis.length() < 0.001: return # already aligned, or exactly opposed axis = axis.normalized() var angle := current_forward.angle_to(vel_dir) var step := minf(angle, nose_align_speed * delta) global_transform.basis = global_transform.basis.rotated(axis, step) global_transform.basis = global_transform.basis.orthonormalized() func _on_landed() -> void: state = State.LANDED angular_velocity = Vector3.ZERO landed.emit(self, global_position) func _calculate_throw_velocity() -> Vector3: if _pos_history.size() < 2: return linear_velocity var dt: float = _time_history[-1] - _time_history[0] if dt <= 0.0: return linear_velocity return (_pos_history[-1] - _pos_history[0]) / dt