Co-Op Layer
This commit is contained in:
@@ -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"/> <= 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
|
||||
Reference in New Issue
Block a user