From 6edcb9c45506b13cef82fa0249e8f1f40fe2a0c7 Mon Sep 17 00:00:00 2001 From: copygirl Date: Wed, 24 Jan 2024 13:28:06 +0100 Subject: [PATCH] Item synchronization, Multiplayer improvements - More / better static Game properties - Instance now initialized on _EnterTree - Players moved in from MultiplayerManager - MultiplayerManager renamed to Multiplayer - LocalPlayer now static - Minor preparation for multiple workshops - Add Players script for easy enumerating of players and getting them by peer id - Simplify Player.PeerId and .IsLocal - Remove unnecessary Synchronizer script ("Player:velocity" property specified manually) - Add RpcMode and TransferMode enums to GlobalUsings - Add a bunch more extension functions - Add RPC class to send RPCs in a fancy, type-safe way Relating to item synchronization: - Grid now assumes Grid-local transforms etc - Item functions to pickup, place and throw - This is no longer handled by PickupController - ItemManager is used to relay RPCs due to a bug --- Game.cs | 16 +- Players.cs | 14 ++ game.tscn | 20 +-- objects/Grid.cs | 46 +++--- objects/Item.cs | 181 +++++++++++++++++++-- player/CameraController.cs | 9 +- player/PickupController.cs | 194 +++++++++++------------ player/Player.cs | 24 +-- player/Synchronizer.cs | 12 -- player/player.tscn | 10 +- scenes/ItemManager.cs | 62 ++++++++ scenes/workshop.tscn | 6 +- scripts/MultiplayerManager.cs | 54 +++---- scripts/globals/GlobalUsings.cs | 3 + scripts/globals/MultiplayerExtensions.cs | 5 + scripts/globals/NodeExtensions.cs | 44 +++++ scripts/globals/RPC.cs | 55 +++++++ ui/ControlsMenu.cs | 2 +- ui/MultiplayerMenu.cs | 14 +- 19 files changed, 540 insertions(+), 231 deletions(-) create mode 100644 Players.cs delete mode 100644 player/Synchronizer.cs create mode 100644 scenes/ItemManager.cs create mode 100644 scripts/globals/MultiplayerExtensions.cs create mode 100644 scripts/globals/NodeExtensions.cs create mode 100644 scripts/globals/RPC.cs diff --git a/Game.cs b/Game.cs index 66e2982..4b60e95 100644 --- a/Game.cs +++ b/Game.cs @@ -2,19 +2,19 @@ using System.Globalization; public partial class Game : Node { - static Game _game; - public static Game Instance => _game - ??= ((SceneTree)Engine.GetMainLoop()).Root.GetNode("Game"); - - [Export] public Player LocalPlayer { get; set; } - - public MultiplayerManager MultiplayerManager { get; private set; } + public static Game Instance { get; private set; } + public static new MultiplayerManager Multiplayer { get; private set; } + public static Players Players { get; private set; } + public static Player LocalPlayer { get; private set; } public override void _EnterTree() { // Set invariant culture so formatting is consistent. CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; - MultiplayerManager = GetNode("MultiplayerManager"); + Instance = this; + Multiplayer = this.GetNodeOrThrow(nameof(MultiplayerManager)); + Players = this.GetNodeOrThrow(nameof(Players)); + LocalPlayer = Players.Single(); } } diff --git a/Players.cs b/Players.cs new file mode 100644 index 0000000..8e7ebee --- /dev/null +++ b/Players.cs @@ -0,0 +1,14 @@ +public partial class Players : Node + , IReadOnlyCollection +{ + public int Count + => GetChildCount(); + + public Player ByPeerId(int peerId) + => this.GetNodeOrThrow(peerId.ToString()); + + public IEnumerator GetEnumerator() + => GetChildren().Cast().GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + => GetEnumerator(); +} diff --git a/game.tscn b/game.tscn index 0e00a80..62b2346 100644 --- a/game.tscn +++ b/game.tscn @@ -1,31 +1,31 @@ -[gd_scene load_steps=10 format=3 uid="uid://puuk72ficqhu"] +[gd_scene load_steps=11 format=3 uid="uid://cqitgdxo33amx"] [ext_resource type="Script" path="res://scripts/MultiplayerManager.cs" id="1_7shyh"] [ext_resource type="Script" path="res://Game.cs" id="1_uywdd"] [ext_resource type="PackedScene" uid="uid://dmd7w2r8s0x6y" path="res://player/player.tscn" id="2_iv2f7"] [ext_resource type="PackedScene" uid="uid://bwfuet1irfi17" path="res://scenes/workshop.tscn" id="3_4u5ql"] +[ext_resource type="Script" path="res://Players.cs" id="4_l8q75"] [ext_resource type="Material" uid="uid://c0q35rri3vb07" path="res://assets/shaders/outline_material.tres" id="5_a3fxj"] [ext_resource type="Script" path="res://scripts/OutlineCamera.cs" id="5_qpc14"] [ext_resource type="PackedScene" uid="uid://c5ooi36ibspfo" path="res://ui/menu.tscn" id="6_ol0j5"] [ext_resource type="Texture2D" uid="uid://lxxfestfg2dt" path="res://assets/crosshair.png" id="7_0l5tv"] [ext_resource type="Script" path="res://scripts/Crosshair.cs" id="8_mfhgr"] -[node name="Game" type="Node" node_paths=PackedStringArray("LocalPlayer")] +[node name="Game" type="Node"] script = ExtResource("1_uywdd") -LocalPlayer = NodePath("Players/Local") -[node name="MultiplayerManager" type="Node" parent="." node_paths=PackedStringArray("LocalPlayer", "Players")] +[node name="MultiplayerManager" type="Node" parent="."] script = ExtResource("1_7shyh") -LocalPlayer = NodePath("../Players/Local") -Players = NodePath("../Players") PlayerScene = ExtResource("2_iv2f7") -[node name="Workshop" parent="." instance=ExtResource("3_4u5ql")] - -[node name="Players" type="Node3D" parent="."] +[node name="Players" type="Node" parent="."] +script = ExtResource("4_l8q75") [node name="Local" parent="Players" instance=ExtResource("2_iv2f7")] -IsLocal = true + +[node name="Workshops" type="Node" parent="."] + +[node name="Workshop" parent="Workshops" instance=ExtResource("3_4u5ql")] [node name="OutlineViewportContainer" type="SubViewportContainer" parent="."] material = ExtResource("5_a3fxj") diff --git a/objects/Grid.cs b/objects/Grid.cs index 25e1b3c..0b653f9 100644 --- a/objects/Grid.cs +++ b/objects/Grid.cs @@ -63,12 +63,20 @@ public partial class Grid : Area3D /// - /// Returns whether the item can be placed at the specified global transform. + /// Returns if the item could be considered for placement + /// at the specified local position and normal. + /// + public bool CanPlaceAgainst(Item item, Vector3 position, Vector3 normal) + { + return normal.IsEqualApprox(Vector3.Up); + } + + /// + /// Returns whether the item can be placed at the specified local transform. /// The transform needs to be grid-aligned, such as by calling . /// public bool CanPlaceAt(Item item, Transform3D transform) { - transform = GlobalTransform.AffineInverse() * transform; var region = LocalItemTransformToLocalAabb(transform, item).Grow(-0.01f); foreach (var other in GetChildren().OfType()) if (region.Intersects(LocalItemTransformToLocalAabb(other.Transform, other))) @@ -77,44 +85,28 @@ public partial class Grid : Area3D } /// - /// Snaps a global transform to line up with the grid. + /// Snaps a local transform to line up with the grid. /// The transform will be "pushed out" into the normal vector's /// direction according to the current rotation and item's size. /// public Transform3D Snap(Transform3D transform, Vector3 normal, Item item) { - var halfSize = (Vector3)item.Size * StepSize / 2; - - // Get grid-local values of the global transform and normal. - var inverse = GlobalTransform.AffineInverse(); - var pos = inverse * transform.Origin; - var basis = inverse.Basis * transform.Basis; - normal = inverse.Basis * normal; - // Snap rotation to nearest axis. - basis = Basis.FromEuler(basis.GetEuler().Snapped(Tau / 4)); - + transform.Basis = Basis.FromEuler(transform.Basis.GetEuler().Snapped(Tau / 4)); // Offset / "push out" by half of the item's size. - var axis = (normal * basis).Abs().MaxAxisIndex(); - pos += halfSize[(int)axis] * normal; - + var halfSize = (Vector3)item.Size * StepSize / 2; + var axis = (normal * transform.Basis).Abs().MaxAxisIndex(); + transform.Origin += halfSize[(int)axis] * normal; // Snap the position to the grid. - var halfOff = (basis * halfSize + _halfGridActualSize).PosMod(1.0f); - pos = halfOff + (pos - halfOff).Snapped(StepSize); - - return new(GlobalBasis * basis, GlobalTransform * pos); + var halfOff = (transform.Basis * halfSize + _halfGridActualSize).PosMod(1.0f); + transform.Origin = halfOff + (transform.Origin - halfOff).Snapped(StepSize); + return transform; } static StandardMaterial3D _material; static StandardMaterial3D GetOrCreateMaterial() - { - if (_material == null) { - _material = new StandardMaterial3D(); - _material.VertexColorUseAsAlbedo = true; - } - return _material; - } + => _material ??= new() { VertexColorUseAsAlbedo = true }; ConvexPolygonShape3D _shape; void UpdateCollisionShape() diff --git a/objects/Item.cs b/objects/Item.cs index 0e4beb3..8a57098 100644 --- a/objects/Item.cs +++ b/objects/Item.cs @@ -1,27 +1,186 @@ +using Godot.NativeInterop; + public partial class Item : RigidBody3D { - [Export] public Vector3I Size { get; set; } + public MultiplayerSynchronizer Sync { get; internal set; } + public ItemManager Manager { get; internal set; } = null; // TODO: Replace with Owner? + public uint TrackingId { get; internal set; } + + /// Child node representing the 3D model of this item. + public virtual Node3D Model => GetNode("Model"); + + /// Whether this item is attached to a grid. + public bool IsAttached => GetParent() is Grid; - public virtual Node3D Model - => GetNode("Model"); + /// Size of the item in grid spaces. + [Export] public Vector3I Size { get; set; } public override void _Ready() { + // Set the collision properties here so we don't have to specify them in each item scene separately. + CollisionLayer = (uint)(PhysicsLayer.Item | PhysicsLayer.Pickup); + CollisionMask = (uint)(PhysicsLayer.Static | PhysicsLayer.Dynamic | PhysicsLayer.Player | PhysicsLayer.Item); + // TODO: Find a better way to better import models with colliders. // TODO: Import items dynamically at runtime? // TODO: Use PostImport tool script? foreach (var body in FindChildren("*", "StaticBody3D").Cast()) { - foreach (var shape in body.GetChildren().OfType()) { - body.RemoveChild(shape); - AddChild(shape); - } + foreach (var shape in body.GetChildren().OfType()) + shape.Reparent(this); body.GetParent().RemoveChild(body); } - // Set the collision properties here so we don't have to specify them in each item scene separately. - CollisionLayer = (uint)(PhysicsLayer.Item | PhysicsLayer.Pickup); - CollisionMask = (uint)(PhysicsLayer.Static | PhysicsLayer.Dynamic | PhysicsLayer.Player | PhysicsLayer.Item); + // Set up syncronization for this item when its physics are enabled. + // Sync = new() { RootPath = ".." }; + // var config = Sync.ReplicationConfig = new(); + // config.AddProperty(":position"); + // config.AddProperty(":rotation"); + // config.AddProperty(":linear_velocity"); + // config.AddProperty(":angular_velocity"); + // AddChild(Sync); + + UpdatePhysicsState(); + } + + public override void _Notification(int what) + { + switch ((long)what) { + case NotificationParented: + if (IsInsideTree()) + UpdatePhysicsState(); + break; + } + } + + void UpdatePhysicsState() + { + Freeze = IsAttached; + // Sync.PublicVisibility = !IsAttached; + } + + + public void TryPickup() + { + if (this.IsAuthority()) + RPC.ToAll().Send(Manager.RelayAccept, GetPath(), + ItemManager.AcceptFunc.AcceptPickup, + new Godot.Collections.Array { Multiplayer.GetUniqueId() }); + // RPC.ToAll().Send(AcceptPickup, Multiplayer.GetUniqueId()); + else + RPC.To(GetMultiplayerAuthority()).Send(Manager.RelayRequest, GetPath(), + ItemManager.RequestFunc.RequestPickup, + new Godot.Collections.Array()); + // RPC.To(GetMultiplayerAuthority()).Send(RequestPickup); + } + + [Rpc(RpcMode.AnyPeer)] + void RequestPickup() + { + // Pickup will be requested by any player to the owner of the item. + // If this message is received by anyone else, ignore it (for now). + if (!IsMultiplayerAuthority()) return; + + var player = Multiplayer.GetRemoteSenderPlayer(); + if (player.Pickup.HasItemsHeld) return; + // TODO: Check if player is in range. + + RPC.ToAll().Send(Manager.RelayAccept, GetPath(), + ItemManager.AcceptFunc.AcceptPickup, + new Godot.Collections.Array { player.PeerId }); + // RPC.ToAll().Send(AcceptPickup, player.PeerId); + } + + [Rpc(CallLocal = true)] + void AcceptPickup(int peerId) + { + var player = Game.Players.ByPeerId(peerId); + Reparent(player.Pickup); + Transform = Transform3D.Identity; // TODO: Rotate item? + } + + + public void TryPlace(Grid grid, Transform3D localTransform) + { + if (this.IsAuthority()) + RPC.ToAll().Send(Manager.RelayAccept, GetPath(), + ItemManager.AcceptFunc.AcceptPlace, + new Godot.Collections.Array { grid.GetPath(), localTransform }); + // RPC.ToAll().Send(AcceptPlace, grid.GetPath(), localTransform); + else + RPC.To(GetMultiplayerAuthority()).Send(Manager.RelayRequest, GetPath(), + ItemManager.RequestFunc.RequestPlace, + new Godot.Collections.Array { grid.GetPath(), localTransform }); + // RPC.To(GetMultiplayerAuthority()).Send(RequestPlace, grid.GetPath(), localTransform); + } + + [Rpc(RpcMode.AnyPeer)] + void RequestPlace(NodePath gridPath, Transform3D localTransform) + { + // Must be received by the owner of this item. + if (!IsMultiplayerAuthority()) return; + + if (GetNodeOrNull(gridPath) is not Grid grid) return; + // Item and Grid must be owned by the same peer. + if (!grid.IsMultiplayerAuthority()) return; + if (!grid.CanPlaceAt(this, localTransform)) return; + + RPC.ToAll().Send(Manager.RelayAccept, GetPath(), + ItemManager.AcceptFunc.AcceptPlace, + new Godot.Collections.Array { gridPath, localTransform }); + // RPC.ToAll().Send(AcceptPlace, gridPath, localTransform); + } + + [Rpc(CallLocal = true)] + void AcceptPlace(NodePath gridPath, Transform3D localTransform) + { + if (GetNodeOrNull(gridPath) is not Grid grid) return; + // Item and Grid must be owned by the same peer. + if (grid.GetMultiplayerAuthority() != GetMultiplayerAuthority()) return; + + Reparent(grid); + Transform = localTransform; + } + + + public void TryThrow() + { + if (this.IsAuthority()) + RPC.ToAll().Send(Manager.RelayAccept, GetPath(), + ItemManager.AcceptFunc.AcceptThrow, + new Godot.Collections.Array()); + // RPC.ToAll().Send(AcceptThrow); + else + RPC.To(GetMultiplayerAuthority()).Send(Manager.RelayRequest, GetPath(), + ItemManager.RequestFunc.RequestThrow, + new Godot.Collections.Array()); + // RPC.To(GetMultiplayerAuthority()).Send(RequestThrow); + } + + [Rpc(RpcMode.AnyPeer)] + void RequestThrow() + { + if (!IsMultiplayerAuthority()) return; + + var player = Multiplayer.GetRemoteSenderPlayer(); + if (this.FindParentOrNull() != player) return; + + RPC.ToAll().Send(Manager.RelayAccept, GetPath(), + ItemManager.AcceptFunc.AcceptThrow, + new Godot.Collections.Array()); + // RPC.ToAll().Send(AcceptThrow); + } + + [Rpc(CallLocal = true)] + void AcceptThrow() + { + var player = this.FindParentOrThrow(); + var world = Manager.GetParent(); + + Reparent(world, true); - Freeze = FindParent("Grid") != null; + // Throw item forward and up a bit. + var basis = player.Camera.Camera.GlobalBasis; + var direction = -basis.Z + basis.Y; + ApplyImpulse(direction * 2); } } diff --git a/player/CameraController.cs b/player/CameraController.cs index b6de835..acb0d5a 100644 --- a/player/CameraController.cs +++ b/player/CameraController.cs @@ -17,13 +17,8 @@ public partial class CameraController : Node public override void _Ready() { _player = GetParent(); - - if (_player.IsLocal) { - DefaultTransform = Camera.Transform; - } else { - Camera.QueueFree(); - Camera = null; - } + DefaultTransform = Camera.Transform; + Camera.Current = _player.IsLocal; } public override void _Input(InputEvent @event) diff --git a/player/PickupController.cs b/player/PickupController.cs index 8c4fae7..e9246ad 100644 --- a/player/PickupController.cs +++ b/player/PickupController.cs @@ -4,141 +4,141 @@ public partial class PickupController : Node3D static readonly Color OutlineYesPlace = Colors.Green with { A = 0.75f }; static readonly Color OutlineNoPlace = Colors.Red with { A = 0.75f }; - Node3D _preview; // Placement preview of the item - a duplicate of its model. - Grid _grid; // Grid currently being hovered over. - - public Item CurrentItem { get; private set; } - public bool HasItemsHeld => GetChildCount() > 0; [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; - Node3D _world; - ShaderMaterial _outlineShaderMaterial; + ShaderMaterial _outlineMaterial; public override void _Ready() { _player = GetParent(); - _world = GetNode("/root/Game/Workshop"); // TODO: Find a better way to get the world. - _outlineShaderMaterial = Load("res://assets/shaders/outline_material.tres"); + _outlineMaterial = Load("res://assets/shaders/outline_material.tres"); + ChildEnteredTree += OnChildEnteredTree; + ChildExitingTree += OnChildExitingTree; } 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. - _preview = (Node3D)CurrentItem.Model.Duplicate(0); - _preview.Name = "PlacementPreview"; - _preview.TopLevel = true; - _preview.Visible = false; - SetMeshLayerOutline(_preview, OutlineMode.Exclusive); - AddChild(_preview); - - // 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); - - if (_preview.Visible && _grid != null) { - _grid.AddChild(CurrentItem); - CurrentItem.GlobalTransform = _preview.GlobalTransform; - } else { - _world.AddChild(CurrentItem); - 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); - } - - // Reset the color of the outline shader material. - _outlineShaderMaterial.SetShaderParameter("line_color", OutlinePickup); - RemoveChild(_preview); - _preview.QueueFree(); - _preview = null; - _grid = null; + if (@event.IsActionPressed("interact_pickup") + && (TargetedItem != null) && !HasItemsHeld) + { + TargetedItem.TryPickup(); + GetViewport().SetInputAsHandled(); + } - GetViewport().SetInputAsHandled(); - } + if (@event.IsActionPressed("interact_place") + && HasItemsHeld) + { + if (_preview.Visible && (_targetedGrid != null)) { + var inverse = _targetedGrid.GlobalTransform.AffineInverse(); + HeldItem.TryPlace(_targetedGrid, inverse * _preview.GlobalTransform); + } else + HeldItem.TryThrow(); + GetViewport().SetInputAsHandled(); } } public override void _PhysicsProcess(double delta) { if (!_player.IsLocal) return; - EnsureCurrentItemValid(); - - static bool CanPlaceAgainst(Grid grid, Vector3 normal) - { - normal = grid.GlobalBasis.Inverse() * normal; - return normal.IsEqualApprox(Vector3.Up); - } if (HasItemsHeld) { // This ray will be blocked by static and dynamic objects. const PhysicsLayer Mask = PhysicsLayer.Place | PhysicsLayer.Static | PhysicsLayer.Dynamic; if ((RayToMouseCursor(Mask) is RayResult ray) && (ray.Collider is Grid grid) - // Make sure this is placed against the top of the grid. - && CanPlaceAgainst(grid, ray.Normal) - // Ensure item is not being added to itself or nested items. - && !((grid.GetParent() == CurrentItem) || grid.ContainsItem(CurrentItem))) { - var transform = CurrentItem.GlobalTransform with { Origin = ray.Position }; - transform = grid.Snap(transform, ray.Normal, CurrentItem); - _preview.GlobalTransform = transform; - - var canPlace = grid.CanPlaceAt(CurrentItem, transform); - var outlineColor = canPlace ? OutlineYesPlace : OutlineNoPlace; - _outlineShaderMaterial.SetShaderParameter("line_color", outlineColor); - - _preview.Visible = true; - _grid = canPlace ? grid : null; + // Not pointing at item's own grid, or one of its nested grids. + && (grid.GetParent() != HeldItem) && !grid.ContainsItem(HeldItem)) + { + var inverseTransform = grid.GlobalTransform.AffineInverse(); + var inverseBasis = grid.GlobalBasis.Inverse(); + + var pos = inverseTransform * ray.Position; + var normal = inverseBasis * ray.Normal; + + if (grid.CanPlaceAgainst(HeldItem, pos, normal)) { + var transform = new Transform3D(inverseBasis * HeldItem.GlobalBasis, pos); + transform = grid.Snap(transform, normal, HeldItem); + var canPlace = grid.CanPlaceAt(HeldItem, transform); + + var outlineColor = canPlace ? OutlineYesPlace : OutlineNoPlace; + _outlineMaterial.SetShaderParameter("line_color", outlineColor); + + _preview.GlobalTransform = grid.GlobalTransform * transform; + _preview.Visible = true; + _targetedGrid = canPlace ? grid : null; + } else { + _preview.Visible = false; + _targetedGrid = null; + } } else { _preview.Visible = false; - _grid = null; + _targetedGrid = null; } + } else { var interactable = RayToMouseCursor(PhysicsLayer.Pickup)?.Collider; // Remove the outline from the previously looked-at item. - if (CurrentItem != null) SetMeshLayerOutline(CurrentItem.Model, OutlineMode.Disable); + if (TargetedItem != null) SetMeshLayerOutline(TargetedItem.Model, OutlineMode.Disable); // If the ray hits anything and the object hit is an item, set it as current. - CurrentItem = interactable as Item; + TargetedItem = interactable as Item; // Add the outline to the currently looked-at item. - if (CurrentItem != null) SetMeshLayerOutline(CurrentItem.Model, OutlineMode.Enable); + if (TargetedItem != null) SetMeshLayerOutline(TargetedItem.Model, OutlineMode.Enable); } } - void EnsureCurrentItemValid() + + void OnChildEnteredTree(Node node) { - if (CurrentItem == null) return; - if (!IsInstanceValid(CurrentItem)) { - CurrentItem = null; - if (_preview != null) { - RemoveChild(_preview); - _preview.QueueFree(); - _preview = null; - } - } + 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; } record class RayResult(CollisionObject3D Collider, Vector3 Position, Vector3 Normal); @@ -153,7 +153,7 @@ public partial class PickupController : Node3D query.CollisionMask = (uint)collisionMask; query.CollideWithAreas = true; // Exclude the `CurrentItem` from collision checking if it's being held. - query.Exclude = HasItemsHeld ? [ CurrentItem.GetRid() ] : []; + query.Exclude = HasItemsHeld ? [ HeldItem.GetRid() ] : []; var result = GetWorld3D().DirectSpaceState.IntersectRay(query); return (result.Count > 0) ? new( diff --git a/player/Player.cs b/player/Player.cs index 9f36703..9a73840 100644 --- a/player/Player.cs +++ b/player/Player.cs @@ -1,19 +1,9 @@ public partial class Player : CharacterBody3D { - [Export] public bool IsLocal { get; set; } - - public int PeerId { - get { - if (int.TryParse(Name, out var result)) return result; - throw new InvalidOperationException($"'{Name}' could not be parsed to PeerId"); - } - set { - if (value > 0) Name = value.ToString(); - else if (IsLocal) Name = "Local"; - else throw new InvalidOperationException("Non-local player can't have PeerId set to 0"); - } - } + public int PeerId => GetMultiplayerAuthority(); + public bool IsLocal => this.IsAuthority(); + // TODO: Add "Controller" suffix to these. public MovementController Movement { get; private set; } public CameraController Camera { get; private set; } public AnimationController Animation { get; private set; } @@ -21,9 +11,9 @@ public partial class Player : CharacterBody3D public override void _EnterTree() { - Movement = GetNode("MovementController"); - Camera = GetNode("CameraController"); - Animation = GetNode("AnimationController"); - Pickup = GetNode("PickupController"); + Movement = GetNode(nameof(MovementController)); + Camera = GetNode(nameof(CameraController)); + Animation = GetNode(nameof(AnimationController)); + Pickup = GetNode(nameof(PickupController)); } } diff --git a/player/Synchronizer.cs b/player/Synchronizer.cs deleted file mode 100644 index 8b16e51..0000000 --- a/player/Synchronizer.cs +++ /dev/null @@ -1,12 +0,0 @@ -public partial class Synchronizer : MultiplayerSynchronizer -{ - // Required because `Velocity` can't be synchronized automatically. - [Export] public Vector3 PlayerVelocity { - get => _player.Velocity; - set => _player.Velocity = value; - } - - Player _player; - public override void _Ready() - => _player = GetParent(); -} diff --git a/player/player.tscn b/player/player.tscn index cb78d27..22f851a 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=29 format=3 uid="uid://dmd7w2r8s0x6y"] +[gd_scene load_steps=28 format=3 uid="uid://dmd7w2r8s0x6y"] [ext_resource type="PackedScene" uid="uid://bfh3eqgywr0ul" path="res://assets/models/character.blend" id="1_3qh37"] [ext_resource type="Script" path="res://player/Player.cs" id="1_a0mas"] @@ -7,7 +7,6 @@ [ext_resource type="Script" path="res://player/PickupController.cs" id="2_ns2pe"] [ext_resource type="Script" path="res://player/CameraController.cs" id="2_r3gna"] [ext_resource type="Script" path="res://player/AnimationController.cs" id="3_5rlwc"] -[ext_resource type="Script" path="res://player/Synchronizer.cs" id="4_h8li4"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_h1mfd"] radius = 0.24 @@ -17,14 +16,14 @@ height = 1.5 radius = 0.24 height = 1.5 -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_dpppx"] +[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_305lt"] properties/0/path = NodePath(".:position") properties/0/spawn = true properties/0/replication_mode = 1 properties/1/path = NodePath(".:rotation") properties/1/spawn = true properties/1/replication_mode = 1 -properties/2/path = NodePath("MultiplayerSynchronizer:PlayerVelocity") +properties/2/path = NodePath(".:velocity") properties/2/spawn = true properties/2/replication_mode = 1 properties/3/path = NodePath("MovementController:InputVector") @@ -1001,8 +1000,7 @@ shape = SubResource("CapsuleShape3D_l8s0f") transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, -0.75, 0) [node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."] -replication_config = SubResource("SceneReplicationConfig_dpppx") -script = ExtResource("4_h8li4") +replication_config = SubResource("SceneReplicationConfig_305lt") [node name="MovementController" type="Node" parent="."] script = ExtResource("2_1pst4") diff --git a/scenes/ItemManager.cs b/scenes/ItemManager.cs new file mode 100644 index 0000000..a2e020c --- /dev/null +++ b/scenes/ItemManager.cs @@ -0,0 +1,62 @@ +public partial class ItemManager : Node +{ + public Dictionary TrackedItems { get; } = []; + + public override void _Ready() + { + var items = GetParent().FindChildren("*", "res://objects/Item.cs", owned: false).Cast(); + foreach (var item in items) Add(item); + } + + public void Add(Item item) + { + if (item.Manager != null) throw new ArgumentException( + $"Item '{item.GetPath()}' is already part of another scene"); + + // Generate a random, unused id for this item. + uint id; do { id = Randi(); } while (TrackedItems.ContainsKey(id)); + TrackedItems.Add(id, item); + + item.Manager = this; + item.TrackingId = id; + } + + public void Remove(Item item) + { + if (item.Manager != this) throw new ArgumentException( + $"Item '{item.GetPath()}' is not part of this scene"); + + TrackedItems.Remove(item.TrackingId); + + item.Manager = null; + item.TrackingId = 0; + } + + + // FIXME: This should be fixed in 4.3 and thus not required anymore? + // https://github.com/godotengine/godot/issues/85883 + + internal enum RequestFunc { RequestPickup, RequestPlace, RequestThrow } + internal enum AcceptFunc { AcceptPickup, AcceptPlace, AcceptThrow } + + [Rpc(RpcMode.AnyPeer)] + internal void RelayRequest(NodePath itemPath, RequestFunc func, Godot.Collections.Array args) + { + var item = this.GetNodeOrThrow(itemPath); + var callable = new Callable(item, func.ToString()); + callable.Call(args.ToArray()); + } + + [Rpc(CallLocal = true)] + internal void RelayAccept(NodePath itemPath, AcceptFunc func, Godot.Collections.Array args) + { + var item = this.GetNodeOrThrow(itemPath); + + if (GetMultiplayerAuthority() != item.GetMultiplayerAuthority()) + throw new InvalidOperationException( + $"Item {item.GetPath()} not owned by correct player"); + + var callable = new Callable(item, func.ToString()); + callable.Call(args.ToArray()); + } +} diff --git a/scenes/workshop.tscn b/scenes/workshop.tscn index 4e88a62..08db2fc 100644 --- a/scenes/workshop.tscn +++ b/scenes/workshop.tscn @@ -1,5 +1,6 @@ -[gd_scene load_steps=13 format=3 uid="uid://bwfuet1irfi17"] +[gd_scene load_steps=14 format=3 uid="uid://bwfuet1irfi17"] +[ext_resource type="Script" path="res://scenes/ItemManager.cs" id="1_l6hw6"] [ext_resource type="PackedScene" uid="uid://yvy5vvaqgxy8" path="res://objects/crate.tscn" id="2_j6a20"] [ext_resource type="Texture2D" uid="uid://dts3g3ivc4stn" path="res://assets/palettes/metal.png" id="3_kvstu"] [ext_resource type="PackedScene" uid="uid://ccprmftodum0o" path="res://objects/nail.tscn" id="4_6l6v6"] @@ -24,6 +25,9 @@ size = Vector3(0.1, 0.9, 0.1) [node name="Workshop" type="Node3D"] +[node name="ItemManager" type="Node" parent="."] +script = ExtResource("1_l6hw6") + [node name="Sun" type="DirectionalLight3D" parent="."] transform = Transform3D(0.866025, 0, -0.5, 0.25, 0.866025, 0.433013, 0.433013, -0.5, 0.75, 0, 5, 0) diff --git a/scripts/MultiplayerManager.cs b/scripts/MultiplayerManager.cs index f2052ef..8813fd1 100644 --- a/scripts/MultiplayerManager.cs +++ b/scripts/MultiplayerManager.cs @@ -1,12 +1,11 @@ public partial class MultiplayerManager : Node { - [Export] public Player LocalPlayer { get; set; } - [Export] public Node3D Players { get; set; } [Export] public PackedScene PlayerScene { get; set; } public event Action PlayerJoined; public event Action PlayerLeft; + public override void _Ready() { Multiplayer.ConnectedToServer += OnMultiplayerReady; @@ -16,10 +15,6 @@ public partial class MultiplayerManager : Node } - public Player GetPlayerByPeerId(int peerId) - => Players.GetNode(peerId.ToString()); - - public void Connect(string address, ushort port) { var peer = new ENetMultiplayerPeer(); @@ -44,41 +39,46 @@ public partial class MultiplayerManager : Node void OnMultiplayerReady() { - var localId = Multiplayer.GetUniqueId(); - LocalPlayer.PeerId = localId; - LocalPlayer.SetMultiplayerAuthority(localId); - - if (!Multiplayer.IsServer()) - // Spawn players for all the other peers. This excludes the server, - // since `OnPeerConnected` will already be called for it on connecting. - foreach (var peerId in Multiplayer.GetPeers()) - if (peerId != 1) OnPeerConnected(peerId); + SetAuthorityAndName(Game.LocalPlayer, Multiplayer.GetUniqueId()); + + // Spawn players for all the other peers. This excludes the server, + // since `OnPeerConnected` will already be called for it on connecting. + foreach (var peerId in Multiplayer.GetPeers()) + if (peerId != 1) OnPeerConnected(peerId); } void OnMultiplayerDisconnected() { - foreach (var player in Players.GetChildren().Cast()) { - if (player.IsLocal) player.PeerId = 0; - else OnPeerDisconnected(player.PeerId); - } Multiplayer.MultiplayerPeer.Close(); - Multiplayer.MultiplayerPeer = null; - } + Multiplayer.MultiplayerPeer = new OfflineMultiplayerPeer(); + foreach (var player in Game.Players) + if (player != Game.LocalPlayer) + OnPeerDisconnected(player.PeerId); - void OnPeerConnected(long peerId) + SetAuthorityAndName(Game.LocalPlayer, Multiplayer.GetUniqueId()); + } + + void OnPeerConnected(long _peerId) { var player = PlayerScene.Instantiate(); - player.SetMultiplayerAuthority((int)peerId); - player.PeerId = (int)peerId; - Players.AddChild(player); + SetAuthorityAndName(player, (int)_peerId); + Game.Players.AddChild(player); PlayerJoined?.Invoke(player); } - void OnPeerDisconnected(long peerId) + void OnPeerDisconnected(long _peerId) { - var player = GetPlayerByPeerId((int)peerId); + var player = Game.Players.ByPeerId((int)_peerId); + Game.Players.RemoveChild(player); PlayerLeft?.Invoke(player); player.QueueFree(); } + + + static void SetAuthorityAndName(Player player, int peerId) + { + player.SetMultiplayerAuthority(peerId); + player.Name = peerId.ToString(); + } } diff --git a/scripts/globals/GlobalUsings.cs b/scripts/globals/GlobalUsings.cs index 37b3d1d..fed74e2 100644 --- a/scripts/globals/GlobalUsings.cs +++ b/scripts/globals/GlobalUsings.cs @@ -9,3 +9,6 @@ global using Godot; global using static Godot.GD; global using static Godot.Mathf; + +global using RpcMode = Godot.MultiplayerApi.RpcMode; +global using TransferMode = Godot.MultiplayerPeer.TransferModeEnum; diff --git a/scripts/globals/MultiplayerExtensions.cs b/scripts/globals/MultiplayerExtensions.cs new file mode 100644 index 0000000..adbdd91 --- /dev/null +++ b/scripts/globals/MultiplayerExtensions.cs @@ -0,0 +1,5 @@ +public static class MultiplayerExtensions +{ + public static Player GetRemoteSenderPlayer(this MultiplayerApi self) + => Game.Players.ByPeerId(self.GetRemoteSenderId()); +} diff --git a/scripts/globals/NodeExtensions.cs b/scripts/globals/NodeExtensions.cs new file mode 100644 index 0000000..86250ac --- /dev/null +++ b/scripts/globals/NodeExtensions.cs @@ -0,0 +1,44 @@ +public static class NodeExtensions +{ + /// + /// Similar to , but throws a + /// if the node at the given + /// path does not exist, or a if + /// it is not of the correct type. + /// + public static T GetNodeOrThrow(this Node self, NodePath path) + where T : class + { + var result = self.GetNodeOrNull(path); + if (result == null) throw new InvalidOperationException( + $"Node at '{path}' not found"); + if (result is not T resultOfType) throw new InvalidCastException( + $"Node at '{path}' is {result.GetType()}, expected {typeof(T)}"); + return resultOfType; + } + + /// + /// Finds the first parent of this node that is of + /// type or null if not found. + /// + public static T FindParentOrNull(this Node self) + where T : class + { + while ((self = self.GetParent()) != null) + if (self is T result) return result; + return null; + } + + /// + /// Finds the first parent of this node that is of type + /// or throws a if not found. + /// + public static T FindParentOrThrow(this Node self) + where T : class + => self.FindParentOrNull() ?? throw new InvalidOperationException( + $"No parent node of type {typeof(T)} found in hierarchy of '{self.GetPath()}'"); + + /// Returns whether the current peer has authority over this node. + public static bool IsAuthority(this Node self) + => self.Multiplayer.GetUniqueId() == self.GetMultiplayerAuthority(); +} diff --git a/scripts/globals/RPC.cs b/scripts/globals/RPC.cs new file mode 100644 index 0000000..c22c75c --- /dev/null +++ b/scripts/globals/RPC.cs @@ -0,0 +1,55 @@ +public class RPC +{ + readonly IEnumerable _targets; // null for "everyone" + IEnumerable _except = null; + + RPC(IEnumerable targets) => _targets = targets; + + public static RPC ToAll() => new(null); + + public static RPC To(IEnumerable peerIds) => new(peerIds); + public static RPC To(params int[] peerIds) => To((IEnumerable)peerIds); + public static RPC To(int peerId) => To([ peerId ]); + + public static RPC To(IEnumerable players) => To(players.Select(p => p.PeerId)); + public static RPC To(params Player[] players) => To((IEnumerable)players); + public static RPC To(Player player) => To(player.PeerId); + + public RPC Except(IEnumerable peerIds) { _except = peerIds; return this; } + public RPC Except(params int[] peerIds) => Except((IEnumerable)peerIds); + public RPC Except(int peerId) => Except([ peerId ]); + + public RPC Except(IEnumerable players) => Except(players.Select(p => p.PeerId)); + public RPC Except(params Player[] players) => Except((IEnumerable)players); + public RPC Except(Player player) => Except(player.PeerId); + + public void Send(Action action) + => SendInternal(GetNode(action), action.Method.Name, []); + public void Send<[MustBeVariant] T>(Action action, T arg) + => SendInternal(GetNode(action), action.Method.Name, Variant.From(arg)); + public void Send<[MustBeVariant] T0, [MustBeVariant] T1>(Action action, T0 arg0, T1 arg1) + => SendInternal(GetNode(action), action.Method.Name, Variant.From(arg0), Variant.From(arg1)); + public void Send<[MustBeVariant] T0, [MustBeVariant] T1, [MustBeVariant] T2>(Action action, T0 arg0, T1 arg1, T2 arg2) + => SendInternal(GetNode(action), action.Method.Name, Variant.From(arg0), Variant.From(arg1), Variant.From(arg2)); + public void Send<[MustBeVariant] T0, [MustBeVariant] T1, [MustBeVariant] T2, [MustBeVariant] T3>(Action action, T0 arg0, T1 arg1, T2 arg2, T3 arg3) + => SendInternal(GetNode(action), action.Method.Name, Variant.From(arg0), Variant.From(arg1), Variant.From(arg2), Variant.From(arg3)); + + + /// Returns the target of the instance method as a . + static Node GetNode(Delegate action) + => (action.Target as Node) ?? throw new ArgumentException( + $"Target ({action.Target?.GetType().ToString() ?? "null"}) must be a Node", nameof(action)); + + void SendInternal(Node node, StringName method, params Variant[] args) + { + if ((_targets == null) && (_except == null)) + // TODO: Check if RpcId is recommended for client -> server messages over just Rpc. + node.Rpc(method, args); + else { + var targets = _targets ?? node.Multiplayer.GetPeers(); + if (_except != null) targets = targets.Except(_except); + foreach (var target in targets) + node.RpcId(target, method, args); + } + } +} diff --git a/ui/ControlsMenu.cs b/ui/ControlsMenu.cs index 9526d48..3667752 100644 --- a/ui/ControlsMenu.cs +++ b/ui/ControlsMenu.cs @@ -19,7 +19,7 @@ public partial class ControlsMenu : MarginContainer { DisplayX.Text = $"{SliderX.Value:0.00}"; DisplayY.Text = $"{SliderY.Value:0.00}"; - Game.Instance.LocalPlayer.Camera.MouseSensitivity = new( + Game.LocalPlayer.Camera.MouseSensitivity = new( (float)SliderX.Value * (InvertX.ButtonPressed ? -1 : 1), (float)SliderY.Value * (InvertY.ButtonPressed ? -1 : 1)); // TODO: Do a saving. diff --git a/ui/MultiplayerMenu.cs b/ui/MultiplayerMenu.cs index c0ce39b..f9131a2 100644 --- a/ui/MultiplayerMenu.cs +++ b/ui/MultiplayerMenu.cs @@ -38,8 +38,8 @@ public partial class MultiplayerMenu : MarginContainer Multiplayer.ConnectionFailed += () => UpdateStatus(Status.ConnectionFailed); Multiplayer.ServerDisconnected += () => UpdateStatus(Status.Disconnected); - Game.Instance.MultiplayerManager.PlayerJoined += _ => UpdatePlayerCount(); - Game.Instance.MultiplayerManager.PlayerLeft += _ => UpdatePlayerCount(); + Game.Multiplayer.PlayerJoined += _ => UpdatePlayerCount(); + Game.Multiplayer.PlayerLeft += _ => UpdatePlayerCount(); } void UpdateStatus(Status status) @@ -79,8 +79,8 @@ public partial class MultiplayerMenu : MarginContainer < Status.Connecting => "Singleplayer", Status.Connecting => "??? Players", > Status.Connecting => ((Func)(() => { - var players = Game.Instance.MultiplayerManager.Players.GetChildCount(); - return $"{players} {(players != 1 ? "Players" : "Player")}"; + var count = Game.Players.Count; + return $"{count} {(count != 1 ? "Players" : "Player")}"; }))(), }; } @@ -107,14 +107,14 @@ public partial class MultiplayerMenu : MarginContainer port = ushort.Parse(AddressInput.PlaceholderText.Split(':')[1]); } - Game.Instance.MultiplayerManager.Connect(address, port); + Game.Multiplayer.Connect(address, port); UpdateStatus(Status.Connecting); } public void OnHostPressed() { var port = (ushort)RoundToInt(PortInput.Value); - if (Game.Instance.MultiplayerManager.CreateServer(port)) { + if (Game.Multiplayer.CreateServer(port)) { PortDisplay.Text = port.ToString(); UpdateStatus(Status.Hosting); } else @@ -123,7 +123,7 @@ public partial class MultiplayerMenu : MarginContainer public void OnDisconnectPressed() { - Game.Instance.MultiplayerManager.Disconnect(); + Game.Multiplayer.Disconnect(); UpdateStatus(Status.Disconnected); } }