Exclude other players from climb probes and spread network spawns

Wall/ceiling/ground climb probes now skip any player collider (own and
remote) instead of only the local hierarchy, so running into another
chameleon no longer triggers the climb mechanic. New players are placed
on a per-clientId ring at spawn (host stays at origin) to avoid stacking
into one collision pile.

Also persists the serialized defaults of the recent network fields into
the Player prefab (incl. the spawnRingRadius inspector tuning) and a TMP
dynamic-atlas reset artifact.
This commit is contained in:
admin committed 2026-06-27 16:26:54 +02:00
1 parent eedba5f0e6
commit 10f2518640
4 files changed
+1316 -13

No files matched your search

+43
View File
@@ -11,12 +11,24 @@ using UnityEngine;
[DisallowMultipleComponent]
public class NetworkPlayerSetup : NetworkBehaviour
{
[Header("Spawn placement")]
[Tooltip("All avatars spawn from the same prefab position, so without an offset they stack and shove " +
"each other on connect. The owner places itself on a ring keyed by its client id (owner-" +
"authoritative NetworkTransform replicates it). Id 0 (host) stays at the origin; later joiners " +
"fan out. Spacing between ring slots, in world units. 0 disables placement.")]
[SerializeField] private float spawnRingRadius = 2.5f;
[Tooltip("Slots per ring before the radius steps outward, so spacing stays roughly constant as more join.")]
[SerializeField] private int spawnRingSlots = 8;
public override void OnNetworkSpawn()
{
var controller = GetComponent<PlayerCharacterController>();
if (IsOwner)
{
PlaceOnSpawnRing();
var manager = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
if (manager)
manager.BindLocalPlayer(controller);
@@ -40,4 +52,35 @@ public class NetworkPlayerSetup : NetworkBehaviour
// 3D brush placement into UV space; freezing the bake would mis-map replicated paint onto any
// non-rest pose. Its bake is the necessary cost of correct remote paint, not wasted work.
}
/// <summary>
/// Offsets the freshly-spawned owner onto a deterministic ring keyed by its client id so avatars
/// don't pile up at the shared prefab spawn point. Id 0 (the host) keeps the origin so single-player
/// "host alone" is unchanged; every later joiner takes the next ring slot, stepping the radius out
/// once a ring fills. Only X/Z are shifted (gravity settles Y), and the CharacterController is toggled
/// around the move because it otherwise overrides direct transform writes. Owner-authoritative, so the
/// NetworkTransform replicates the placement outward.
/// </summary>
private void PlaceOnSpawnRing()
{
if (spawnRingRadius <= 0f || spawnRingSlots < 1) return;
ulong id = OwnerClientId;
if (id == 0) return; // host stays centered
ulong k = id - 1;
int ring = (int)(k / (ulong)spawnRingSlots);
int slot = (int)(k % (ulong)spawnRingSlots);
float radius = spawnRingRadius * (ring + 1);
// Half-slot stagger per ring so outer slots don't line up radially with inner ones.
float angle = (slot / (float)spawnRingSlots + ring * 0.5f / spawnRingSlots) * Mathf.PI * 2f;
Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius;
var cc = GetComponent<CharacterController>();
bool reenable = cc && cc.enabled;
if (cc) cc.enabled = false;
transform.position += offset;
if (cc) cc.enabled = reenable;
}
}
+14 -4
View File
@@ -940,6 +940,16 @@ public partial class PlayerCharacterController : MonoBehaviour
private float WorldHalfHeight => _controller.height * 0.5f * CapsuleScale;
private const float ProbeReachSlack = 0.5f; // extra ray length beyond the capsule surface (scale-1 units)
/// <summary>
/// True if the collider belongs to ANY player avatar -- this one OR a networked remote. Every climb
/// probe must skip players: avatars still collide physically (this player's CharacterController vs a
/// remote's baked MeshCollider), but another chameleon's body must never register as a climbable
/// wall/ceiling/ground. Replaces the old self-only IsChildOf check -- self is a player too, so it
/// stays excluded. Finds the controller even on remotes, where the component is disabled (only the
/// GameObject's active state gates GetComponentInParent, not the component's enabled flag).
/// </summary>
private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent<PlayerCharacterController>();
/// <summary>
/// Raycasts from the character center toward the surface (along -_wallNormal) to find the
/// current normal and the gap (surface clearance) to it. The capsule's support distance
@@ -963,7 +973,7 @@ public partial class PlayerCharacterController : MonoBehaviour
bool found = false;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform)) // skip our own colliders
if (IsPlayerCollider(_wallProbe[i].collider)) // skip self and other player avatars
continue;
if (_wallProbe[i].distance < best)
{
@@ -1008,7 +1018,7 @@ public partial class PlayerCharacterController : MonoBehaviour
bool found = false;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform))
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
if (_wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling
continue;
@@ -1080,7 +1090,7 @@ public partial class PlayerCharacterController : MonoBehaviour
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform))
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
return true;
}
@@ -1106,7 +1116,7 @@ public partial class PlayerCharacterController : MonoBehaviour
bool found = false;
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform))
if (IsPlayerCollider(_wallProbe[i].collider))
continue;
if (_wallProbe[i].distance < best)
{