Files
modbison/scripts/components/object_motion_component.gd

342 lines
9.4 KiB
GDScript

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)