Co-Op Layer

This commit is contained in:
Luis Gonzalez
2026-06-01 10:48:18 -07:00
parent 1f647dd5e1
commit e851d5f8e9
29 changed files with 667 additions and 20 deletions
+46
View File
@@ -119,6 +119,51 @@ NavMeshSettings:
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &121227139
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 121227141}
- component: {fileID: 121227140}
m_Layer: 0
m_Name: NetConnectionUI
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &121227140
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 121227139}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 977a7574cf6ff4898b7e9db6c21368f9, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Client::ProjectM.Client.ConnectionUI
_port: 7979
--- !u!4 &121227141
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 121227139}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &330585543
GameObject:
m_ObjectHideFlags: 0
@@ -629,3 +674,4 @@ SceneRoots:
- {fileID: 832575519}
- {fileID: 1314640900}
- {fileID: 833091047}
- {fileID: 121227141}
@@ -16,6 +16,12 @@ namespace ProjectM.Authoring
public Vector3 SpawnPoint = Vector3.zero;
[Tooltip("Radius (m) of the co-op spawn ring; players land on distinct slots so they don't stack.")]
public float SpawnRingRadius = 2.5f;
[Tooltip("Evenly-spaced ring positions before players spill onto an outer ring.")]
public int RingSlots = 4;
private class PlayerSpawnerBaker : Baker<PlayerSpawnerAuthoring>
{
public override void Bake(PlayerSpawnerAuthoring authoring)
@@ -26,7 +32,9 @@ namespace ProjectM.Authoring
AddComponent(entity, new PlayerSpawner
{
PlayerPrefab = GetEntity(authoring.PlayerPrefab, TransformUsageFlags.Dynamic),
SpawnPoint = authoring.SpawnPoint
SpawnPoint = authoring.SpawnPoint,
SpawnRingRadius = authoring.SpawnRingRadius,
RingSlots = authoring.RingSlots
});
}
}
@@ -63,7 +63,7 @@ namespace ProjectM.Client
SystemAPI.GetSingletonEntity<GhostCollection>(),
SystemAPI.GetSingletonEntity<SpawnedGhostEntityMap>());
m_PredictedGhostSpawnLookup = state.GetBufferLookup<PredictedGhostSpawn>(true);
m_PredictedGhostSpawnLookup = state.GetBufferLookup<PredictedGhostSpawn>(false);
m_ProjectileLookup = state.GetComponentLookup<Projectile>(true);
state.RequireForUpdate<GhostSpawnQueue>();
@@ -134,7 +134,7 @@ namespace ProjectM.Client
// is NOT marked [ReadOnly].
public Entity PredictedSpawnListEntity;
[ReadOnly] public BufferLookup<PredictedGhostSpawn> PredictedGhostSpawnLookup;
public BufferLookup<PredictedGhostSpawn> PredictedGhostSpawnLookup;
[ReadOnly] public ComponentLookup<Projectile> ProjectileLookup;
// 'data' is taken by value (NOT 'in') because TryGetComponentDataFromSnapshotHistory needs a
@@ -0,0 +1,49 @@
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client/thin-client half of the M4 LAN join flow. When the per-world <see cref="ConnectionConfig"/>
/// requests <see cref="ConnectionMode.Join"/>, creates a <see cref="NetworkStreamRequestConnect"/>
/// entity for the parsed endpoint and clears the request. Runs in both full and thin client worlds so
/// the editor auto-host can connect thin clients too. Not Burst-compiled: <c>NetworkEndpoint.TryParse</c>
/// takes a managed string and this is a cold path.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
public partial struct ClientConnectionControlSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ConnectionConfig>();
}
public void OnUpdate(ref SystemState state)
{
var cfgRef = SystemAPI.GetSingletonRW<ConnectionConfig>();
if (!cfgRef.ValueRO.Requested || cfgRef.ValueRO.Mode != ConnectionMode.Join)
return;
// Clear first so a malformed address does not retry-spam every frame.
cfgRef.ValueRW.Requested = false;
var address = cfgRef.ValueRO.Address;
var port = cfgRef.ValueRO.Port;
if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4))
{
Debug.LogError($"[ClientConnectionControlSystem] Invalid join address '{address}'.");
return;
}
var ecb = new EntityCommandBuffer(Allocator.Temp);
var req = ecb.CreateEntity();
ecb.AddComponent(req, new NetworkStreamRequestConnect { Endpoint = endpoint });
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c274f15ad41814f8d8337f786590313d
@@ -0,0 +1,79 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Minimal IMGUI Host / Join (+ IP) panel for the M4 LAN flow — the connection entry point for player
/// builds, where there is no editor auto-host. Writes a <see cref="ConnectionConfig"/> request into the
/// relevant netcode world(s): <b>Host</b> makes the server world listen and the local client join over
/// loopback; <b>Join</b> makes the client world connect to the typed IP. Hides itself once the client
/// world has a connection. Direct IP/LAN only — Unity Relay is deferred to a later pass. In the editor
/// the <c>EditorAutoHostSystem</c> usually connects first, so this panel just shows "Connected".
/// </summary>
public class ConnectionUI : MonoBehaviour
{
[SerializeField] ushort _port = 7979;
string _ipField = "127.0.0.1";
void OnGUI()
{
if (IsClientConnected())
{
GUILayout.BeginArea(new Rect(10, 10, 220, 26));
GUILayout.Label("Net: Connected");
GUILayout.EndArea();
return;
}
GUILayout.BeginArea(new Rect(10, 10, 240, 150), GUI.skin.box);
GUILayout.Label("Project M — LAN co-op");
// Host is only possible where a server world exists (editor or a ClientServer/host build).
if (ClientServerBootstrap.ServerWorld is { IsCreated: true })
{
if (GUILayout.Button("Host"))
{
Seed(ClientServerBootstrap.ServerWorld, ConnectionMode.Host, "0.0.0.0", _port);
Seed(ClientServerBootstrap.ClientWorld, ConnectionMode.Join, "127.0.0.1", _port);
}
GUILayout.Space(6);
}
GUILayout.Label("Join host IP:");
_ipField = GUILayout.TextField(_ipField);
if (GUILayout.Button("Join"))
Seed(ClientServerBootstrap.ClientWorld, ConnectionMode.Join, _ipField, _port);
GUILayout.EndArea();
}
static bool IsClientConnected()
{
var world = ClientServerBootstrap.ClientWorld;
if (world is not { IsCreated: true })
return false;
using var q = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkId>());
return !q.IsEmpty;
}
static void Seed(World world, ConnectionMode mode, string address, ushort port)
{
if (world is not { IsCreated: true })
return;
var em = world.EntityManager;
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<ConnectionConfig>());
Entity e = q.IsEmpty ? em.CreateEntity(typeof(ConnectionConfig)) : q.GetSingletonEntity();
em.SetComponentData(e, new ConnectionConfig
{
Mode = mode,
Address = address,
Port = port,
Requested = true,
});
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 977a7574cf6ff4898b7e9db6c21368f9
@@ -10,7 +10,8 @@
"Unity.Burst",
"Unity.NetCode",
"Unity.Entities.Graphics",
"Unity.InputSystem"
"Unity.InputSystem",
"Unity.Networking.Transport"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -45,7 +45,7 @@ namespace ProjectM.Server
var networkId = SystemAPI.GetComponent<NetworkId>(connection);
var player = ecb.Instantiate(spawner.PlayerPrefab);
ecb.SetComponent(player, LocalTransform.FromPosition(spawner.SpawnPoint));
ecb.SetComponent(player, LocalTransform.FromPosition(spawner.SpawnPoint + PlayerSpawnMath.SpawnOffset(networkId.Value, spawner.SpawnRingRadius, spawner.RingSlots)));
ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value });
// Auto-despawn the player when its owning connection is removed.
@@ -0,0 +1,41 @@
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
namespace ProjectM.Server
{
/// <summary>
/// Server-world half of the M4 LAN host flow. When the per-world <see cref="ConnectionConfig"/>
/// requests <see cref="ConnectionMode.Host"/>, creates a <see cref="NetworkStreamRequestListen"/>
/// entity (netcode binds the server driver and starts listening) and clears the request. Replaces the
/// old <c>AutoConnectPort</c> auto-listen. Not Burst-compiled: a cold path that fires only on an
/// explicit host request.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct ServerConnectionControlSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ConnectionConfig>();
}
public void OnUpdate(ref SystemState state)
{
var cfgRef = SystemAPI.GetSingletonRW<ConnectionConfig>();
if (!cfgRef.ValueRO.Requested || cfgRef.ValueRO.Mode != ConnectionMode.Host)
return;
cfgRef.ValueRW.Requested = false;
var endpoint = NetworkEndpoint.AnyIpv4.WithPort(cfgRef.ValueRO.Port);
var ecb = new EntityCommandBuffer(Allocator.Temp);
var req = ecb.CreateEntity();
ecb.AddComponent(req, new NetworkStreamRequestListen { Endpoint = endpoint });
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5530186288473432f8edcc47c1844e07
@@ -8,7 +8,8 @@
"Unity.Collections",
"Unity.Mathematics",
"Unity.Burst",
"Unity.NetCode"
"Unity.NetCode",
"Unity.Networking.Transport"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -0,0 +1,35 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Connection intent for the M4 LAN host/join flow. Lives in Simulation so both the server and
/// client worlds can read it. A UI (<c>ConnectionUI</c>) — or, in the editor, the auto-host system —
/// writes the desired <see cref="Mode"/> + endpoint and sets <see cref="Requested"/> true; the
/// per-world ConnectionControlSystem turns it into a netcode <c>NetworkStreamRequestListen</c> /
/// <c>NetworkStreamRequestConnect</c> and clears <see cref="Requested"/>. Direct IP/LAN only for now —
/// Unity Relay is deferred to a later pass. Created per-world as a singleton.
/// </summary>
public enum ConnectionMode : byte
{
None,
Host,
Join,
}
public struct ConnectionConfig : IComponentData
{
/// <summary>What to do with this world's network stream.</summary>
public ConnectionMode Mode;
/// <summary>Dotted IPv4 to connect to (Join). Ignored for Host (binds AnyIpv4).</summary>
public FixedString64Bytes Address;
/// <summary>Listen/connect port.</summary>
public ushort Port;
/// <summary>Set true to request the control system act on Mode this frame; it clears the flag.</summary>
public bool Requested;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85356726c8f524aebb6cc93336fa09dd
@@ -0,0 +1,35 @@
#if UNITY_EDITOR
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// EDITOR-ONLY dev convenience for the M4 LAN flow. With auto-connect disabled in <c>GameBootstrap</c>,
/// this seeds a <see cref="ConnectionConfig"/> in each world on its first update so entering Play "just
/// works" multi-client: the server world hosts on loopback and every client / thin-client world joins
/// it — reproducing the old single-key playflow but for N players. Runs once per world then disables
/// itself, and skips seeding if a <see cref="ConnectionConfig"/> already exists (e.g. set by
/// ConnectionUI). Does not exist in player builds.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
public partial struct EditorAutoHostSystem : ISystem
{
public void OnUpdate(ref SystemState state)
{
state.Enabled = false; // run exactly once per world
if (SystemAPI.HasSingleton<ConnectionConfig>())
return;
const ushort port = 7979;
var cfg = state.WorldUnmanaged.IsServer()
? new ConnectionConfig { Mode = ConnectionMode.Host, Address = "127.0.0.1", Port = port, Requested = true }
: new ConnectionConfig { Mode = ConnectionMode.Join, Address = "127.0.0.1", Port = port, Requested = true };
var e = state.EntityManager.CreateEntity();
state.EntityManager.AddComponentData(e, cfg);
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 642fc5c4d71654becba10814a2d08e92
@@ -5,21 +5,24 @@ using UnityEngine.Scripting;
namespace ProjectM.Simulation
{
/// <summary>
/// Custom Netcode for Entities bootstrap. Subclassing <see cref="ClientServerBootstrap"/>
/// gives an explicit hook to customize world creation, tick rate, and auto-connect.
/// For now it reproduces the default behavior: create separate client and server worlds
/// based on the Multiplayer PlayMode Tools settings, without auto-connecting.
/// Custom Netcode for Entities bootstrap. Subclassing <see cref="ClientServerBootstrap"/> gives an
/// explicit hook to customize world creation, tick rate, and connection.
/// <para>
/// M4 (LAN co-op): auto-connect is DISABLED (<see cref="ClientServerBootstrap.AutoConnectPort"/> = 0).
/// Listening/connecting is driven explicitly via the <see cref="ConnectionConfig"/> singleton and the
/// per-world ConnectionControlSystems — from <c>ConnectionUI</c> (Host / Join + IP) in player builds,
/// or from the editor-only <c>EditorAutoHostSystem</c>, which auto-hosts on loopback and connects the
/// in-proc client plus any Multiplayer-PlayMode-Tools thin clients. Direct IP/LAN only for now; Unity
/// Relay is deferred to a later pass.
/// </para>
/// </summary>
[Preserve]
public class GameBootstrap : ClientServerBootstrap
{
public override bool Initialize(string defaultWorldName)
{
// Auto-connect in-editor: the server listens and the in-process client connects (over
// IPC) on the default BinaryWorlds host mode — one process hosts both worlds (M1 listen
// server). M3 replaces this with an explicit Unity Relay host/join flow. Set to 0 to
// disable auto-connect.
AutoConnectPort = 7979;
// No auto-connect: ConnectionConfig + the ConnectionControlSystems own listen/connect (M4).
AutoConnectPort = 0;
CreateDefaultClientServerWorlds();
return true;
}
@@ -0,0 +1,41 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Deterministic spawn-position math for co-op. Pure and side-effect-free — no RNG, no wall-clock —
/// so the same NetworkId always yields the same planar offset. The server places each connecting
/// player at a stable ring slot keyed by its NetworkId, so players never stack. Unit-tested in
/// EditMode (no netcode world required).
/// </summary>
public static class PlayerSpawnMath
{
/// <summary>
/// Planar (XZ) offset from the base spawn point for a connection's NetworkId. NetworkIds start at
/// 1, so id 1 maps to slot 0. Slots are evenly spaced around a ring of <paramref name="radius"/>;
/// once a ring's slots are full, further players spill onto an outer ring (radius * (ring + 1)),
/// keeping every position distinct. Returns zero when <paramref name="radius"/> &lt;= 0 (degenerate
/// or unbaked spawner) so behaviour falls back to the single shared spawn point.
/// </summary>
public static float3 SpawnOffset(int networkId, float radius, int slots)
{
if (radius <= 0f)
return float3.zero;
if (slots < 1)
slots = 1;
int idx = networkId - 1;
if (idx < 0)
idx = 0;
int ring = idx / slots;
int slotInRing = idx % slots;
float angle = (2f * math.PI * slotInRing) / slots;
float r = radius * (ring + 1);
math.sincos(angle, out float s, out float c);
return new float3(c * r, 0f, s * r);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cabd6efb0aaf14e3a95c47c95bc4160c
@@ -11,5 +11,10 @@ namespace ProjectM.Simulation
{
public Entity PlayerPrefab;
public float3 SpawnPoint;
// M4 co-op: deterministic per-NetworkId ring spread so players don't stack on connect.
// Radius of the spawn ring (metres); RingSlots = evenly-spaced positions before rings expand.
public float SpawnRingRadius;
public int RingSlots;
}
}
@@ -0,0 +1,87 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-function tests for <see cref="PlayerSpawnMath.SpawnOffset"/> (no ECS world), mirroring
/// StatMathTests. Pins the M4 co-op deterministic spawn spread: stable, distinct per-NetworkId ring
/// positions so players never stack on connect.
/// </summary>
public class PlayerSpawnRingTests
{
const float R = 2.5f;
const int Slots = 4;
const float Eps = 1e-4f;
[Test]
public void Deterministic_SameInputs_SameOffset()
{
var a = PlayerSpawnMath.SpawnOffset(2, R, Slots);
var b = PlayerSpawnMath.SpawnOffset(2, R, Slots);
Assert.AreEqual(a.x, b.x, Eps);
Assert.AreEqual(a.y, b.y, Eps);
Assert.AreEqual(a.z, b.z, Eps);
}
[Test]
public void FirstFour_Land_On_Cardinal_Ring_Slots()
{
// NetworkIds start at 1; slots=4 -> 0, 90, 180, 270 degrees on a ring of radius R.
AssertXz(PlayerSpawnMath.SpawnOffset(1, R, Slots), R, 0f);
AssertXz(PlayerSpawnMath.SpawnOffset(2, R, Slots), 0f, R);
AssertXz(PlayerSpawnMath.SpawnOffset(3, R, Slots), -R, 0f);
AssertXz(PlayerSpawnMath.SpawnOffset(4, R, Slots), 0f, -R);
}
[Test]
public void Distinct_NetworkIds_Give_Distinct_Positions()
{
var seen = new System.Collections.Generic.List<float3>();
for (int id = 1; id <= 8; id++)
{
var p = PlayerSpawnMath.SpawnOffset(id, R, Slots);
foreach (var q in seen)
Assert.Greater(math.distance(p, q), 1e-3f, $"id {id} collides with an earlier slot");
seen.Add(p);
}
}
[Test]
public void FifthPlayer_Spills_To_Outer_Ring()
{
// idx 4 with slots 4 -> ring 1, slot 0 -> radius doubled, angle 0.
AssertXz(PlayerSpawnMath.SpawnOffset(5, R, Slots), 2f * R, 0f);
}
[Test]
public void NonPositiveRadius_Returns_Zero()
{
Assert.AreEqual(0f, math.length(PlayerSpawnMath.SpawnOffset(3, 0f, Slots)), Eps);
Assert.AreEqual(0f, math.length(PlayerSpawnMath.SpawnOffset(3, -1f, Slots)), Eps);
}
[Test]
public void DegenerateSlots_DoNotThrow_AndStayDistinct()
{
// slots < 1 is clamped to 1 (every player on its own concentric ring).
var a = PlayerSpawnMath.SpawnOffset(1, R, 0);
var b = PlayerSpawnMath.SpawnOffset(2, R, 0);
Assert.Greater(math.distance(a, b), 1e-3f);
}
[Test]
public void Offset_Is_Planar_NoVerticalComponent()
{
for (int id = 1; id <= 6; id++)
Assert.AreEqual(0f, PlayerSpawnMath.SpawnOffset(id, R, Slots).y, Eps);
}
static void AssertXz(float3 p, float x, float z)
{
Assert.AreEqual(x, p.x, Eps);
Assert.AreEqual(z, p.z, Eps);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6578b95ff4c4c40c780ec25b56e28eff