- 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 bugmain
parent
b1112f6006
commit
6edcb9c455
19 changed files with 540 additions and 231 deletions
@ -0,0 +1,14 @@ |
||||
public partial class Players : Node |
||||
, IReadOnlyCollection<Player> |
||||
{ |
||||
public int Count |
||||
=> GetChildCount(); |
||||
|
||||
public Player ByPeerId(int peerId) |
||||
=> this.GetNodeOrThrow<Player>(peerId.ToString()); |
||||
|
||||
public IEnumerator<Player> GetEnumerator() |
||||
=> GetChildren().Cast<Player>().GetEnumerator(); |
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() |
||||
=> GetEnumerator(); |
||||
} |
@ -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; } |
||||
|
||||
/// <summary> Child node representing the 3D model of this item. </summary> |
||||
public virtual Node3D Model => GetNode<Node3D>("Model"); |
||||
|
||||
/// <summary> Whether this item is attached to a grid. </summary> |
||||
public bool IsAttached => GetParent() is Grid; |
||||
|
||||
public virtual Node3D Model |
||||
=> GetNode<Node3D>("Model"); |
||||
/// <summary> Size of the item in grid spaces. </summary> |
||||
[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<StaticBody3D>()) { |
||||
foreach (var shape in body.GetChildren().OfType<CollisionShape3D>()) { |
||||
body.RemoveChild(shape); |
||||
AddChild(shape); |
||||
} |
||||
foreach (var shape in body.GetChildren().OfType<CollisionShape3D>()) |
||||
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>() != 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<Player>(); |
||||
var world = Manager.GetParent<Node3D>(); |
||||
|
||||
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); |
||||
} |
||||
} |
||||
|
@ -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<Player>(); |
||||
} |
@ -0,0 +1,62 @@ |
||||
public partial class ItemManager : Node |
||||
{ |
||||
public Dictionary<uint, Item> TrackedItems { get; } = []; |
||||
|
||||
public override void _Ready() |
||||
{ |
||||
var items = GetParent().FindChildren("*", "res://objects/Item.cs", owned: false).Cast<Item>(); |
||||
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<Item>(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<Item>(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()); |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
public static class MultiplayerExtensions |
||||
{ |
||||
public static Player GetRemoteSenderPlayer(this MultiplayerApi self) |
||||
=> Game.Players.ByPeerId(self.GetRemoteSenderId()); |
||||
} |
@ -0,0 +1,44 @@ |
||||
public static class NodeExtensions |
||||
{ |
||||
/// <summary> |
||||
/// Similar to <see cref="Node.GetNodeOrNull{T}"/>, but throws a |
||||
/// <see cref="InvalidOperationException"/> if the node at the given |
||||
/// path does not exist, or a <see cref="InvalidCastException"/> if |
||||
/// it is not of the correct type. |
||||
/// </summary> |
||||
public static T GetNodeOrThrow<T>(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; |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Finds the first parent of this node that is of |
||||
/// type <paramref name="T"/> or null if not found. |
||||
/// </summary> |
||||
public static T FindParentOrNull<T>(this Node self) |
||||
where T : class
|
||||
{ |
||||
while ((self = self.GetParent()) != null) |
||||
if (self is T result) return result; |
||||
return null; |
||||
} |
||||
|
||||
/// <summary> |
||||
/// Finds the first parent of this node that is of type <paramref name="T"/> |
||||
/// or throws a <see cref="InvalidOperationException"/> if not found. |
||||
/// </summary> |
||||
public static T FindParentOrThrow<T>(this Node self) |
||||
where T : class
|
||||
=> self.FindParentOrNull<T>() ?? throw new InvalidOperationException( |
||||
$"No parent node of type {typeof(T)} found in hierarchy of '{self.GetPath()}'"); |
||||
|
||||
/// <summary> Returns whether the current peer has authority over this node. </summary> |
||||
public static bool IsAuthority(this Node self) |
||||
=> self.Multiplayer.GetUniqueId() == self.GetMultiplayerAuthority(); |
||||
} |
@ -0,0 +1,55 @@ |
||||
public class RPC |
||||
{ |
||||
readonly IEnumerable<int> _targets; // null for "everyone" |
||||
IEnumerable<int> _except = null; |
||||
|
||||
RPC(IEnumerable<int> targets) => _targets = targets; |
||||
|
||||
public static RPC ToAll() => new(null); |
||||
|
||||
public static RPC To(IEnumerable<int> peerIds) => new(peerIds); |
||||
public static RPC To(params int[] peerIds) => To((IEnumerable<int>)peerIds); |
||||
public static RPC To(int peerId) => To([ peerId ]); |
||||
|
||||
public static RPC To(IEnumerable<Player> players) => To(players.Select(p => p.PeerId)); |
||||
public static RPC To(params Player[] players) => To((IEnumerable<Player>)players); |
||||
public static RPC To(Player player) => To(player.PeerId); |
||||
|
||||
public RPC Except(IEnumerable<int> peerIds) { _except = peerIds; return this; } |
||||
public RPC Except(params int[] peerIds) => Except((IEnumerable<int>)peerIds); |
||||
public RPC Except(int peerId) => Except([ peerId ]); |
||||
|
||||
public RPC Except(IEnumerable<Player> players) => Except(players.Select(p => p.PeerId)); |
||||
public RPC Except(params Player[] players) => Except((IEnumerable<Player>)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<T> action, T arg) |
||||
=> SendInternal(GetNode(action), action.Method.Name, Variant.From(arg)); |
||||
public void Send<[MustBeVariant] T0, [MustBeVariant] T1>(Action<T0, T1> 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<T0, T1, T2> 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<T0, T1, T2, T3> 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)); |
||||
|
||||
|
||||
/// <summary> Returns the target of the instance method <paramref name="action"/> as a <see cref="Node"/>. </summary> |
||||
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); |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue