public partial class PickupController : Node3D { public Item CurrentItem { get; private set; } public bool HasItemsHeld => GetChildCount() > 0; Node3D _placementPreview; [Export] public float PickupDistance { get; set; } = 2.0f; Player _player; Node3D _world; public override void _Ready() { _player = GetParent(); // TODO: Find a better way to find the world. _world = GetNode("/root/Game/Workshop"); } public override void _UnhandledInput(InputEvent @event) { if (!_player.IsLocal) return; EnsureCurrentItemValid(); if (CurrentItem == null) return; if (@event.IsActionPressed("interact_pickup")) { if (!HasItemsHeld) { // Create clone of the item's model, use it as placement preview. _placementPreview = (Node3D)CurrentItem.Model.Duplicate(0); _placementPreview.Name = "PlacementPreview"; _placementPreview.TopLevel = true; _placementPreview.Visible = false; SetMeshLayerOutline(_placementPreview, OutlineMode.Exclusive); AddChild(_placementPreview); // Parent item to the `PickupController`. var prevRot = CurrentItem.GlobalRotation; CurrentItem.Freeze = true; SetMeshLayerOutline(CurrentItem.Model, OutlineMode.Disable); CurrentItem.GetParent().RemoveChild(CurrentItem); AddChild(CurrentItem); CurrentItem.Position = Vector3.Zero; CurrentItem.GlobalRotation = prevRot; GetViewport().SetInputAsHandled(); } } else if (@event.IsActionPressed("interact_place")) { if (HasItemsHeld) { // Parent item back to the world. var prevTransform = CurrentItem.GlobalTransform; RemoveChild(CurrentItem); _world.AddChild(CurrentItem); if (_placementPreview.Visible) { CurrentItem.GlobalTransform = _placementPreview.GlobalTransform; } else { CurrentItem.GlobalTransform = prevTransform; CurrentItem.Freeze = false; // Throw item forward and up a bit. var basis = _player.Camera.Camera.GlobalBasis; var direction = -basis.Z + basis.Y; CurrentItem.ApplyImpulse(direction * 2); } RemoveChild(_placementPreview); _placementPreview.QueueFree(); _placementPreview = null; GetViewport().SetInputAsHandled(); } } } public override void _PhysicsProcess(double delta) { if (!_player.IsLocal) return; EnsureCurrentItemValid(); if (HasItemsHeld) { if ((RayToMouseCursor() is RayResult ray) && (ray.Collider is Grid grid)) { // Snao rotation to nearest axis. var localBasis = grid.GlobalBasis.Inverse() * CurrentItem.GlobalBasis; localBasis = Basis.FromEuler(localBasis.GetEuler().Snapped(Tau / 4)); _placementPreview.GlobalBasis = grid.GlobalBasis * localBasis; // Snap the position to the grid. var halfSize = (Vector3)CurrentItem.Size * Grid.StepSize / 2; var localPos = ray.Position * grid.GlobalTransform; // Get grid-local ray position. var localNormal = ray.Normal * grid.GlobalBasis; // Get grid-local ray normal. (Pointing away from surface hit.) var off = localBasis * halfSize.PosMod(1.0f); // Calculate an offset for grid snapping. off[(int)localNormal.Abs().MaxAxisIndex()] = 0; // Do not include offset in the normal direction. localPos = off + (localPos - off).Snapped(Grid.StepSize); // Snap `localPos` to nearest grid value. var axis = (localNormal * localBasis).Abs().MaxAxisIndex(); // Find object-local axis that the normal is pointing towards. localPos += halfSize[(int)axis] * localNormal; // Offset (push out) object by half its size. _placementPreview.GlobalPosition = grid.GlobalTransform * localPos; _placementPreview.Visible = true; } else { _placementPreview.Visible = false; } } else { var interactable = RayToMouseCursor()?.Collider; // Remove the outline from the previously looked-at item. if (CurrentItem != null) SetMeshLayerOutline(CurrentItem.Model, OutlineMode.Disable); // If the ray hits anything and the object hit is an item, set it as current. CurrentItem = interactable as Item; // Add the outline to the currently looked-at item. if (CurrentItem != null) SetMeshLayerOutline(CurrentItem.Model, OutlineMode.Enable); } } void EnsureCurrentItemValid() { if (CurrentItem == null) return; if (!IsInstanceValid(CurrentItem)) { CurrentItem = null; if (_placementPreview != null) { RemoveChild(_placementPreview); _placementPreview.QueueFree(); _placementPreview = null; } } } RayResult RayToMouseCursor() { var camera = _player.Camera.Camera; var mouse = GetViewport().GetMousePosition(); var from = camera.ProjectRayOrigin(mouse); var to = from + camera.ProjectRayNormal(mouse) * PickupDistance; var query = PhysicsRayQueryParameters3D.Create(from, to); query.CollisionMask = (uint)PhysicsLayer.Interactable; query.CollideWithAreas = true; // Exclude the `CurrentItem` from collision checking if it's being held. query.Exclude = HasItemsHeld ? [ CurrentItem.GetRid() ] : []; var result = GetWorld3D().DirectSpaceState.IntersectRay(query); return (result.Count > 0) ? new(result) : null; } class RayResult(Dictionary dict) { public CollisionObject3D Collider { get; } = dict["collider"].As(); public Vector3 Position { get; } = (Vector3)dict["position"]; public Vector3 Normal { get; } = (Vector3)dict["normal"]; } enum OutlineMode { Disable, Enable, Exclusive } static void SetMeshLayerOutline(Node3D parent, OutlineMode mode) { var children = parent.FindChildren("*", "MeshInstance3D", owned: false); foreach (var mesh in children.Cast()) switch (mode) { case OutlineMode.Disable: mesh.Layers &= ~(uint)RenderLayer.Outline; break; case OutlineMode.Enable: mesh.Layers |= (uint)RenderLayer.Outline; break; case OutlineMode.Exclusive: mesh.Layers = (uint)RenderLayer.Outline; break; } } }