diff --git a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
index 48fe433..df29f00 100644
--- a/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
+++ b/Assets/Scripts/Components/Controller/PlayerCharacterController.cs
@@ -151,6 +151,14 @@ public class PlayerCharacterController : MonoBehaviour
///
public bool ControlEnabled { get; set; } = true;
+ ///
+ /// When false, gravity, ground-stick and slope-sliding are suspended and the vertical velocity is
+ /// held at rest, so the avatar hovers at its spawn position instead of settling. Set by the
+ /// GameModeManager during the pre-Hide loading barrier (alongside ) and
+ /// restored when the Hide phase begins.
+ ///
+ public bool GravityEnabled { get; set; } = true;
+
///
/// When set, the body faces this world-space yaw (degrees) every frame instead of turning toward its
/// movement direction. First-person sets it so the character always faces where the camera looks;
@@ -418,20 +426,30 @@ public class PlayerCharacterController : MonoBehaviour
// (also covers balancing on a sharp box edge).
float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up);
bool onSteepSlope = slideDownSteepSlopes
+ && GravityEnabled
&& _controller.isGrounded
&& slopeAngle > _controller.slopeLimit + 0.01f;
- if (_controller.isGrounded)
+ if (!GravityEnabled)
{
- if (_verticalVelocity < 0f)
- _verticalVelocity = groundedStickVelocity;
-
- // No jumping off a slope too steep to stand on.
- if (jumpPressed && !onSteepSlope)
- _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
+ // Loading barrier freeze: no gravity or ground-stick, hold vertical velocity at rest so the
+ // avatar keeps its spawn position (movement input is already cut via ControlEnabled).
+ _verticalVelocity = 0f;
}
+ else
+ {
+ if (_controller.isGrounded)
+ {
+ if (_verticalVelocity < 0f)
+ _verticalVelocity = groundedStickVelocity;
- _verticalVelocity += gravity * Time.deltaTime;
+ // No jumping off a slope too steep to stand on.
+ if (jumpPressed && !onSteepSlope)
+ _verticalVelocity = Mathf.Sqrt(jumpHeight * -2f * gravity);
+ }
+
+ _verticalVelocity += gravity * Time.deltaTime;
+ }
Vector3 slideVelocity = Vector3.zero;
if (onSteepSlope)
diff --git a/Assets/Scripts/GameMode/GameModeManager.cs b/Assets/Scripts/GameMode/GameModeManager.cs
index e225ed3..33e76dd 100644
--- a/Assets/Scripts/GameMode/GameModeManager.cs
+++ b/Assets/Scripts/GameMode/GameModeManager.cs
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using UnityEngine;
using Unity.Cinemachine;
+using Chameleon.Components;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
@@ -329,14 +330,24 @@ public class GameModeManager : MonoBehaviour
// without a camera jump (exactly like SetPaused's resume - the mode was never Exited).
if (MovementGated())
{
- if (player) player.ControlEnabled = false;
+ if (player)
+ {
+ player.ControlEnabled = false;
+ player.GravityEnabled = false; // hover in place; don't settle to the floor while loading
+ }
if (painter) painter.PaintingEnabled = false;
+ // Hide the own body so the frozen avatar doesn't hang in the half-loaded world. Re-asserted every
+ // frame (not once) because a lobby->match respawn rebinds `player` mid-load, so the fresh avatar
+ // must be hidden too.
+ SetOwnerBodyHidden(true);
_loadFrozen = true;
return;
}
if (_loadFrozen)
{
_loadFrozen = false;
+ if (player) player.GravityEnabled = true; // Enter() doesn't touch gravity, so restore it here
+ SetOwnerBodyHidden(false); // show the body, then let Enter() re-hide it if first-person
ActiveMode?.Enter();
}
@@ -382,6 +393,35 @@ public class GameModeManager : MonoBehaviour
match.LocalSubPhase == MatchManager.MatchSubPhase.Loading;
}
+ // Cached body/fur renderers of the currently bound local avatar, re-resolved only when `player` changes
+ // (a lobby->match respawn rebinds mid-load), so the per-frame Loading hide doesn't walk the hierarchy each tick.
+ private PlayerCharacterController _bodyHideOwner;
+ private SkinnedMeshRenderer[] _bodyHideRenderers;
+ private FurShellRenderer[] _bodyHideFur;
+
+ /// Hides/shows the LOCAL player's own body (skinned mesh + fur) during the pre-match loading freeze,
+ /// so the owner doesn't watch its frozen avatar hang in the half-loaded world. Local-only (renderer.enabled
+ /// is not replicated, so remotes are unaffected - their view is driven by ).
+ /// On show the freeze release re-enters the active mode, so first-person re-applies its own-body self-hide.
+ private void SetOwnerBodyHidden(bool hidden)
+ {
+ if (player != _bodyHideOwner)
+ {
+ _bodyHideOwner = player;
+ _bodyHideRenderers = player ? player.GetComponentsInChildren(true) : null;
+ _bodyHideFur = player ? player.GetComponentsInChildren(true) : null;
+ }
+
+ if (_bodyHideRenderers != null)
+ foreach (var r in _bodyHideRenderers)
+ if (r) r.enabled = !hidden;
+ // Fur draws via a GPU-instanced camera hook, not a Renderer, so the skinned-mesh toggle misses it;
+ // disabling the component unsubscribes that hook (mirrors FirstPersonController.SetMeshHidden).
+ if (_bodyHideFur != null)
+ foreach (var f in _bodyHideFur)
+ if (f) f.enabled = !hidden;
+ }
+
/// First non-Paint mode index at or after (wrapping once through the
/// cycle); -1 if the set has no non-paint mode (a degenerate paint-only configuration).
private int FirstNonPaintFrom(int start)
diff --git a/Assets/Scripts/Network/MatchManager.cs b/Assets/Scripts/Network/MatchManager.cs
index e7e3eb4..47b8327 100644
--- a/Assets/Scripts/Network/MatchManager.cs
+++ b/Assets/Scripts/Network/MatchManager.cs
@@ -605,6 +605,13 @@ public class MatchManager : MonoBehaviour
private IEnumerator MatchSequence(HashSet hunterIds)
{
+ // Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the
+ // map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap.
+ // Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the
+ // readiness barrier below (BeginLoadingBarrierServer re-asserts Loading) holds that hold until every
+ // client has the map + all match avatars ready, then Hide releases it.
+ SetPhaseServer(MatchSubPhase.Loading, 0f);
+
yield return SwapScenes(lobbySceneName, matchSceneName);
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);