public partial class PickupController : Node3D { static readonly Color OutlinePickup = Colors.White with { A = 0.75f }; static readonly Color OutlineYesPlace = Colors.Green with { A = 0.75f }; static readonly Color OutlineNoPlace = Colors.Red with { A = 0.75f }; [Export] public float PickupDistance { get; set; } = 2.0f; /// Item that is currently being targeted by the mouse cursor. public Item TargetedItem { get; private set; } /// Item currently being held. public Item HeldItem => GetChildren().OfType().FirstOrDefault(); // TODO: Support holding multiple items. /// Whether any items are being held. public bool HasItemsHeld => HeldItem != null; Node3D _preview; // Placement preview of the item - a duplicate of its model. Grid _targetedGrid; // Grid currently being hovered over. Player _player; ShaderMaterial _outlineMaterial; public override void _Ready() { _player = GetParent(); _outlineMaterial = Load("res://assets/shaders/outline_material.tres"); ChildEnteredTree += OnChildEnteredTree; ChildExitingTree += OnChildExitingTree; } public override void _UnhandledInput(InputEvent @event) { if (!_player.IsLocal) return; if (@event.IsActionPressed("interact_pickup") && (TargetedItem != null) && !HasItemsHeld) { TargetedItem.Manager.TryPickup(TargetedItem); GetViewport().SetInputAsHandled(); } if (@event.IsActionPressed("interact_place") && HasItemsHeld) { if (_preview.Visible && (_targetedGrid != null)) { var inverse = _targetedGrid.GlobalTransform.AffineInverse(); HeldItem.Manager.TryPlace(HeldItem, _targetedGrid, inverse * _preview.GlobalTransform); } else HeldItem.Manager.TryThrow(HeldItem); GetViewport().SetInputAsHandled(); } } public override void _PhysicsProcess(double delta) { if (!_player.IsLocal) return; if (HasItemsHeld) { if (GetValidPlacement(HeldItem) is var (grid, transform, isFree)) { var outlineColor = isFree ? OutlineYesPlace : OutlineNoPlace; _outlineMaterial.SetShaderParameter("line_color", outlineColor); _preview.GlobalTransform = grid.GlobalTransform * transform; _preview.Visible = true; _targetedGrid = isFree ? grid : null; } else { _preview.Visible = false; _targetedGrid = null; } } else { var interactable = RayToMouseCursor(PhysicsLayer.Interact)?.Collider; // Remove the outline from the previously looked-at item. if (TargetedItem != null) SetMeshLayerOutline(TargetedItem.Model, OutlineMode.Disable); // If the ray hits anything and the object hit is an item, set it as current. TargetedItem = interactable as Item; // Add the outline to the currently looked-at item. if (TargetedItem != null) SetMeshLayerOutline(TargetedItem.Model, OutlineMode.Enable); } } void OnChildEnteredTree(Node node) { if (node is not Item item) return; // TODO: Will be able to hold multiple items in the future. Map item => preview. AddChild(_preview = CreatePlacementPreview(item)); // TODO: Move this elsewhere. Or just redo outline handling. SetMeshLayerOutline(item.Model, OutlineMode.Disable); } void OnChildExitingTree(Node node) { if (node is not Item) return; // Reset the color of the outline shader material. _outlineMaterial.SetShaderParameter("line_color", OutlinePickup); _preview.QueueFree(); _preview = null; _targetedGrid = null; } /// Creates a clone of the item's model, to be rendered as a placement preview. static Node3D CreatePlacementPreview(Item item) { var preview = (Node3D)item.Model.Duplicate(0); preview.Name = "PlacementPreview"; preview.TopLevel = true; // Only use global space transformations. preview.Visible = false; // Starts out not visible. SetMeshLayerOutline(preview, OutlineMode.Exclusive); return preview; } (Grid Grid, Transform3D Target, bool IsFree)? GetValidPlacement(Item itemToPlace) { var excludeSet = new HashSet { HeldItem }; var heldItemGrid = HeldItem.GetNodeOrNull(nameof(Grid)); heldItemGrid?.AddItemsRecursively(excludeSet); // Cast a ray and make sure it hit something. // This ray will be blocked by static and dynamic objects. const PhysicsLayer MASK = PhysicsLayer.Static | PhysicsLayer.Dynamic | PhysicsLayer.Placable; if (RayToMouseCursor(MASK, excludeSet) is not RayResult ray) return null; // Find a grid to place against, which will be either the grid belonging // to the item the ray intersected, or the grid said item is placed upon. var grid = ray.Collider.GetNodeOrNull(nameof(Grid)) ?? ray.Collider.GetParentOrNull(); if (grid == null) return null; // No suitable grid found. var inverseTransform = grid.GlobalTransform.AffineInverse(); var inverseBasis = grid.GlobalBasis.Inverse(); var pos = inverseTransform * ray.Position; var normal = inverseBasis * ray.Normal; if (!grid.CanPlaceAgainst(itemToPlace, pos, normal)) return null; var transform = new Transform3D(inverseBasis * itemToPlace.GlobalBasis, pos); transform = grid.Snap(transform, normal, itemToPlace); var isFree = grid.CanPlaceAt(itemToPlace, transform); return (grid, transform, isFree); } record class RayResult(CollisionObject3D Collider, Vector3 Position, Vector3 Normal); RayResult RayToMouseCursor(PhysicsLayer collisionMask, IEnumerable excluded = null) { 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)collisionMask; query.CollideWithAreas = true; query.Exclude = new((excluded ?? []).Select(obj => obj.GetRid())); var result = GetWorld3D().DirectSpaceState.IntersectRay(query); return (result.Count > 0) ? new( result["collider"].As(), (Vector3)result["position"], (Vector3)result["normal"] ) : null; } 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; } } }