From 6e830bd59fe6dce5341b81432c7bf8f149c89422 Mon Sep 17 00:00:00 2001 From: squidt <106044562+squidt@users.noreply.github.com> Date: Wed, 13 Nov 2024 03:26:55 -0700 Subject: [PATCH] Add SnapPath (#685) Add a new snap path control that allows you to snap objects on a rail. --- addons/godot-xr-tools/objects/snap_path.gd | 176 ++++++++++++++++++ addons/godot-xr-tools/objects/snap_path.tscn | 32 ++++ .../godot-xr-tools/objects/snap_path_guide.gd | 14 ++ addons/godot-xr-tools/objects/snap_zone.gd | 22 ++- addons/godot-xr-tools/xr/start_xr.gd | 8 +- .../3dmodelscc0/models/scenes/sniper_rifle.gd | 10 - .../models/scenes/sniper_rifle.tscn | 23 +-- assets/meshes/picatinny_rail/picatinny_30.glb | Bin 0 -> 31888 bytes .../picatinny_rail/picatinny_30.glb.import | 36 ++++ .../pickable_demo/objects/picatinny_rail.tscn | 40 ++++ .../objects/picatinny_scope.tscn | 73 ++++++++ scenes/pickable_demo/pickable_demo.tscn | 9 +- 12 files changed, 405 insertions(+), 38 deletions(-) create mode 100644 addons/godot-xr-tools/objects/snap_path.gd create mode 100644 addons/godot-xr-tools/objects/snap_path.tscn create mode 100644 addons/godot-xr-tools/objects/snap_path_guide.gd create mode 100644 assets/meshes/picatinny_rail/picatinny_30.glb create mode 100644 assets/meshes/picatinny_rail/picatinny_30.glb.import create mode 100644 scenes/pickable_demo/objects/picatinny_rail.tscn create mode 100644 scenes/pickable_demo/objects/picatinny_scope.tscn diff --git a/addons/godot-xr-tools/objects/snap_path.gd b/addons/godot-xr-tools/objects/snap_path.gd new file mode 100644 index 00000000..3108875a --- /dev/null +++ b/addons/godot-xr-tools/objects/snap_path.gd @@ -0,0 +1,176 @@ +@tool +class_name XRToolsSnapPath +extends XRToolsSnapZone + + +## An [XRToolsSnapZone] that allows [XRToolsPickable] to be placed along a +## child [Path3D] node. They can either be placed along any point in the curve +## or at discrete intervals by setting "snap_interval" above '0.0'. +## +## Note: Attached [XRToolsPickable]s will face the +Z axis. + + +## Real world distance between intervals in Meters. +## Enabled when not 0 +@export var snap_interval := 0.0: + set(v): snap_interval = absf(v) + +@onready var path : Path3D + + +func _ready() -> void: + super._ready() + + for c in get_children(): + if c is Path3D: + path = c + break + + +func has_snap_interval() -> bool: + return !is_equal_approx(snap_interval, 0.0) + + +func _get_configuration_warnings() -> PackedStringArray: + # Check for Path3D child + for c in get_children(): + if c is Path3D: + path = c + return[] + return["This node requires a Path3D child node to define its shape."] + + +# Called when a target in our grab area is dropped +func _on_target_dropped(target: Node3D) -> void: + # Skip if invalid + if !enabled or !path or !target.can_pick_up(self) or \ + !is_instance_valid(target) or \ + is_instance_valid(picked_up_object): + return + + # Make a zone that will destruct once its object has left + var zone = _make_temp_zone() + var offset = _find_offset(path, target.global_position) + + # if snap guide + if _has_snap_guide(target): + # comply with guide + offset = _find_closest_offset_with_length(path.curve, offset, _get_snap_guide(target).length) + + # too large to place on path + if is_equal_approx(offset, -1.0): + return + + # if snap_interval has been set, use it + if has_snap_interval(): + offset = snappedf(offset, snap_interval) + + # set position + zone.position = path.curve.sample_baked(offset) + + # Add zone as a child + path.add_child(zone) + zone.owner = path + + # Connect self-destruct with lambda + zone.has_dropped.connect(func(): zone.queue_free(), Object.ConnectFlags.CONNECT_ONE_SHOT) + + # Use Pickable's Shapes as our Shapes + for c in target.get_children(): + if c is CollisionShape3D: + PhysicsServer3D.area_add_shape(zone.get_rid(), c.shape.get_rid(), c.transform) + + # Force pickup + zone.pick_up_object(target) + + +# Make a zone that dies on dropping objects +func _make_temp_zone(): + var zone = XRToolsSnapZone.new() + + # connect lambda to play stash sounds when temp zone picks up + if has_node("AudioStreamPlayer3D"): + zone.has_picked_up.connect(\ + func(object):\ + $AudioStreamPlayer3D.stream = stash_sound;\ + $AudioStreamPlayer3D.play()\ + ) + + # XRToolsSnapZone manaul copy + zone.enabled = true + zone.stash_sound = stash_sound + zone.grab_distance = grab_distance + zone.snap_mode = snap_mode + zone.snap_require = snap_require + zone.snap_exclude = snap_exclude + zone.grab_require = grab_require + zone.grab_exclude = grab_exclude + zone.initial_object = NodePath() + + # CollisionObject3D manual copy + zone.disable_mode = disable_mode + zone.collision_layer = collision_layer + zone.collision_mask = collision_mask + zone.collision_priority = collision_priority + + return zone + + +func _has_snap_guide(target: Node3D) -> bool: + for c in target.get_children(): + if c is XRToolsSnapPathGuide: + return true + return false + + +func _get_snap_guide(target: Node3D) -> Node3D: + for c in target.get_children(): + if c is XRToolsSnapPathGuide: + return c + return null + + +# Returns -1 if invalid +# _offset should be in _curve's local coordinates +func _find_closest_offset_with_length(_curve: Curve3D, _offset: float, _length: float) -> float: + # p1 and p2 are the object's start and end respectively + var p1 = _offset + var p2 = _offset - _length + + # a _curve's final point is its end, aka the furthest 'forward', which is why it is p1 + # path_p1 and path_p2 are the curve's start and end respectively + var path_p1 := _curve.get_closest_offset(_curve.get_point_position(_curve.point_count-1)) + var path_p2 := _curve.get_closest_offset(_curve.get_point_position(0)) + + # if at front (or beyond) + if is_equal_approx(p1, path_p1): + # if too large + if p2 < path_p2: + return -1 + # if too far back + elif p2 < path_p2: + # check if snapping will over-extend + if has_snap_interval(): + # snapping p1_new may move it further back, and out-of-bounds + # larger snaps move the object further forward + var p1_new = path_p2 + _length + var ideal_snap = snappedf(p1_new, snap_interval) + var more_snap = _snappedf_up(p1_new, snap_interval) + # if ideal snap fits, take that + if ideal_snap >= p1_new: + return ideal_snap + return more_snap + return path_p2 + _length + # otherwise: within bounds + return p1 + + +## Round 'x' upwards to the nearest 'step' +func _snappedf_up(x, step) -> float: + return step * ceilf(x / step) + + +func _find_offset(_path: Path3D, _global_position: Vector3) -> float: + # transform target pos to local space + var local_pos: Vector3 = _global_position * _path.global_transform + return _path.curve.get_closest_offset(local_pos) diff --git a/addons/godot-xr-tools/objects/snap_path.tscn b/addons/godot-xr-tools/objects/snap_path.tscn new file mode 100644 index 00000000..1dbd3231 --- /dev/null +++ b/addons/godot-xr-tools/objects/snap_path.tscn @@ -0,0 +1,32 @@ +[gd_scene load_steps=4 format=3 uid="uid://dsstvanwd58r0"] + +[ext_resource type="Script" path="res://addons/godot-xr-tools/objects/snap_path.gd" id="1_m211o"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_pik8g"] +size = Vector3(0.1, 0.1, 1) + +[sub_resource type="Curve3D" id="Curve3D_w68am"] +_data = { +"points": PackedVector3Array(0, 0, 0, 0, 0, 0, 0, 0, -0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0.5), +"tilts": PackedFloat32Array(0, 0) +} +point_count = 2 + +[node name="SnapPath" type="Area3D"] +collision_layer = 4 +collision_mask = 65540 +script = ExtResource("1_m211o") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +shape = SubResource("BoxShape3D_pik8g") + +[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="."] +unit_size = 3.0 +max_db = 1.0 +max_distance = 100.0 + +[node name="Path3D" type="Path3D" parent="."] +curve = SubResource("Curve3D_w68am") + +[connection signal="body_entered" from="." to="." method="_on_snap_zone_body_entered"] +[connection signal="body_exited" from="." to="." method="_on_snap_zone_body_exited"] diff --git a/addons/godot-xr-tools/objects/snap_path_guide.gd b/addons/godot-xr-tools/objects/snap_path_guide.gd new file mode 100644 index 00000000..6badc2c7 --- /dev/null +++ b/addons/godot-xr-tools/objects/snap_path_guide.gd @@ -0,0 +1,14 @@ +@tool +class_name XRToolsSnapPathGuide +extends Marker3D + + +## XRToolsSnapRailGuide depicts a guide for [XRToolsSnapPath] to judge the +## length of an [XRToolsPickable], helping place pickables within its bounds. +## Add as a child node to any [XRToolsPickable], then move negatively along +## the Z-Axis to define a length. + + +var length : float: + get: + return abs(position.z) diff --git a/addons/godot-xr-tools/objects/snap_zone.gd b/addons/godot-xr-tools/objects/snap_zone.gd index 8b4afc72..603999d9 100644 --- a/addons/godot-xr-tools/objects/snap_zone.gd +++ b/addons/godot-xr-tools/objects/snap_zone.gd @@ -68,7 +68,14 @@ func is_xr_class(name : String) -> bool: func _ready(): # Set collision shape radius - $CollisionShape3D.shape.radius = grab_distance + if has_node("CollisionShape3D") and "radius" in $CollisionShape3D.shape: + $CollisionShape3D.shape.radius = grab_distance + + # Add important connections + if not body_entered.is_connected(_on_snap_zone_body_entered): + body_entered.connect(_on_snap_zone_body_exited) + if not body_exited.is_connected(_on_snap_zone_body_exited): + body_exited.connect(_on_snap_zone_body_exited) # Perform updates _update_snap_mode() @@ -244,12 +251,13 @@ func pick_up_object(target: Node3D) -> void: # Pick up our target. Note, target may do instant drop_and_free picked_up_object = target - var player = get_node("AudioStreamPlayer3D") - if is_instance_valid(player): - if player.playing: - player.stop() - player.stream = stash_sound - player.play() + if has_node("AudioStreamPlayer3D"): + var player = get_node("AudioStreamPlayer3D") + if is_instance_valid(player): + if player.playing: + player.stop() + player.stream = stash_sound + player.play() target.pick_up(self) diff --git a/addons/godot-xr-tools/xr/start_xr.gd b/addons/godot-xr-tools/xr/start_xr.gd index 80714f01..19ebd215 100644 --- a/addons/godot-xr-tools/xr/start_xr.gd +++ b/addons/godot-xr-tools/xr/start_xr.gd @@ -28,6 +28,10 @@ signal xr_ended signal xr_failed_to_initialize +## XR active flag +static var _xr_active : bool = false + + ## Optional viewport to control @export var viewport : Viewport @@ -50,13 +54,9 @@ var xr_interface : XRInterface ## XR frame rate var xr_frame_rate : float = 0 - # Is a WebXR is_session_supported query running var _webxr_session_query : bool = false -## XR active flag -static var _xr_active : bool = false - # Handle auto-initialization when ready func _ready() -> void: diff --git a/assets/3dmodelscc0/models/scenes/sniper_rifle.gd b/assets/3dmodelscc0/models/scenes/sniper_rifle.gd index 1d2186bf..c393d2ac 100644 --- a/assets/3dmodelscc0/models/scenes/sniper_rifle.gd +++ b/assets/3dmodelscc0/models/scenes/sniper_rifle.gd @@ -1,17 +1,7 @@ @tool extends XRToolsPickable -@export var player_camera : Camera3D: - set(value): - player_camera = value - if is_inside_tree(): - _update_player_camera() - -func _update_player_camera(): - $sniper_rifle/ScopeDisplay.player_camera = player_camera # Called when the node enters the scene tree for the first time. func _ready(): super._ready() - _update_player_camera() - diff --git a/assets/3dmodelscc0/models/scenes/sniper_rifle.tscn b/assets/3dmodelscc0/models/scenes/sniper_rifle.tscn index fbb31026..7c743fb9 100644 --- a/assets/3dmodelscc0/models/scenes/sniper_rifle.tscn +++ b/assets/3dmodelscc0/models/scenes/sniper_rifle.tscn @@ -5,7 +5,6 @@ [ext_resource type="PackedScene" uid="uid://c25yxb0vt53vc" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_left.tscn" id="2_x4cgt"] [ext_resource type="Animation" uid="uid://ddbo6ioa282en" path="res://addons/godot-xr-tools/hands/animations/left/Pistol.res" id="3_np0oa"] [ext_resource type="Script" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_mworp"] -[ext_resource type="PackedScene" uid="uid://e8cx22o0aoxa" path="res://assets/3dmodelscc0/models/scenes/scope_display.tscn" id="5_oy4qx"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_sjb82"] [ext_resource type="PackedScene" uid="uid://da2qgxxwwitl6" path="res://addons/godot-xr-tools/objects/highlight/highlight_ring.tscn" id="6_x8gva"] [ext_resource type="Script" path="res://assets/digitaln8m4r3/scripts/firearm_slide.gd" id="7_gwebu"] @@ -22,9 +21,10 @@ [ext_resource type="PackedScene" uid="uid://dc5t2qgmhb2nf" path="res://addons/godot-xr-tools/objects/hand_pose_area.tscn" id="16_4b70r"] [ext_resource type="Animation" uid="uid://m5x2m8x3tcel" path="res://addons/godot-xr-tools/hands/animations/left/Pinch Tight.res" id="17_in4xb"] [ext_resource type="Animation" uid="uid://ca21ej1p3g2yt" path="res://addons/godot-xr-tools/hands/animations/right/Pinch Tight.res" id="18_1meaq"] +[ext_resource type="PackedScene" uid="uid://cxytulli8pgww" path="res://scenes/pickable_demo/objects/picatinny_rail.tscn" id="20_e06no"] [sub_resource type="BoxShape3D" id="BoxShape3D_apd71"] -size = Vector3(0.0449182, 0.225, 1.145) +size = Vector3(0.0449182, 0.184766, 1.145) [sub_resource type="SphereShape3D" id="SphereShape3D_exh4i"] radius = 0.06 @@ -116,7 +116,7 @@ script = ExtResource("2_bdnea") second_hand_grab = 2 [node name="CollisionShape3D" parent="." index="0"] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.00313599, -0.0294102, 0.0465783) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.00313599, -0.0495274, 0.0465783) shape = SubResource("BoxShape3D_apd71") [node name="HighlightRing" parent="." index="1" instance=ExtResource("6_x8gva")] @@ -141,6 +141,7 @@ surface_material_override/0 = ExtResource("8_7v2rw") surface_material_override/0 = ExtResource("8_7v2rw") [node name="Scope" parent="sniper_rifle/SniperRifle" index="5"] +visible = false surface_material_override/0 = ExtResource("8_7v2rw") [node name="Stock" parent="sniper_rifle/SniperRifle" index="6"] @@ -149,12 +150,7 @@ surface_material_override/0 = ExtResource("8_7v2rw") [node name="Trigger" parent="sniper_rifle/SniperRifle" index="7"] surface_material_override/0 = ExtResource("8_7v2rw") -[node name="ScopeDisplay" parent="sniper_rifle" index="1" instance=ExtResource("5_oy4qx")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.00361723, 0.0610211, 0.324882) -radius = 0.01 -fov = 5.0 - -[node name="FirearmSlide" type="Node3D" parent="sniper_rifle" index="2"] +[node name="FirearmSlide" type="Node3D" parent="sniper_rifle" index="1"] script = ExtResource("7_gwebu") slider_end = 0.072 @@ -240,14 +236,13 @@ drive_position = 0.0 drive_angle = 0.0 drive_aim = 1.0 -[node name="MagSnapZone" parent="." index="8" groups=["sniper_slot"] instance=ExtResource("15_coy4c")] +[node name="PicatinnyRail" parent="." index="8" instance=ExtResource("20_e06no")] +transform = Transform3D(-1, 0, 8.74228e-08, 0, 1, 0, -8.74228e-08, 0, -1, -0.00377285, 0.0421098, 0.0647577) + +[node name="MagSnapZone" parent="." index="9" groups=["sniper_slot"] instance=ExtResource("15_coy4c")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.00341882, -0.0350358, 0.15841) snap_require = "sinper_magazine" -[node name="ScopeSnapZone" parent="." index="9" groups=["sniper_attachment"] instance=ExtResource("15_coy4c")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00416867, 0.0566482, 0.159675) -snap_require = "sniper_scope" - [node name="HandPoseArea" parent="." index="10" node_paths=PackedStringArray("grabpoints") instance=ExtResource("16_4b70r")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.00371962, 0.0341488, 0.238741) left_pose = SubResource("Resource_xtv8j") diff --git a/assets/meshes/picatinny_rail/picatinny_30.glb b/assets/meshes/picatinny_rail/picatinny_30.glb new file mode 100644 index 0000000000000000000000000000000000000000..78f5ce8139b5929b4ac354c5df365e9be8c324b6 GIT binary patch literal 31888 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