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
main
copygirl 4 months ago
parent b1112f6006
commit 6edcb9c455
  1. 16
      Game.cs
  2. 14
      Players.cs
  3. 20
      game.tscn
  4. 46
      objects/Grid.cs
  5. 181
      objects/Item.cs
  6. 9
      player/CameraController.cs
  7. 194
      player/PickupController.cs
  8. 24
      player/Player.cs
  9. 12
      player/Synchronizer.cs
  10. 10
      player/player.tscn
  11. 62
      scenes/ItemManager.cs
  12. 6
      scenes/workshop.tscn
  13. 54
      scripts/MultiplayerManager.cs
  14. 3
      scripts/globals/GlobalUsings.cs
  15. 5
      scripts/globals/MultiplayerExtensions.cs
  16. 44
      scripts/globals/NodeExtensions.cs
  17. 55
      scripts/globals/RPC.cs
  18. 2
      ui/ControlsMenu.cs
  19. 14
      ui/MultiplayerMenu.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>("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>("MultiplayerManager");
Instance = this;
Multiplayer = this.GetNodeOrThrow<MultiplayerManager>(nameof(MultiplayerManager));
Players = this.GetNodeOrThrow<Players>(nameof(Players));
LocalPlayer = Players.Single();
}
}

@ -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,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")

@ -63,12 +63,20 @@ public partial class Grid : Area3D
/// <summary>
/// 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.
/// </summary>
public bool CanPlaceAgainst(Item item, Vector3 position, Vector3 normal)
{
return normal.IsEqualApprox(Vector3.Up);
}
/// <summary>
/// Returns whether the item can be placed at the specified local transform.
/// The transform needs to be grid-aligned, such as by calling <see cref="Snap"/>.
/// </summary>
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<Item>())
if (region.Intersects(LocalItemTransformToLocalAabb(other.Transform, other)))
@ -77,44 +85,28 @@ public partial class Grid : Area3D
}
/// <summary>
/// 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.
/// </summary>
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()

@ -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);
}
}

@ -17,13 +17,8 @@ public partial class CameraController : Node
public override void _Ready()
{
_player = GetParent<Player>();
if (_player.IsLocal) {
DefaultTransform = Camera.Transform;
} else {
Camera.QueueFree();
Camera = null;
}
DefaultTransform = Camera.Transform;
Camera.Current = _player.IsLocal;
}
public override void _Input(InputEvent @event)

@ -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;
/// <summary> Item that is currently being targeted by the mouse cursor. </summary>
public Item TargetedItem { get; private set; }
/// <summary> Item currently being held. </summary>
public Item HeldItem => GetChildren().OfType<Item>().FirstOrDefault();
// TODO: Support holding multiple items.
/// <summary> Whether any items are being held. </summary>
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<Player>();
_world = GetNode<Node3D>("/root/Game/Workshop"); // TODO: Find a better way to get the world.
_outlineShaderMaterial = Load<ShaderMaterial>("res://assets/shaders/outline_material.tres");
_outlineMaterial = Load<ShaderMaterial>("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;
}
/// <summary> Creates a clone of the item's model, to be rendered as a placement preview. </summary>
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(

@ -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>("MovementController");
Camera = GetNode<CameraController>("CameraController");
Animation = GetNode<AnimationController>("AnimationController");
Pickup = GetNode<PickupController>("PickupController");
Movement = GetNode<MovementController>(nameof(MovementController));
Camera = GetNode<CameraController>(nameof(CameraController));
Animation = GetNode<AnimationController>(nameof(AnimationController));
Pickup = GetNode<PickupController>(nameof(PickupController));
}
}

@ -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>();
}

@ -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")

@ -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());
}
}

@ -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)

@ -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<Player> PlayerJoined;
public event Action<Player> PlayerLeft;
public override void _Ready()
{
Multiplayer.ConnectedToServer += OnMultiplayerReady;
@ -16,10 +15,6 @@ public partial class MultiplayerManager : Node
}
public Player GetPlayerByPeerId(int peerId)
=> Players.GetNode<Player>(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<Player>()) {
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>();
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();
}
}

@ -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;

@ -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);
}
}
}

@ -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.

@ -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<string>)(() => {
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);
}
}

Loading…
Cancel
Save