class_name ObjectMotionComponent extends Node ## Component providing procedural movement (Rotate, Bob/Float, Waypoint paths) ## and interactive triggers for placed world objects. ## Supports platform velocity for player character riding and multiplayer timestamp sync. signal motion_started signal motion_stopped signal waypoint_reached(index: int) enum MotionType { NONE, ROTATE, BOB, WAYPOINT } enum TriggerMode { ALWAYS, PROXIMITY, INTERACT, SIGNAL } enum LoopMode { CYCLE, PING_PONG, ONCE } var motion_type: String = "none" var trigger_mode: String = "always" var loop_mode: String = "cycle" # Rotation parameters var rotation_axis: Vector3 = Vector3.UP var rotation_speed: float = 45.0 # degrees per second # Bob / Float parameters var bob_axis: Vector3 = Vector3.UP var bob_amplitude: float = 0.5 var bob_frequency: float = 1.0 # Waypoint parameters var waypoints: Array[Vector3] = [] var move_speed: float = 2.0 var pause_time: float = 0.0 # Trigger parameters var trigger_radius: float = 3.5 var is_active: bool = true # Runtime state var _target_node: Node3D = null var _initial_pos: Vector3 = Vector3.ZERO var _initial_basis: Basis = Basis.IDENTITY var _time: float = 0.0 var _current_waypoint_idx: int = 0 var _waypoint_forward: bool = true var _pause_timer: float = 0.0 var linear_velocity: Vector3 = Vector3.ZERO var angular_velocity: Vector3 = Vector3.ZERO func _ready() -> void: add_to_group("moving_objects") if _target_node == null: _target_node = get_parent() as Node3D if _target_node != null: _initial_pos = _target_node.position _initial_basis = _target_node.basis if trigger_mode != "always": is_active = false func setup(p_target: Node3D, config: Dictionary = {}) -> void: _target_node = p_target if _target_node != null: _initial_pos = _target_node.position _initial_basis = _target_node.basis _time = 0.0 _pause_timer = 0.0 _current_waypoint_idx = 0 _waypoint_forward = true linear_velocity = Vector3.ZERO angular_velocity = Vector3.ZERO if not config.is_empty(): load_from_dict(config) func _physics_process(delta: float) -> void: if _target_node == null or delta <= 0.0: return if trigger_mode.to_lower() == "proximity": _process_proximity_check() if not is_active: linear_velocity = Vector3.ZERO angular_velocity = Vector3.ZERO _apply_velocities_to_colliders() return _time += delta var prev_pos := _target_node.position match motion_type.to_lower(): "rotate": _process_rotate(delta) "bob", "oscillate": _process_bob(delta) "waypoint", "patrol": _process_waypoint(delta) linear_velocity = (_target_node.position - prev_pos) / delta _apply_velocities_to_colliders() func _process_proximity_check() -> void: if _target_node == null: return var target_pos := _target_node.global_position if _target_node.is_inside_tree() else _target_node.position var players := get_tree().get_nodes_in_group("players") var nearby := false for p in players: if p is Node3D: var p_pos := (p as Node3D).global_position if (p as Node3D).is_inside_tree() else (p as Node3D).position if p_pos.distance_to(target_pos) <= trigger_radius: nearby = true break if nearby and not is_active: start_motion() elif not nearby and is_active: stop_motion() func check_proximity_to_position(pos: Vector3) -> bool: if _target_node == null: return false var target_pos := _target_node.global_position if _target_node.is_inside_tree() else _target_node.position var dist := target_pos.distance_to(pos) var in_range := dist <= trigger_radius if in_range and not is_active: start_motion() elif not in_range and is_active: stop_motion() return in_range func on_interact() -> void: if trigger_mode.to_lower() == "interact": toggle_motion() func on_signal(_sig_name: String = "") -> void: if trigger_mode.to_lower() == "signal": toggle_motion() func _process_rotate(delta: float) -> void: var rad_speed := deg_to_rad(rotation_speed) var rot_delta := rad_speed * delta _target_node.rotate(rotation_axis.normalized(), rot_delta) angular_velocity = rotation_axis.normalized() * rad_speed func _process_bob(_delta: float) -> void: var offset: float = sin(_time * bob_frequency * TAU) * bob_amplitude _target_node.position = _initial_pos + (bob_axis.normalized() * offset) func _process_waypoint(delta: float) -> void: if waypoints.is_empty(): return if _pause_timer > 0.0: _pause_timer -= delta return var target_wp := waypoints[_current_waypoint_idx] var current_pos := _target_node.position var to_target := target_wp - current_pos var dist := to_target.length() if dist < 0.001: _advance_waypoint() target_wp = waypoints[_current_waypoint_idx] to_target = target_wp - current_pos dist = to_target.length() var step := move_speed * delta if dist <= step: _target_node.position = target_wp waypoint_reached.emit(_current_waypoint_idx) _pause_timer = pause_time _advance_waypoint() else: _target_node.position += to_target.normalized() * step func _advance_waypoint() -> void: match loop_mode.to_lower(): "cycle": _current_waypoint_idx = (_current_waypoint_idx + 1) % waypoints.size() "ping_pong": if _waypoint_forward: if _current_waypoint_idx >= waypoints.size() - 1: _waypoint_forward = false _current_waypoint_idx = maxi(0, waypoints.size() - 2) else: _current_waypoint_idx += 1 else: if _current_waypoint_idx <= 0: _waypoint_forward = true _current_waypoint_idx = mini(1, waypoints.size() - 1) else: _current_waypoint_idx -= 1 "once": if _current_waypoint_idx < waypoints.size() - 1: _current_waypoint_idx += 1 else: is_active = false motion_stopped.emit() func _apply_velocities_to_colliders() -> void: if _target_node == null: return var sb := _target_node.find_child("collision", true, false) as StaticBody3D if sb == null: sb = _target_node.find_child("*", true, false) as StaticBody3D if sb != null: sb.constant_linear_velocity = linear_velocity sb.constant_angular_velocity = angular_velocity func start_motion() -> void: is_active = true motion_started.emit() func stop_motion() -> void: is_active = false linear_velocity = Vector3.ZERO angular_velocity = Vector3.ZERO _apply_velocities_to_colliders() motion_stopped.emit() func toggle_motion() -> void: if is_active: stop_motion() else: start_motion() func reverse_motion() -> void: rotation_speed = -rotation_speed _waypoint_forward = not _waypoint_forward func sync_to_timestamp(server_time: float) -> void: _time = server_time if _target_node == null: return match motion_type.to_lower(): "bob", "oscillate": var offset: float = sin(_time * bob_frequency * TAU) * bob_amplitude _target_node.position = _initial_pos + (bob_axis.normalized() * offset) "rotate": var angle: float = deg_to_rad(rotation_speed) * server_time _target_node.basis = _initial_basis.rotated(rotation_axis.normalized(), angle) "waypoint", "patrol": if waypoints.size() >= 2 and move_speed > 0.0: _sync_waypoints(server_time) func _sync_waypoints(t: float) -> void: var total_dur := 0.0 var seg_durs: Array[float] = [] var count := waypoints.size() var seg_count := count if loop_mode == "cycle" else count - 1 for i in range(seg_count): var p0: Vector3 = waypoints[i] var p1: Vector3 = waypoints[(i + 1) % count] var d := p0.distance_to(p1) var dur := (d / move_speed) + pause_time seg_durs.append(dur) total_dur += dur if total_dur <= 0.0: return var progress := fmod(t, total_dur) var accumulated := 0.0 for i in range(seg_count): var dur := seg_durs[i] if progress <= accumulated + dur: var seg_t := (progress - accumulated) / maxf(0.001, (dur - pause_time)) seg_t = clampf(seg_t, 0.0, 1.0) var p0: Vector3 = waypoints[i] var p1: Vector3 = waypoints[(i + 1) % count] _target_node.position = p0.lerp(p1, seg_t) _current_waypoint_idx = (i + 1) % count break accumulated += dur func to_dict() -> Dictionary: var wps_serialized: Array = [] for wp in waypoints: wps_serialized.append([wp.x, wp.y, wp.z]) return { "motion_type": motion_type, "trigger_mode": trigger_mode, "loop_mode": loop_mode, "rotation_axis": [rotation_axis.x, rotation_axis.y, rotation_axis.z], "rotation_speed": rotation_speed, "bob_axis": [bob_axis.x, bob_axis.y, bob_axis.z], "bob_amplitude": bob_amplitude, "bob_frequency": bob_frequency, "move_speed": move_speed, "pause_time": pause_time, "waypoints": wps_serialized, "trigger_radius": trigger_radius, "is_active": is_active, } func load_from_dict(d: Dictionary) -> void: motion_type = str(d.get("motion_type", "none")) trigger_mode = str(d.get("trigger_mode", "always")) loop_mode = str(d.get("loop_mode", "cycle")) rotation_speed = float(d.get("rotation_speed", 45.0)) bob_amplitude = float(d.get("bob_amplitude", 0.5)) bob_frequency = float(d.get("bob_frequency", 1.0)) move_speed = float(d.get("move_speed", 2.0)) pause_time = float(d.get("pause_time", 0.0)) trigger_radius = float(d.get("trigger_radius", 3.5)) is_active = bool(d.get("is_active", trigger_mode == "always")) if d.has("rotation_axis"): var a = d["rotation_axis"] if a is Array and a.size() >= 3: rotation_axis = Vector3(a[0], a[1], a[2]) if d.has("bob_axis"): var b = d["bob_axis"] if b is Array and b.size() >= 3: bob_axis = Vector3(b[0], b[1], b[2]) if d.has("waypoints"): waypoints.clear() for wp in d["waypoints"]: if wp is Array and wp.size() >= 3: waypoints.append(Vector3(wp[0], wp[1], wp[2])) elif wp is Vector3: waypoints.append(wp)