Docs: reduce over-documentation across gameplay scripts
Trim restated narration, dead commented-out code, and oversized class headers; keep all why-comments (authority, timing, edge cases), tooltips, and serialization caveats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
882d30797e
commit
066e308cde
21 files changed
+478
-902
No files matched your search
@@ -198,7 +198,6 @@ public class ConcealmentColliderBaker : MonoBehaviour
|
||||
int made = 0;
|
||||
for (int ci = 0; ci < clusters.Count; ci++)
|
||||
{
|
||||
// Unique verts of this cluster.
|
||||
var pset = new HashSet<int>();
|
||||
var pts = new List<Vector3>();
|
||||
List<int> cl = clusters[ci];
|
||||
|
||||
+11
-23
@@ -2,32 +2,20 @@ using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Single-finger procedural pose + IK solver for a LOCAL +Y bone chain. Flexion (curl)
|
||||
/// is rotation around each joint's local hinge axis (default local -X, the palm-ward
|
||||
/// flex direction for the WhiteHand rig); negative per-joint limits allow a little
|
||||
/// back-of-hand (upward) bend when an IK target pulls there. Spread/abduction is around
|
||||
/// local Z on the base joint only and is also IK-solved toward the target for slight
|
||||
/// sideways reach. Works for any chain length: the thumb (2 rotating joints) and the long
|
||||
/// fingers (3) use identical code.
|
||||
/// Single-finger procedural pose + IK solver for a LOCAL +Y bone chain. Flexion (curl) rotates around
|
||||
/// each joint's local hinge axis (default local -X = palm-ward on the WhiteHand rig); spread/abduction
|
||||
/// is around local Z on the base joint only. Any chain length (thumb and long fingers share the code).
|
||||
///
|
||||
/// Two drive modes, blended per frame:
|
||||
/// 1. Procedural curl. <see cref="curl"/> (0..1) lerps every joint from its rest angle
|
||||
/// (0) toward its per-joint <see cref="maxCurl"/> (closed fist). This is written first
|
||||
/// every frame and is the SEED pose for the IK pass.
|
||||
/// 2. IK reach. When a <see cref="target"/> is assigned and <see cref="ikWeight"/> > 0,
|
||||
/// CCD bends the chain (seeded from the curl pose) so the fingertip reaches the target,
|
||||
/// constraining every joint to its hinge axis and angle limits mid-sweep. The final
|
||||
/// per-joint pose is Slerp(seedCurl, solvedIK, ikWeight) so curl and IK never fight and
|
||||
/// nothing accumulates frame-to-frame.
|
||||
/// 1. Procedural curl: <see cref="curl"/> (0..1) lerps every joint from rest toward its per-joint
|
||||
/// <see cref="maxCurl"/> - written first each frame, and the SEED pose for the IK pass.
|
||||
/// 2. IK reach: with a <see cref="target"/> and <see cref="ikWeight"/> > 0, CCD bends the chain from
|
||||
/// that seed, hinge/limit-constrained mid-sweep; the final pose is Slerp(seed, solved, ikWeight),
|
||||
/// so curl and IK never fight and nothing accumulates frame-to-frame.
|
||||
///
|
||||
/// All curl math is REST-RELATIVE: the captured rest localRotation is the 0-degree datum,
|
||||
/// so the rig's modeled fan/opposition offsets (e.g. thumb's ~55 deg local-Z rest) are
|
||||
/// preserved instead of being snapped to identity. Positive curl closes the fist for every
|
||||
/// digit on this rig.
|
||||
///
|
||||
/// Added at runtime onto the finger-root GameObject by <see cref="HandIK"/>, or wired by
|
||||
/// hand via <see cref="Setup"/>. By default the owning <see cref="HandIK"/> drives the
|
||||
/// solve once per frame; set <see cref="SolveInLateUpdate"/> if using this component alone.
|
||||
/// All curl math is REST-RELATIVE (the captured rest localRotation is the 0-degree datum), preserving
|
||||
/// the rig's modeled fan/opposition offsets. Added at runtime by <see cref="HandIK"/>, or wired by hand
|
||||
/// via <see cref="Setup"/>; set <see cref="SolveInLateUpdate"/> only when using this component alone.
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[DisallowMultipleComponent]
|
||||
|
||||
@@ -30,9 +30,6 @@ public class GameModeManager : MonoBehaviour
|
||||
[Tooltip("First-person camera. Empty = found automatically. Absent = First Person is left out of the cycle.")]
|
||||
public FirstPersonController firstPerson;
|
||||
|
||||
//[Tooltip("Insect / surface-walk controller. Empty = found automatically.")]
|
||||
//public InsectController insect;
|
||||
|
||||
[Header("Switching")]
|
||||
[Tooltip("Key that cycles to the next mode.")]
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
@@ -49,7 +46,6 @@ public class GameModeManager : MonoBehaviour
|
||||
public MeshPainter Painter => painter;
|
||||
public FreeCamController FreeCam => freeCam;
|
||||
public FirstPersonController FirstPerson => firstPerson;
|
||||
//public InsectController Insect => insect;
|
||||
|
||||
/// <summary>Raised after a mode switch finishes (the new mode's Enter has already run).
|
||||
/// Argument = the now-active mode. Used by UI (e.g. the paint HUD) to follow the mode.</summary>
|
||||
@@ -81,7 +77,6 @@ public class GameModeManager : MonoBehaviour
|
||||
// Include inactive so a freecam rig that starts disabled is still found and wired.
|
||||
if (!freeCam) freeCam = FindFirstObjectByType<FreeCamController>(FindObjectsInactive.Include);
|
||||
if (!firstPerson) firstPerson = FindFirstObjectByType<FirstPersonController>(FindObjectsInactive.Include);
|
||||
//if (!insect) insect = FindFirstObjectByType<InsectController>();
|
||||
|
||||
BuildModes();
|
||||
}
|
||||
@@ -104,22 +99,19 @@ public class GameModeManager : MonoBehaviour
|
||||
switch (role)
|
||||
{
|
||||
case PlayerRole.Hunter:
|
||||
// Hunter: first person + paint. Index 0 (First Person, if its rig exists) is the start mode.
|
||||
if (firstPerson) _modes.Add(new FirstPersonMode());
|
||||
_modes.Add(new PaintMode());
|
||||
break;
|
||||
|
||||
case PlayerRole.Hider:
|
||||
// Hider: third person (player control) + paint + free cam. Index 0 (Player Control) is the start.
|
||||
if (player) _modes.Add(new PlayerControlMode());
|
||||
_modes.Add(new PaintMode());
|
||||
if (freeCam) _modes.Add(new FreeCamMode());
|
||||
break;
|
||||
|
||||
default:
|
||||
// No role: legacy full set, gated on whichever rigs exist. Player Control only joins when a
|
||||
// gravity player is present (player-less pre-spawn stays in Paint, not a dead frozen mode);
|
||||
// Free Cam / First Person only when their rigs exist, so non-networked scenes are unchanged.
|
||||
// No role: legacy full set, gated on whichever rigs exist (a player-less pre-spawn
|
||||
// stays in Paint rather than a dead frozen Player Control).
|
||||
if (player) _modes.Add(new PlayerControlMode());
|
||||
_modes.Add(new PaintMode());
|
||||
if (freeCam) _modes.Add(new FreeCamMode());
|
||||
@@ -181,15 +173,12 @@ public class GameModeManager : MonoBehaviour
|
||||
|
||||
player = localPlayer;
|
||||
|
||||
// The painter lives on the player (per-avatar), so bind the local one - UNCONDITIONALLY. On a
|
||||
// lobby->match respawn the previous avatar is despawned and the role variant spawned in the same
|
||||
// server frame; the old painter is Destroy-pending (still non-null) when this runs, so a
|
||||
// `if (!painter)` guard would keep the stale reference and the new avatar's painter would never be
|
||||
// mode-toggled - it would paint in every mode. Always re-point to the fresh avatar.
|
||||
// Re-point UNCONDITIONALLY: on a lobby->match respawn the old painter is Destroy-pending (still
|
||||
// non-null), so a `if (!painter)` guard would keep the stale reference and the new avatar's
|
||||
// painter would never be mode-toggled.
|
||||
painter = localPlayer.GetComponentInChildren<MeshPainter>(true);
|
||||
PainterChanged?.Invoke(painter);
|
||||
|
||||
// Point the cameras at the avatar's follow target.
|
||||
Transform camTarget = localPlayer.transform.Find("CameraTarget");
|
||||
if (cameraInput)
|
||||
{
|
||||
@@ -198,9 +187,8 @@ public class GameModeManager : MonoBehaviour
|
||||
{
|
||||
vcam.Follow = camTarget;
|
||||
vcam.LookAt = camTarget;
|
||||
// Follow is now the new avatar's target, so its lossyScale is live - start the camera at this
|
||||
// avatar's scaled default zoom (Hider pulls in, Hunter pushes out) instead of inheriting the
|
||||
// previous avatar's radius.
|
||||
// Follow (and its lossyScale) is live now - start at this avatar's scaled default zoom
|
||||
// instead of inheriting the previous avatar's radius.
|
||||
cameraInput.ResetZoom();
|
||||
}
|
||||
}
|
||||
@@ -212,17 +200,14 @@ public class GameModeManager : MonoBehaviour
|
||||
ActiveMode?.Exit();
|
||||
_activeIndex = -1;
|
||||
|
||||
// Lobby/neutral avatar (base Player prefab) -> full mode set; match avatar (Hunter/Hider variant)
|
||||
// -> only its role's modes. Scope is baked per prefab variant, so it follows the spawned avatar
|
||||
// with no replicated-phase timing race. (Mid-match spectators take a separate, empty prefab and
|
||||
// bind via BindSpectator, not here.)
|
||||
// Lobby avatar -> full set; match variant -> its role's modes. Scope is baked per prefab
|
||||
// variant, so it follows the spawned avatar with no replicated-phase timing race.
|
||||
if (setup && setup.ScopedModes)
|
||||
BuildModes(setup.Role);
|
||||
else
|
||||
BuildModes(null);
|
||||
|
||||
// Role lists are ordered so index 0 is the start mode (Hunter -> First Person, Hider -> Player
|
||||
// Control); the full lobby set starts at Player Control too.
|
||||
// Index 0 is each set's intended start mode.
|
||||
SwitchTo(0);
|
||||
}
|
||||
|
||||
@@ -246,9 +231,8 @@ public class GameModeManager : MonoBehaviour
|
||||
painter = null;
|
||||
PainterChanged?.Invoke(null);
|
||||
|
||||
// No SwitchTo runs here (the cycle is now empty), so notify mode-following UI explicitly that
|
||||
// there is no active mode - otherwise the paint HUD, which only hides via ModeChanged, would stay
|
||||
// open back in Main after a shutdown that happened while PaintMode was active.
|
||||
// The cycle is empty (no SwitchTo runs), so notify mode-following UI explicitly - otherwise the
|
||||
// paint HUD (hides only via ModeChanged) would stay open after a shutdown during PaintMode.
|
||||
ModeChanged?.Invoke(null);
|
||||
|
||||
if (cameraInput)
|
||||
@@ -260,18 +244,11 @@ public class GameModeManager : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses or resumes local gameplay for a full-screen modal overlay (the online session panel, opened
|
||||
/// with Esc in the lobby/map). Pausing frees the OS cursor and suppresses ALL local input - movement,
|
||||
/// painting, camera look and the mode-switch key - WITHOUT tearing down the mode cycle, so resuming
|
||||
/// re-enters the exact mode that was active and restores its camera/cursor state.
|
||||
///
|
||||
/// Crucially it does NOT Exit the active mode while pausing: the First-Person / Free-Cam rigs keep their
|
||||
/// CinemachineCamera at its live priority, so the view stays frozen exactly where it is instead of
|
||||
/// blending back to the orbit camera (the camera "jump" when the menu opened). Pausing therefore only
|
||||
/// disables each rig's per-frame look input - dropping the controller's <c>enabled</c> freezes its look
|
||||
/// without touching its priority - and parks the orbit rig in Disabled so it stops re-grabbing the cursor
|
||||
/// every frame. Resuming re-runs the same mode's Enter(); because no priority was lowered, that mode's
|
||||
/// snap-to-current-view is a no-op, so there is no jump on close either. No-op if unchanged.
|
||||
/// Pauses/resumes local gameplay for a full-screen modal overlay (the Esc session panel). Suppresses
|
||||
/// ALL local input and frees the cursor WITHOUT tearing down the mode cycle, so resuming re-enters
|
||||
/// the exact mode that was active. Deliberately never Exits the active mode: the FP/free-cam vcam
|
||||
/// keeps its priority, so the view stays frozen instead of blending back to the orbit camera (no
|
||||
/// jump on open), and the resumed mode's snap-to-current-view is a no-op (no jump on close).
|
||||
/// </summary>
|
||||
public void SetPaused(bool paused)
|
||||
{
|
||||
@@ -283,12 +260,9 @@ public class GameModeManager : MonoBehaviour
|
||||
_resumeIndex = _activeIndex;
|
||||
_activeIndex = -1; // bookkeeping: no live mode while paused (Tick won't run)
|
||||
|
||||
// Freeze every input source but leave the active camera rig untouched (priority unchanged) so the
|
||||
// view stays put. Painting/movement run off their own flags; the FP/freecam rigs drive their own
|
||||
// CinemachineCamera each frame, so dropping their `enabled` freezes the look in place WITHOUT
|
||||
// lowering priority; the orbit rig re-asserts the cursor each frame from its LookMode, so a
|
||||
// Disabled look-mode keeps the cursor free every frame. At most one camera rig is ever enabled,
|
||||
// so disabling both here is safe regardless of which mode was active.
|
||||
// Freeze every input source without touching vcam priorities: dropping the FP/freecam
|
||||
// `enabled` freezes their look in place; a Disabled orbit look-mode stops the per-frame
|
||||
// cursor re-grab. At most one rig is ever enabled, so disabling both is safe.
|
||||
if (player) player.ControlEnabled = false;
|
||||
if (painter) painter.PaintingEnabled = false;
|
||||
if (firstPerson) firstPerson.enabled = false;
|
||||
@@ -309,9 +283,7 @@ public class GameModeManager : MonoBehaviour
|
||||
_resumeIndex = -1;
|
||||
if (resume >= 0 && resume < _modes.Count)
|
||||
{
|
||||
// Re-Enter the SAME mode (it was never Exited). The active rig still holds the current view,
|
||||
// so Enter()'s sync-to-current-view is a no-op -> no jump. Enter restores control/painting/
|
||||
// look + cursor and re-shows the HUD.
|
||||
// Re-Enter the SAME mode (never Exited): its sync-to-current-view is a no-op -> no jump.
|
||||
_activeIndex = resume;
|
||||
ActiveMode.Enter();
|
||||
ModeChanged?.Invoke(ActiveMode);
|
||||
@@ -326,10 +298,8 @@ public class GameModeManager : MonoBehaviour
|
||||
if (_paused) return;
|
||||
if (_modes.Count == 0) return;
|
||||
|
||||
// Match resolution (the end-of-match reveal window) is watch-only: painting is off. Force out of Paint
|
||||
// mode and keep the switch key from re-entering it. Local + owner by construction - this manager drives
|
||||
// only the local avatar's cycle; remotes have no modes. No restore needed: the match ends by despawning
|
||||
// and respawning the avatar, which rebuilds the cycle at index 0.
|
||||
// Resolution (the reveal window) is watch-only: force out of Paint and keep the switch key from
|
||||
// re-entering. No restore needed - the end-of-match respawn rebuilds the cycle at index 0.
|
||||
bool suppressPaint = PaintSuppressed();
|
||||
if (suppressPaint && ActiveMode is PaintMode)
|
||||
{
|
||||
@@ -388,7 +358,7 @@ public class GameModeManager : MonoBehaviour
|
||||
|
||||
private bool WasSwitchPressed()
|
||||
{
|
||||
if (TypingGuard.IsTyping) return false; // don't switch modes while typing in a text field
|
||||
if (TypingGuard.IsTyping) return false;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
return Keyboard.current != null && Keyboard.current[switchKey].wasPressedThisFrame;
|
||||
#else
|
||||
|
||||
@@ -3,22 +3,14 @@ using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-level IK manager for the WhiteHand rig. Auto-discovers the five fingers under
|
||||
/// <see cref="handRoot"/> (Thumb, IndexFinger, MiddleFinger, RingFinger, LittleFinger),
|
||||
/// walks each single-child bone spine to its *_end leaf, and creates/configures a
|
||||
/// <see cref="FingerIK"/> on every finger root with sensible per-joint curl limits (the
|
||||
/// thumb gets its own 2-joint range). Owns per-finger curl/target/weight and drives every
|
||||
/// finger's solve once per frame in LateUpdate (so FingerIK does not double-solve, and the
|
||||
/// override lands AFTER any Animator pose for the frame).
|
||||
/// <see cref="handRoot"/>, walks each single-child bone spine to its *_end leaf, and
|
||||
/// creates/configures a <see cref="FingerIK"/> per finger root (the thumb gets its own 2-joint
|
||||
/// range). Owns per-finger curl/target/weight and drives every finger's solve once per frame in
|
||||
/// LateUpdate (no double-solve; the override lands AFTER any Animator pose).
|
||||
///
|
||||
/// Runs in edit mode too ([ExecuteAlways]): an editor tick solves live so per-finger curl,
|
||||
/// the IK weight, and the per-finger target slots pose the hand WITHOUT entering Play, and
|
||||
/// dragging an assigned target in the scene view updates the reach live. The solve only re-runs
|
||||
/// when an input actually changed, so an idle hand never re-dirties the scene.
|
||||
///
|
||||
/// Minimal wiring: drop on WhiteHand (or Hand); leave handRoot empty to auto-find the 'Hand'
|
||||
/// node by name. Fingers auto-discover (in edit mode the first time, deferred to a safe point;
|
||||
/// in Play via discoverOnAwake). Right-click the component header for "Rediscover" (after rig
|
||||
/// edits) and "Reset To Rest".
|
||||
/// Runs in edit mode too ([ExecuteAlways]): an editor tick solves live (curl / weight / dragged
|
||||
/// targets) without entering Play, re-running only when an input changed so an idle hand never
|
||||
/// re-dirties the scene. Right-click the header for "Rediscover" (after rig edits) and "Reset To Rest".
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[DisallowMultipleComponent]
|
||||
|
||||
@@ -2,34 +2,21 @@ using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the world-space "hero cutout": occluder geometry that sits inside a cone running from the
|
||||
/// third-person camera (apex) to the player is clipped away, punching a clean see-through funnel toward
|
||||
/// the character. Every late frame this feeds two global shader properties (<c>_HeroCutoutTarget</c>,
|
||||
/// <c>_HeroCutoutParams</c>) that the <c>Custom/HeroCutoutLit</c> shader reads.
|
||||
/// Drives the world-space "hero cutout": occluder geometry inside a cone from the third-person camera
|
||||
/// (apex) to the player is clipped away, punching a see-through funnel toward the character. Each late
|
||||
/// frame this feeds two global shader properties (<c>_HeroCutoutTarget</c>, <c>_HeroCutoutParams</c>)
|
||||
/// that <c>Custom/HeroCutoutLit</c> reads.
|
||||
///
|
||||
/// Arming is deliberately simple: a single ray is cast FROM the camera TOWARD the player. If it hits
|
||||
/// world geometry before reaching the player, the cut is armed; with a clear line of sight the
|
||||
/// occluders stay solid. There is no multi-sample / silhouette estimation - one ray, one answer.
|
||||
/// Arming is deliberately simple: ONE ray from the camera toward the player - blocked = armed, clear
|
||||
/// view = occluders stay solid. The cut volume lives entirely in the shader: everything between camera
|
||||
/// and player (within the cone) is cut, geometry behind the player stays solid, and the far end fades
|
||||
/// across a soft depth band (<see cref="depthFeather"/>) instead of a hard plane. The cone radius grows
|
||||
/// with camera distance, so the hole keeps a constant on-screen size at every occluder depth.
|
||||
///
|
||||
/// The cut volume is defined entirely in the shader as a cone along the camera->player axis: everything
|
||||
/// BETWEEN camera and player (within the cone) is cut; geometry well behind the player stays solid - so a
|
||||
/// wall the player stands in front of renders completely normally. The far end fades out across a soft
|
||||
/// depth band (<see cref="depthFeather"/>) straddling the player rather than snapping off at a hard
|
||||
/// plane, so an occluder running past the player fades just behind them with no hard cut line. The cone's
|
||||
/// radius grows with distance from the camera, so the hole keeps a constant on-screen size regardless of
|
||||
/// occluder depth.
|
||||
///
|
||||
/// The effect is only active while the third-person orbit <see cref="CinemachineCamera"/> is the live
|
||||
/// Cinemachine camera (Player Control / Paint mode). In first-person, free-cam or while a menu is open
|
||||
/// a different vcam is live and the cutout disarms. Disarming clears the global "enabled" flag (also
|
||||
/// the zero-initialised default), so any scene without this driver - or any frame with a clear view -
|
||||
/// leaves cutout materials looking exactly like URP/Lit.
|
||||
///
|
||||
/// This replaces the camera pulling in toward the player (Cinemachine Deoccluder): instead of closing
|
||||
/// the distance to dodge an obstacle, the camera holds its orbit distance and the obstacle is punched
|
||||
/// see-through.
|
||||
///
|
||||
/// Attach to a persistent scene object (e.g. the GameModeManager host).
|
||||
/// Active only while the third-person orbit vcam is the live Cinemachine camera; disarming clears the
|
||||
/// global flag (also the zero-initialised default), so scenes without this driver render cutout
|
||||
/// materials as plain URP/Lit. Replaces the Cinemachine Deoccluder pull-in: the camera holds its orbit
|
||||
/// distance and the obstacle goes see-through. Attach to a persistent scene object.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class HeroCutout : MonoBehaviour
|
||||
|
||||
+54
-114
@@ -7,52 +7,22 @@ using UnityEngine.SceneManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Server-authoritative match flow. Lives on the persistent NetworkManager object in the boot scene
|
||||
/// (Main), which stays loaded the whole session (NetworkManager, cameras, HUD). The gameplay scenes
|
||||
/// (Lobby, Test Map) are loaded ADDITIVELY through NGO's scene manager so Main is never torn down.
|
||||
/// (Main); the gameplay scenes (Lobby, Test Map) are loaded ADDITIVELY through NGO's scene manager so
|
||||
/// Main (cameras/HUD/managers) is never torn down.
|
||||
///
|
||||
/// Two design pillars make the persistent-Main + additive-gameplay model actually work:
|
||||
/// Two design pillars make this model work:
|
||||
/// 1. Clients sync ADDITIVELY (<see cref="NetworkSceneManager.SetClientSynchronizationMode"/>). The NGO
|
||||
/// default (Single) would unload the connecting client's own Main and crash the player-object scene
|
||||
/// migration; additive keeps the client's Main and layers the server's scenes on top. Must be set on
|
||||
/// the server before any client connects (auto-propagated to clients).
|
||||
/// 2. Players are spawned by THIS manager, not NGO auto-spawn - that would spawn into whatever scene is
|
||||
/// active on connect (for the host: floor-less Main, while the additive Lobby load is in flight).
|
||||
/// <see cref="NetworkConfig.PlayerPrefab"/> is nulled and avatars are spawned manually once the
|
||||
/// gameplay scene is loaded. Main holds zero network objects.
|
||||
///
|
||||
/// 1. Clients sync ADDITIVELY (<see cref="NetworkSceneManager.SetClientSynchronizationMode"/> =
|
||||
/// <see cref="LoadSceneMode.Additive"/>). The NGO default (Single) would unload every scene the
|
||||
/// connecting client already has - including its own Main (cameras/HUD/managers) - and reload the
|
||||
/// server's active scene in single mode, which also crashes the player-object scene migration
|
||||
/// (MoveGameObjectToScene "Destination scene is not valid"). Additive keeps the client's Main and
|
||||
/// layers the server's scenes on top. Must be set on the server before any client connects; it is
|
||||
/// auto-propagated to clients via the synchronize event.
|
||||
///
|
||||
/// 2. Players are spawned by THIS manager, not by NGO auto-spawn. NGO would auto-spawn the player the
|
||||
/// instant a peer connects, into whatever scene is active right then - for the host that is Main
|
||||
/// (the additive Lobby load is still in flight), which has no floor, so the host falls into the
|
||||
/// void. We disable auto-spawn (null out <see cref="NetworkConfig.PlayerPrefab"/>) and instead
|
||||
/// spawn the neutral Player prefab into the gameplay scene only once it is loaded, so avatars always
|
||||
/// land on a floor. Main therefore holds zero network objects.
|
||||
///
|
||||
/// Flow: server start -> additive sync mode set -> Lobby loaded additively -> Player spawned for every
|
||||
/// connected peer (full mode set, including First Person). Late joiners spawn on connect into the
|
||||
/// already-loaded scene. The host walks into the Start Match trigger -> <see cref="StartMatch"/>: a
|
||||
/// configurable number of random connected clients are rolled Hunter (<see cref="hunterCount"/>,
|
||||
/// default 1), the rest Hider; the Lobby is unloaded and Test Map loaded
|
||||
/// (strictly serialized - an NGO constraint, only one scene op in flight); then every player object is
|
||||
/// despawned and respawned as its role's prefab variant.
|
||||
///
|
||||
/// Match-control AUTHORITY: the client allowed to start (walk into the trigger) and end the match.
|
||||
/// A host has its own player, so the host is the authority. A DEDICATED server (StartServer, no local
|
||||
/// player) hands authority to the FIRST client that connects, so a server nobody plays on is still
|
||||
/// controllable. The id is server-local; the designated remote client is told via a named message so it
|
||||
/// can ask the server (which validates the sender) to end. If the authority leaves, it is reassigned to
|
||||
/// another connected client.
|
||||
///
|
||||
/// End / join / teardown:
|
||||
/// - The match authority ends the match via <see cref="RequestEndMatch"/> -> <see cref="EndMatch"/>
|
||||
/// (driven by the online session panel's "Back to Lobby" button - Esc opens that panel rather than
|
||||
/// ending directly). Test Map is swapped back to Lobby and everyone is respawned as the neutral Player.
|
||||
/// The host/server ends directly; a remote authority client requests it over a named message that only
|
||||
/// the authority's id is honored for. A non-authority client's panel button is a plain disconnect.
|
||||
/// - A client that joins MID-MATCH spawns as a SPECTATOR: a dedicated EMPTY prefab (no body/collider)
|
||||
/// bound to free-cam-only, so it flies around the running map and watches but does not play. It
|
||||
/// respawns as a neutral lobby Player when the match ends.
|
||||
/// - On shutdown / disconnect (<see cref="ReturnToMainOnly"/>) every gameplay scene is unloaded so only
|
||||
/// Main is left loaded & active. NGO's Dispose does NOT unload additively-synced scenes, so we do it.
|
||||
/// Match-control AUTHORITY (may start/end the match): the host; on a DEDICATED server the first client
|
||||
/// to connect (told via named message, validated server-side on use, reassigned if it leaves).
|
||||
/// Mid-match joiners spawn as empty free-cam SPECTATOR avatars until the match ends.
|
||||
///
|
||||
/// Not a NetworkBehaviour by design: every state change rides existing NGO replication (additive scene
|
||||
/// sync + player-object spawn/despawn), so there is no in-scene NetworkObject hash matching or custom
|
||||
@@ -165,10 +135,8 @@ public class MatchManager : MonoBehaviour
|
||||
/// <summary>Server-local phase guard. Not replicated - see class summary.</summary>
|
||||
public MatchPhase Phase { get; private set; } = MatchPhase.Lobby;
|
||||
|
||||
// --- Replicated sub-phase mirror (every machine, set by the server's broadcast; the server sets its
|
||||
// own copy directly in SetPhaseServer). Cosmetic countdown runs off the local clock from the broadcast
|
||||
// duration; the authoritative transition is always server-driven, so any drift self-corrects on the
|
||||
// next phase message. ---
|
||||
// Replicated sub-phase mirror (set by the server's broadcast; the server sets its own copy directly).
|
||||
// The countdown is cosmetic/local; the authoritative transition is server-driven, so drift self-corrects.
|
||||
private MatchSubPhase _subPhase = MatchSubPhase.None;
|
||||
private float _phaseEndTime; // Time.time when the current sub-phase ends (for the HUD countdown)
|
||||
private float _phaseDuration;
|
||||
@@ -195,25 +163,21 @@ public class MatchManager : MonoBehaviour
|
||||
private bool _sceneReady; // server-only: a gameplay scene (lobby/map) is loaded & active, so it is safe to spawn avatars
|
||||
private bool _cleanedUp; // guards the shutdown scene-cleanup so a host (server+client stop) doesn't run it twice
|
||||
|
||||
// Role pick uses its OWN generator, seeded once from real entropy. UnityEngine.Random is a single
|
||||
// engine-wide generator whose state at match-start can sit at the same point every fresh play
|
||||
// session (nothing random consumed before it), which made the "random" hunter land on the same
|
||||
// client every time. An independent System.Random decouples the pick from that shared global state.
|
||||
// Own generator, seeded from real entropy: UnityEngine.Random's engine-wide state sits at the same
|
||||
// point every fresh session (nothing consumed before match start), which made the "random" hunter
|
||||
// land on the same client every time.
|
||||
private readonly System.Random _roleRng = new System.Random(System.Guid.NewGuid().GetHashCode());
|
||||
|
||||
// Painting carry across an avatar respawn (scene switch). Per-machine: the LOCAL owner's painting,
|
||||
// parked here by its despawning PlayerPaintSync (that component is gone before the replacement spawns)
|
||||
// and consumed once by the next owner avatar. This manager is persistent in Main, so it bridges the
|
||||
// despawn->spawn gap. Cleared on teardown (ReturnToMainOnly) so a fresh session never inherits a
|
||||
// previous one's painting; a normal respawn keeps Main loaded, so the parked copy survives it.
|
||||
// The LOCAL owner's painting, parked across an avatar respawn (scene switch) by its despawning
|
||||
// PlayerPaintSync and consumed once by the next owner avatar - this manager is persistent, so it
|
||||
// bridges the despawn->spawn gap. Cleared on teardown so a fresh session never inherits it.
|
||||
private byte[] _carriedPaint;
|
||||
|
||||
// Match-control authority. Server-local: which client may start/end the match. Host -> the host
|
||||
// itself (set at server start); dedicated server -> the first client to connect (set on connect).
|
||||
// Match-control authority. Server-local: which client may start/end the match. Host -> itself;
|
||||
// dedicated server -> the first client to connect.
|
||||
private bool _hasAuthorityClient;
|
||||
private ulong _authorityClientId;
|
||||
// Client-side mirror, set by the grant message below: this machine's local client is the authority,
|
||||
// so it reads Esc and asks the server to end the match.
|
||||
// Client-side mirror of the grant message: this machine may ask the server to end the match.
|
||||
private bool _isLocalAuthority;
|
||||
|
||||
// Named messages (CustomMessagingManager). One bridges server->authority-client ("you have authority"),
|
||||
@@ -239,17 +203,15 @@ public class MatchManager : MonoBehaviour
|
||||
_net = GetComponent<NetworkManager>();
|
||||
if (!_net) _net = FindFirstObjectByType<NetworkManager>();
|
||||
|
||||
// Disable NGO auto-spawn (decided purely by PlayerPrefab != null on both host and remote paths).
|
||||
// We keep the reference and spawn manually once a floored scene exists. Runtime-only mutation -
|
||||
// the scene asset still carries PlayerPrefab, so there is no asset diff.
|
||||
// Disable NGO auto-spawn (decided purely by PlayerPrefab != null); keep the reference and spawn
|
||||
// manually once a floored scene exists. Runtime-only mutation - no asset diff.
|
||||
if (_net != null)
|
||||
{
|
||||
_playerPrefab = _net.NetworkConfig.PlayerPrefab;
|
||||
_net.NetworkConfig.PlayerPrefab = null;
|
||||
}
|
||||
|
||||
// Placeholder on-screen match HUD (phase + countdown + hunter crosshair). Added at runtime so it
|
||||
// needs no scene/prefab authoring; trivially swapped for a proper Canvas later. Persistent like this.
|
||||
// Placeholder on-screen match HUD, added at runtime so it needs no scene/prefab authoring.
|
||||
if (!GetComponent<MatchHud>()) gameObject.AddComponent<MatchHud>();
|
||||
}
|
||||
|
||||
@@ -298,8 +260,7 @@ public class MatchManager : MonoBehaviour
|
||||
Phase = MatchPhase.Lobby;
|
||||
_sceneReady = false;
|
||||
|
||||
// Match-control authority. A host has its own player, so it is the authority right away. A
|
||||
// dedicated server has no local player; the first client to connect claims it (OnClientConnected).
|
||||
// Host -> authority right away; dedicated server -> the first client to connect claims it.
|
||||
_hasAuthorityClient = false;
|
||||
if (_net.IsHost) { _authorityClientId = _net.LocalClientId; _hasAuthorityClient = true; }
|
||||
|
||||
@@ -327,8 +288,7 @@ public class MatchManager : MonoBehaviour
|
||||
_isLocalAuthority = false;
|
||||
HookSceneManager();
|
||||
|
||||
// Listen for the server granting this machine match authority (dedicated-server case) and for the
|
||||
// sub-phase broadcasts. Registered here (client start) so the handlers exist before the server sends.
|
||||
// Registered at client start so the handlers exist before the server sends.
|
||||
if (_net.CustomMessagingManager != null)
|
||||
{
|
||||
_net.CustomMessagingManager.RegisterNamedMessageHandler(MsgAuthorityGranted, OnAuthorityGrantedMessage);
|
||||
@@ -391,22 +351,19 @@ public class MatchManager : MonoBehaviour
|
||||
yield return SceneOp(() => _net.SceneManager.LoadScene(lobbySceneName, LoadSceneMode.Additive),
|
||||
SceneEventType.LoadEventCompleted, lobbySceneName);
|
||||
|
||||
// Lobby is loaded and active on the server: now it is safe to spawn the already-connected
|
||||
// peers (the host, plus anyone who connected while the load was in flight).
|
||||
// Scene is up - safe to spawn the already-connected peers (host + anyone who joined mid-load).
|
||||
_sceneReady = true;
|
||||
foreach (ulong id in new List<ulong>(_net.ConnectedClientsIds)) SpawnFor(id, _playerPrefab, lobbySpawnCenter);
|
||||
}
|
||||
|
||||
// Server-side: a peer finished connecting. If a gameplay scene is already up, spawn it now;
|
||||
// otherwise LoadLobbyRoutine (lobby) or RespawnAll (mid-transition) covers it (the PlayerObject
|
||||
// guard in SpawnFor prevents a double spawn).
|
||||
// Server-side: a peer finished connecting. If a gameplay scene is already up, spawn it now; otherwise
|
||||
// the load/respawn routine covers it (the PlayerObject guard in SpawnFor prevents a double spawn).
|
||||
private void OnClientConnected(ulong clientId)
|
||||
{
|
||||
if (_net == null || !_net.IsServer) return;
|
||||
|
||||
// Dedicated-server case: the first client to connect becomes the match authority (a host already
|
||||
// claimed it at server start, so this only fires for a server with no local player). Tell the
|
||||
// client over a named message so it reads Esc locally; the trigger picks it up server-side.
|
||||
// claimed it at server start). Told via named message so it reads Esc locally.
|
||||
if (!_hasAuthorityClient)
|
||||
{
|
||||
_authorityClientId = clientId;
|
||||
@@ -418,9 +375,8 @@ public class MatchManager : MonoBehaviour
|
||||
// During a Lobby<->Test Map swap the active scene briefly falls back to Main (no floor); defer
|
||||
// and let the respawn loop spawn this joiner once the target scene is loaded.
|
||||
if (!_sceneReady || Phase == MatchPhase.Transitioning) return;
|
||||
// Mid-match joiner: spawn an empty SPECTATOR avatar (free-cam only) into the running map. They can
|
||||
// fly around and watch but don't play. When the host ends the match, EndMatchSequence's respawn
|
||||
// loop covers every connected client, so they turn into a neutral lobby Player with everyone else.
|
||||
// Mid-match joiner: empty SPECTATOR avatar (free-cam only); the end-of-match respawn loop turns
|
||||
// it into a neutral lobby Player with everyone else.
|
||||
if (Phase == MatchPhase.InMatch)
|
||||
{
|
||||
SpawnFor(clientId, spectatorPrefab, matchSpawnCenter);
|
||||
@@ -431,9 +387,8 @@ public class MatchManager : MonoBehaviour
|
||||
SpawnFor(clientId, _playerPrefab, lobbySpawnCenter);
|
||||
}
|
||||
|
||||
// Server-side: if the match authority leaves, hand authority to another connected client (lowest
|
||||
// remaining id) so a dedicated-server match stays controllable. No-op in the host case (the host
|
||||
// holds authority and only "leaves" on shutdown, which teardown handles).
|
||||
// Server-side: if the match authority leaves, hand authority to another connected client so a
|
||||
// dedicated-server match stays controllable.
|
||||
private void OnClientDisconnected(ulong clientId)
|
||||
{
|
||||
if (_net == null || !_net.IsServer) return;
|
||||
@@ -489,9 +444,8 @@ public class MatchManager : MonoBehaviour
|
||||
|
||||
Phase = MatchPhase.Transitioning;
|
||||
|
||||
// Roll N distinct random Hunters (clamped to the player count); everyone else is a Hider.
|
||||
// hunterCount 0 -> all Hiders, >= player count -> all Hunters. Partial Fisher-Yates: swap a
|
||||
// random not-yet-picked id into each leading slot, then take that slot as a Hunter.
|
||||
// Roll N distinct random Hunters via partial Fisher-Yates (swap a random not-yet-picked id into
|
||||
// each leading slot); hunterCount 0 -> all Hiders, >= player count -> all Hunters.
|
||||
int wanted = Mathf.Clamp(hunterCount, 0, ids.Count);
|
||||
var hunterIds = new HashSet<ulong>();
|
||||
for (int i = 0; i < wanted; i++)
|
||||
@@ -511,19 +465,16 @@ public class MatchManager : MonoBehaviour
|
||||
{
|
||||
var sm = _net.SceneManager;
|
||||
|
||||
// 1. Unload the lobby (strictly serialized: one scene op in flight at a time).
|
||||
// Strictly serialized: NGO allows only one scene op in flight at a time.
|
||||
Scene lobby = SceneManager.GetSceneByName(lobbySceneName);
|
||||
if (lobby.IsValid() && lobby.isLoaded)
|
||||
yield return SceneOp(() => sm.UnloadScene(lobby), SceneEventType.UnloadEventCompleted, lobbySceneName);
|
||||
|
||||
// 2. Load the match map.
|
||||
yield return SceneOp(() => sm.LoadScene(matchSceneName, LoadSceneMode.Additive),
|
||||
SceneEventType.LoadEventCompleted, matchSceneName);
|
||||
|
||||
// 3. Respawn every player as its rolled role's prefab variant.
|
||||
RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter);
|
||||
|
||||
// The hiders the seek phase has to find: every connected client that wasn't rolled a hunter.
|
||||
_pendingHiders.Clear();
|
||||
foreach (ulong id in _net.ConnectedClientsIds)
|
||||
if (!hunterIds.Contains(id)) _pendingHiders.Add(id);
|
||||
@@ -588,10 +539,8 @@ public class MatchManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// Scene props tagged "PreMatch" exist only while players hide; the moment seek starts they must be gone
|
||||
// on every machine. ApplyPhaseLocal runs on all of them (server directly, clients via the phase message),
|
||||
// so each destroys its own local copies -- these are plain scene geometry, not NetworkObjects, so there
|
||||
// is nothing to replicate.
|
||||
// Runs on every machine (server directly, clients via the phase message); the tagged props are plain
|
||||
// scene geometry, not NetworkObjects, so each machine destroys its own local copies.
|
||||
private static void DestroyPreMatchObjects()
|
||||
{
|
||||
var props = GameObject.FindGameObjectsWithTag(PreMatchTag);
|
||||
@@ -599,9 +548,8 @@ public class MatchManager : MonoBehaviour
|
||||
Destroy(props[i]);
|
||||
}
|
||||
|
||||
// Sends a sub-phase (byte) + its remaining duration (float) + the match mode (byte) to one client, or to
|
||||
// all clients except the server itself (which applied it directly in SetPhaseServer, so it must not
|
||||
// double-apply via loopback).
|
||||
// Sends the sub-phase + duration + mode to one client, or to all clients except the server itself
|
||||
// (which applied it directly in SetPhaseServer, so it must not double-apply via loopback).
|
||||
private void BroadcastPhase(MatchSubPhase phase, float duration, MatchMode mode, ulong targetClient, bool toAll)
|
||||
{
|
||||
var cmm = _net != null ? _net.CustomMessagingManager : null;
|
||||
@@ -613,8 +561,6 @@ public class MatchManager : MonoBehaviour
|
||||
writer.WriteValueSafe((byte)mode);
|
||||
if (toAll)
|
||||
{
|
||||
// Every client except the server itself (the host already applied it directly in
|
||||
// SetPhaseServer, so it must not double-apply via loopback). One send, no writer reuse.
|
||||
_phaseRecipients.Clear();
|
||||
foreach (ulong id in _net.ConnectedClientsIds)
|
||||
if (id != _net.LocalClientId) _phaseRecipients.Add(id);
|
||||
@@ -694,12 +640,10 @@ public class MatchManager : MonoBehaviour
|
||||
Debug.Log($"[MatchManager] Hider {hiderClientId} {verb} - {_pendingHiders.Count} hider(s) left.", this);
|
||||
}
|
||||
|
||||
// Server-only (Conversion mode). Despawns the tagged hider's avatar and respawns it as the Hunter prefab
|
||||
// variant for the same client, AT THE SAME position/rotation - it converts where it was tagged, no teleport.
|
||||
// The fresh Hunter's owner skips its spawn-placement because the match is mid-Seek (see NetworkPlayerSetup),
|
||||
// so the server-set position sticks. Same despawn-then-spawn pattern as RespawnAll; the owner's painting
|
||||
// carries across via the per-machine paint carry. After this the client plays as a full hunter (size,
|
||||
// sprint, first-person, shooting) with no special-case runtime state.
|
||||
// Server-only (Conversion mode). Despawns the tagged hider and respawns it as the Hunter variant for
|
||||
// the same client AT THE SAME position/rotation - no teleport. The owner skips its spawn placement
|
||||
// mid-Seek (see NetworkPlayerSetup) so the server-set position sticks; painting carries across via
|
||||
// the paint carry. Afterwards the client is a full hunter with no special-case runtime state.
|
||||
private void ConvertToHunter(ulong clientId, NetworkObject current)
|
||||
{
|
||||
if (!hunterPrefab)
|
||||
@@ -712,9 +656,8 @@ public class MatchManager : MonoBehaviour
|
||||
current.Despawn(true);
|
||||
|
||||
var go = Instantiate(hunterPrefab, pos, rot);
|
||||
// Hand the tagged world position to the owner: the owner-authoritative transform ignores the server's
|
||||
// Instantiate pos, so without this the converted hunter snaps to the prefab origin (0,0,0). Must be set
|
||||
// BEFORE the spawn so OnNetworkSpawn can push it into the replicated _spawnOverride snapshot.
|
||||
// The owner-authoritative transform ignores the server's Instantiate pos - hand the tagged position
|
||||
// to the owner, BEFORE the spawn, so it rides the replicated _spawnOverride snapshot.
|
||||
var setup = go.GetComponent<NetworkPlayerSetup>();
|
||||
if (setup) setup.MarkConversionSpawn(pos);
|
||||
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
|
||||
@@ -750,16 +693,14 @@ public class MatchManager : MonoBehaviour
|
||||
{
|
||||
var sm = _net.SceneManager;
|
||||
|
||||
// 1. Unload the match map (strictly serialized: one scene op in flight at a time).
|
||||
// Strictly serialized: NGO allows only one scene op in flight at a time.
|
||||
Scene map = SceneManager.GetSceneByName(matchSceneName);
|
||||
if (map.IsValid() && map.isLoaded)
|
||||
yield return SceneOp(() => sm.UnloadScene(map), SceneEventType.UnloadEventCompleted, matchSceneName);
|
||||
|
||||
// 2. Reload the lobby.
|
||||
yield return SceneOp(() => sm.LoadScene(lobbySceneName, LoadSceneMode.Additive),
|
||||
SceneEventType.LoadEventCompleted, lobbySceneName);
|
||||
|
||||
// 3. Respawn everyone as the neutral lobby Player (covers held mid-match joiners too).
|
||||
RespawnAll(_ => _playerPrefab, lobbySpawnCenter);
|
||||
|
||||
Phase = MatchPhase.Lobby;
|
||||
@@ -826,9 +767,8 @@ public class MatchManager : MonoBehaviour
|
||||
UnloadLocalIfLoaded(lobbySceneName);
|
||||
UnloadLocalIfLoaded(matchSceneName);
|
||||
|
||||
// The local avatar is gone; drop the mode cycle back to a player-less state so the persistent
|
||||
// orbit input stops re-locking the cursor (and any FreeCam/First-Person takeover is undone). Without
|
||||
// this the OS cursor stays captured back in the empty Main scene.
|
||||
// Drop the mode cycle back to a player-less state so the persistent orbit input stops re-locking
|
||||
// the cursor (and any FreeCam/First-Person takeover is undone).
|
||||
var modes = FindFirstObjectByType<GameModeManager>(FindObjectsInactive.Include);
|
||||
if (modes) modes.ReleaseToNoPlayer();
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ public partial class MeshPainter
|
||||
filterMode = FilterMode.Bilinear
|
||||
},
|
||||
pixels = SeedCpuPixels(mi, canonical),
|
||||
// Seed the dilation-source marker with the UV islands so seeded (loaded /
|
||||
// switched-in) content is protected from edge padding right away; the gutter
|
||||
// stays paddable. Clone: painted[] is mutated per stroke.
|
||||
// UV-island seed protects loaded/switched-in content from edge padding right away
|
||||
// (the gutter stays paddable). Clone: painted[] is mutated per stroke.
|
||||
painted = (bool[])GetIslandMask().Clone(),
|
||||
fillStamp = new int[textureSize * textureSize],
|
||||
dilateStamp = 0
|
||||
@@ -112,11 +111,11 @@ public partial class MeshPainter
|
||||
|
||||
// Mask map target: R = metallic, G = occlusion, B = 0, A = smoothness (one texture
|
||||
// bound to both _MetallicGlossMap and _OcclusionMap).
|
||||
float msoR = Mathf.Clamp01(metallic) * 255f; // metallic
|
||||
float msoG = Mathf.Clamp01(occlusion) * 255f; // occlusion
|
||||
float msoA = Mathf.Clamp01(smoothness) * 255f; // smoothness
|
||||
float msOpMul = Mathf.Clamp01(metallicSmoothnessOpacity); // metallic+smoothness paint opacity
|
||||
float aoOpMul = Mathf.Clamp01(occlusionOpacity); // occlusion paint opacity
|
||||
float msoR = Mathf.Clamp01(metallic) * 255f;
|
||||
float msoG = Mathf.Clamp01(occlusion) * 255f;
|
||||
float msoA = Mathf.Clamp01(smoothness) * 255f;
|
||||
float msOpMul = Mathf.Clamp01(metallicSmoothnessOpacity);
|
||||
float aoOpMul = Mathf.Clamp01(occlusionOpacity);
|
||||
CpuMap mso = _cpu[MAP_MSO];
|
||||
bool aMSO = aMS || aAO;
|
||||
|
||||
@@ -141,7 +140,6 @@ public partial class MeshPainter
|
||||
Vector3 v0 = _verts[i0], v1 = _verts[i1], v2 = _verts[i2];
|
||||
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
|
||||
|
||||
// UV -> pixel.
|
||||
float p0x = uv0.x * ts, p0y = uv0.y * ts;
|
||||
float p1x = uv1.x * ts, p1y = uv1.y * ts;
|
||||
float p2x = uv2.x * ts, p2y = uv2.y * ts;
|
||||
@@ -188,7 +186,6 @@ public partial class MeshPainter
|
||||
if (dv < -radiusLocal || dv > radiusLocal) continue;
|
||||
if (dn < -radiusLocal || dn > radiusLocal) continue;
|
||||
|
||||
// Mask -> opacity.
|
||||
float mu = du * invR * 0.5f + 0.5f;
|
||||
float mv = dv * invR * 0.5f + 0.5f;
|
||||
float maskOp = SampleMaskOpacity(mu, mv);
|
||||
|
||||
@@ -4,15 +4,11 @@ using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// One committed brush stamp in a form that can be replicated across the network. Painting is
|
||||
/// synced as commands (these stamps), not as pixels: the owner already resolves every stamp into
|
||||
/// local object space (hit point, radius, tangent frame) inside <see cref="MeshPainter.PaintAtScreen"/>,
|
||||
/// so a remote peer just replays the SAME stamp through its own backend onto the identical mesh/UVs
|
||||
/// and gets a near-identical result - for a fraction of the bandwidth of streaming the 12 MB of maps.
|
||||
///
|
||||
/// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless and
|
||||
/// robust to ordering/loss (sent reliably). Channel data (metallic/smoothness/occlusion + opacities)
|
||||
/// quantizes to bytes with no real loss because the paint maps are 8-bit anyway.
|
||||
/// One committed brush stamp in replicable form. Painting is synced as commands, not pixels: the owner
|
||||
/// already resolves every stamp into local object space (hit point, radius, tangent frame), so a remote
|
||||
/// peer replays the SAME stamp through its own backend onto the identical mesh/UVs.
|
||||
/// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless.
|
||||
/// Channel data quantizes to bytes with no real loss (the paint maps are 8-bit anyway).
|
||||
/// </summary>
|
||||
public struct PaintStampNet : INetworkSerializable
|
||||
{
|
||||
@@ -239,11 +235,10 @@ public partial class MeshPainter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a fresh
|
||||
/// local baseline - exactly like <see cref="LoadPaintByName"/> does for a file, just from bytes. Used to
|
||||
/// carry a painting onto a freshly spawned avatar across a scene-switch respawn; the caller then
|
||||
/// BroadcastLoadedPaint()s it to peers via the same path a manual load uses. Returns false if the painter
|
||||
/// is not ready or the blob is missing / incompatible (the current painting is left untouched).
|
||||
/// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a
|
||||
/// fresh local baseline (like <see cref="LoadPaintByName"/>, just from bytes) - used to carry a
|
||||
/// painting across a scene-switch respawn, then rebroadcast via BroadcastLoadedPaint. Returns false
|
||||
/// if the painter is not ready or the blob is incompatible (the current painting stays untouched).
|
||||
/// </summary>
|
||||
public bool LoadPaintFromSnapshot(byte[] compressed)
|
||||
{
|
||||
|
||||
@@ -5,21 +5,10 @@ public partial class MeshPainter
|
||||
{
|
||||
// --- Pipette (eyedropper) ---
|
||||
//
|
||||
// A sampling MODE rather than a paint channel: while active (Shift or Ctrl held, or the HUD toggle), a
|
||||
// left-click raycasts onto ANY surface (not just the paint mesh) and reads the channel values at
|
||||
// the hit point (albedo, metallic, smoothness, emission) into the brush properties. The brush
|
||||
// opacities/alphas (albedo alpha, metallic/smoothness opacity, emission alpha) are left UNTOUCHED,
|
||||
// so a pick changes only WHAT is painted, not how strongly. Height (the occlusion channel) is picked
|
||||
// and its toggle enabled ONLY when our own paint mesh is hit; any other surface leaves the height
|
||||
// value untouched and disables its toggle. The height opacity (occlusionOpacity) is untouched either way.
|
||||
//
|
||||
// On a FOREIGN object the data channels (metallic/smoothness/emission) come texel-exact
|
||||
// from the material (raw, UNLIT). Albedo follows the 'pipetteAlbedoFromScreen' toggle: OFF (default)
|
||||
// reads it texel-exact from the material too; ON takes it from the rendered, fully-lit on-screen
|
||||
// pixel ("what you see" - honours baked lightmaps/ambient/SSAO), combining material PBR fidelity
|
||||
// with the lit look. When no texel-exact material read is possible (no readable UV / headless) it
|
||||
// falls back to a pure screen pick (lit albedo, data channels at defaults). Our own paint mesh and
|
||||
// terrain are read texel-exact.
|
||||
// A sampling MODE rather than a paint channel: a click (Shift/Ctrl held, or the HUD toggle) reads the
|
||||
// channel values at the hit point into the brush, leaving the brush opacities/alphas untouched - a
|
||||
// pick changes WHAT is painted, not how strongly. Height is adopted only off our own paint mesh.
|
||||
// Per-surface routing: see PickAtScreen.
|
||||
|
||||
[Header("Brush - pipette (eyedropper)")]
|
||||
[Tooltip("Pipette mode: a click samples the channel values at the hit point on ANY surface " +
|
||||
@@ -49,12 +38,9 @@ public partial class MeshPainter
|
||||
private const float TerrainFoliageHeightEpsilon = 0.3f;
|
||||
|
||||
/// <summary>
|
||||
/// Raycasts at a screen position against ANY collider, samples the channel values at the hit
|
||||
/// (texel-exact where possible) and copies them into the brush, leaving the brush opacities/alphas
|
||||
/// untouched and enabling those paint channels. Height (occlusion) is adopted and its toggle enabled
|
||||
/// only when our own paint mesh is hit; any other surface disables it (see FinalizePick). Reads
|
||||
/// regardless of which channels are currently toggled.
|
||||
/// Pick paths, by what was hit:
|
||||
/// Raycasts at a screen position against ANY collider (regardless of which channels are toggled),
|
||||
/// samples the channel values at the hit and adopts them into the brush (toggle rules: see
|
||||
/// <see cref="FinalizePick"/>). Pick paths, by what was hit:
|
||||
/// - our paint mesh -> texel-exact from the painted maps (full PBR);
|
||||
/// - terrain ground -> blend of the terrain layers by their splat weights (full PBR);
|
||||
/// - terrain tree / detail -> the rendered on-screen colour (no per-instance renderer/UV);
|
||||
@@ -65,9 +51,7 @@ public partial class MeshPainter
|
||||
private void PickAtScreen(Vector2 screenPos)
|
||||
{
|
||||
bool ok;
|
||||
// Height (occlusion) is only meaningful off our OWN painted maps, so a hit on our paint mesh is
|
||||
// the sole pick that enables the height channel - every other surface leaves it off (FinalizePick).
|
||||
bool pickedHeight = false;
|
||||
bool pickedHeight = false; // height is only meaningful off our OWN painted maps
|
||||
if (TryRaycastAny(screenPos, out RaycastHit hit))
|
||||
{
|
||||
if (hit.collider == _collider)
|
||||
@@ -86,8 +70,7 @@ public partial class MeshPainter
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ray left the scene entirely (e.g. foliage silhouetted against the sky): the
|
||||
// on-screen pixel is the only thing to sample.
|
||||
// Ray left the scene entirely (e.g. foliage against the sky) - only the screen pixel remains.
|
||||
ok = PickFromScreen(screenPos);
|
||||
}
|
||||
if (!ok) return;
|
||||
@@ -96,13 +79,9 @@ public partial class MeshPainter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common tail of every successful pick: enables the sampled paint channels, leaves pipette mode
|
||||
/// (one-shot) and notifies listeners (the in-game HUD) to re-sync their widgets. The brush
|
||||
/// opacities/alphas (albedo alpha, metallic/smoothness opacity, emission alpha) are left UNTOUCHED,
|
||||
/// so a pick changes only WHAT is painted, not how strongly. Height (the occlusion channel) is a
|
||||
/// special case: <paramref name="pickedHeight"/> is true only when our own paint mesh was picked -
|
||||
/// then the height toggle is enabled with the adopted value; any other surface disables the toggle
|
||||
/// and keeps the deliberate height value. The height opacity (occlusionOpacity) is untouched either way.
|
||||
/// Common tail of every successful pick: enables the sampled paint channels (height only when our
|
||||
/// own paint mesh was picked - any other surface disables its toggle and keeps the value), leaves
|
||||
/// pipette mode (one-shot) and notifies listeners (the HUD) to re-sync their widgets.
|
||||
/// </summary>
|
||||
private void FinalizePick(bool pickedHeight)
|
||||
{
|
||||
@@ -154,35 +133,26 @@ public partial class MeshPainter
|
||||
brushColor = new Color(a.r / 255f, a.g / 255f, a.b / 255f, brushColor.a);
|
||||
metallic = m.r / 255f;
|
||||
smoothness = m.a / 255f;
|
||||
// Occlusion (m.g) is the painted HEIGHT: adopted only off our own paint mesh, which is the sole
|
||||
// pick that enables the height toggle (FinalizePick). Its opacity (occlusionOpacity) is left as-is.
|
||||
occlusion = m.g / 255f;
|
||||
occlusion = m.g / 255f; // the painted HEIGHT - adopted only off our own mesh (FinalizePick)
|
||||
emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, emissionColor.a);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Foreign-object pick: the data channels (metallic/smoothness/emission) always come
|
||||
/// texel-exact from the material via <see cref="PickFromForeignMaterial"/> (raw, unlit). Albedo
|
||||
/// follows <see cref="pipetteAlbedoFromScreen"/>: when set, it is overwritten with the rendered,
|
||||
/// fully-lit on-screen pixel ("what you see" - honours baked lightmaps/ambient/SSAO); otherwise
|
||||
/// (default) it stays the material's texel-exact value. When no texel-exact material read is
|
||||
/// possible (no readable UV / headless) it falls back to a pure screen pick (lit albedo, data
|
||||
/// channels at defaults).
|
||||
/// Foreign-object pick: data channels always texel-exact from the material (raw, unlit); albedo
|
||||
/// optionally overwritten with the fully-lit on-screen pixel (<see cref="pipetteAlbedoFromScreen"/>).
|
||||
/// Falls back to a pure screen pick when no texel-exact material read is possible.
|
||||
/// </summary>
|
||||
private bool PickFromForeignHybrid(RaycastHit hit, Vector2 screenPos)
|
||||
{
|
||||
if (PickFromForeignMaterial(hit))
|
||||
{
|
||||
// Keep the material's texel-exact PBR; optionally replace albedo with the lit on-screen
|
||||
// colour. No camera (headless) -> keep the material's raw albedo rather than failing.
|
||||
// No camera (headless) -> keep the material's raw albedo rather than failing.
|
||||
if (pipetteAlbedoFromScreen && TrySampleScreenColor(screenPos, out Color lit))
|
||||
brushColor = new Color(Mathf.Clamp01(lit.r), Mathf.Clamp01(lit.g), Mathf.Clamp01(lit.b), brushColor.a);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No texel-exact material read possible (no renderer/UV, or non-readable mesh behind a convex
|
||||
// collider): fall back to the lit screen pixel alone (data channels reset to defaults).
|
||||
return PickFromScreen(screenPos);
|
||||
}
|
||||
|
||||
@@ -199,12 +169,9 @@ public partial class MeshPainter
|
||||
if (!rend) rend = col.GetComponentInParent<Renderer>();
|
||||
if (!rend) return false;
|
||||
|
||||
// A UV at the hit is required to sample any material map texel-exact. hit.textureCoord is
|
||||
// only populated for a NON-convex MeshCollider; convex MeshColliders and primitive colliders
|
||||
// (Box/Sphere/Capsule/Terrain) return Vector2.zero. In that case recover the UV from the
|
||||
// renderer's own mesh (ray vs. triangles), which works for any solid collider whose mesh is
|
||||
// readable. Alongside the UV we resolve which SUBMESH the hit triangle belongs to, so a
|
||||
// multi-material renderer reads the sub-material covering the hit instead of always slot 0.
|
||||
// hit.textureCoord is only populated for a NON-convex MeshCollider (convex/primitive colliders
|
||||
// return zero) - recover the UV from the renderer's own mesh then. The hit SUBMESH is resolved
|
||||
// alongside so a multi-material renderer reads the covering sub-material, not always slot 0.
|
||||
Vector2 uv;
|
||||
bool hasUv;
|
||||
int subMesh;
|
||||
@@ -227,9 +194,8 @@ public partial class MeshPainter
|
||||
: null;
|
||||
if (!mat) return false;
|
||||
|
||||
// A base map exists but no UV could be resolved (e.g. a non-readable mesh behind a convex
|
||||
// collider): a texel-exact read is impossible, so defer to the rendered on-screen pixel,
|
||||
// which at least reflects the texture - rather than silently picking the flat _BaseColor.
|
||||
// Base map but no UV resolvable: defer to the on-screen pixel (which at least reflects the
|
||||
// texture) rather than silently picking the flat _BaseColor.
|
||||
if (!hasUv && GetTexProp(mat, "_BaseMap", "_MainTex")) return false;
|
||||
|
||||
// --- Albedo: BaseMap (sRGB) * BaseColor ---
|
||||
|
||||
@@ -82,13 +82,11 @@ public partial class MeshPainter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes the mesh UV islands into a per-texel coverage marker (true = the texel lies
|
||||
/// inside a UV triangle). This is the dilation SOURCE / overwrite guard: island texels are
|
||||
/// real content the edge-padding pass must never overwrite, while the gutter (false) stays
|
||||
/// paddable. Deriving the marker from the STATIC UV layout - instead of from transient
|
||||
/// per-stroke coverage - makes every seed (fresh start, loaded maps, backend switch) mark
|
||||
/// the same real content as painted, so the first stroke after a load/switch no longer
|
||||
/// bleeds over the existing paint. Built once and cached (UV layout + textureSize are fixed).
|
||||
/// Rasterizes the mesh UV islands into a per-texel coverage marker - the dilation SOURCE / overwrite
|
||||
/// guard (island texels are real content edge padding must never overwrite; the gutter stays
|
||||
/// paddable). Derived from the STATIC UV layout instead of transient per-stroke coverage, so every
|
||||
/// seed (fresh start, load, backend switch) marks the same content and the first stroke after a
|
||||
/// load/switch no longer bleeds over existing paint. Built once and cached.
|
||||
/// </summary>
|
||||
private bool[] GetIslandMask()
|
||||
{
|
||||
@@ -235,16 +233,12 @@ public partial class MeshPainter
|
||||
if (_materialInstance.HasProperty(OcclusionStrengthId)) _materialInstance.SetFloat(OcclusionStrengthId, 1f);
|
||||
}
|
||||
|
||||
// Height: the same mask map is also bound to _ParallaxMap (see MapProps), so the painted
|
||||
// occlusion value (G) doubles as a 0.5-centred height. _PARALLAXMAP turns on URP's single-
|
||||
// step parallax (ApplyPerPixelDisplacement) AND our relief-normal/AO remap in PlayerCamoLit.
|
||||
// Lift _Parallax off its floor so the displacement is actually visible (the relief normal is
|
||||
// the dominant cue; this just adds subtle motion parallax). Live-tunable on the instance.
|
||||
// _NORMALMAP is REQUIRED for the relief normal to show: URP's InitializeInputData only
|
||||
// transforms surfaceData.normalTS into world space when _NORMALMAP (or _DETAIL) is set -
|
||||
// otherwise it discards normalTS and uses the geometric normal, so the relief is invisible.
|
||||
// _BumpMap stays unbound (= flat default normal), so it only enables the tangent->world path
|
||||
// and our relief blends on top of a flat base.
|
||||
// Height: the same mask map is also bound to _ParallaxMap, so the painted occlusion (G) doubles
|
||||
// as a 0.5-centred height. _PARALLAXMAP enables URP's parallax AND our relief-normal/AO remap in
|
||||
// PlayerCamoLit; _Parallax is lifted off its floor so the displacement is visible. _NORMALMAP is
|
||||
// REQUIRED for the relief normal to show - URP only transforms normalTS into world space when it
|
||||
// (or _DETAIL) is set, otherwise the relief is invisible. _BumpMap stays unbound (flat default
|
||||
// normal), so the keyword only enables the tangent->world path.
|
||||
if (_materialInstance.HasProperty(ParallaxMapId))
|
||||
{
|
||||
_materialInstance.EnableKeyword("_PARALLAXMAP");
|
||||
|
||||
@@ -8,21 +8,17 @@ public partial class MeshPainter
|
||||
{
|
||||
// --- Undo / redo: per-stroke history of the canonical maps, mirrored on every peer ---
|
||||
//
|
||||
// One stroke (mouse-down -> mouse-up) = one undo step. Each entry is the full canonical map set AFTER
|
||||
// that edit, restored by re-seeding the backend (same path as load / late-join snapshot). The baseline
|
||||
// at index 0 is the material seed (or a loaded / snapshotted baseline).
|
||||
// One stroke (mouse-down -> mouse-up) = one undo step; each entry is the full canonical map set AFTER
|
||||
// that edit, restored by re-seeding the backend. Index 0 is the baseline (material seed, or a
|
||||
// loaded/snapshotted one).
|
||||
//
|
||||
// Networked design: every replica keeps the SAME history, so undo/redo replicates as a tiny command
|
||||
// instead of re-broadcasting the whole painting. The owner assigns each committed stroke a monotonic
|
||||
// CHECKPOINT ID; remotes replay the same stamps and capture history at the same boundaries (driven by a
|
||||
// network checkpoint marker) under the owner's id. Undo/redo carries the TARGET id; a peer that holds
|
||||
// that id restores locally, a peer that doesn't (a late joiner asked to undo past its join) falls back
|
||||
// to a snapshot. PlayerPaintSync drives all of this through OnHistoryCheckpoint / OnHistoryRestore.
|
||||
// Networked: every replica keeps the SAME history, so undo/redo replicates as a tiny command. The
|
||||
// owner tags each committed stroke with a monotonic CHECKPOINT ID; remotes capture history at the same
|
||||
// boundaries under that id. Undo/redo carries the TARGET id; a peer lacking it (a late joiner) falls
|
||||
// back to a snapshot. PlayerPaintSync drives this via OnHistoryCheckpoint / OnHistoryRestore.
|
||||
//
|
||||
// Memory: consecutive states differ only in the maps a stroke actually painted, so unchanged maps are
|
||||
// shared by reference with the previous entry (copy-on-write). This is safe because both backends
|
||||
// CLONE their seed input (SeedCpuPixels / SeedGpuMap) and ReadCanonical returns fresh arrays - nothing
|
||||
// ever writes back into a stored state array.
|
||||
// Memory: unchanged maps are shared by reference with the previous entry (copy-on-write) - safe
|
||||
// because both backends CLONE their seed input and ReadCanonical returns fresh arrays.
|
||||
|
||||
[Header("Undo")]
|
||||
[Tooltip("Maximum number of undoable edits kept in memory (strokes + loads). Older edits drop off; " +
|
||||
|
||||
@@ -8,25 +8,16 @@ using UnityEngine.InputSystem;
|
||||
using Random = UnityEngine.Random; // System (for Action) also defines Random -> keep the UnityEngine one
|
||||
|
||||
/// <summary>
|
||||
/// Live mesh painting in Play mode: paints the brush onto the mesh textures with the
|
||||
/// mouse. Three paint maps are driven at once (each can be toggled): albedo color
|
||||
/// (_BaseMap), an HDRP-style mask map (R = metallic, G = occlusion, A = smoothness; one
|
||||
/// texture bound to BOTH _MetallicGlossMap and _OcclusionMap) and emission (_EmissionMap).
|
||||
/// Metallic/smoothness and occlusion share that mask map but stay independently paintable.
|
||||
/// Two backends (switchable live via <see cref="useGpu"/>):
|
||||
/// GPU shader painting into RenderTextures (fast, default) and CPU rasterization
|
||||
/// (slower, identical result). Both paint seamlessly across UV seams because the brush
|
||||
/// is evaluated in 3D local space around the hit point, capturing triangles on both
|
||||
/// sides of a seam at once.
|
||||
/// Live mesh painting in Play mode. Three paint maps are driven at once (each toggleable): albedo
|
||||
/// (_BaseMap), an HDRP-style mask map (R = metallic, G = occlusion, A = smoothness; one texture bound
|
||||
/// to BOTH _MetallicGlossMap and _OcclusionMap) and emission (_EmissionMap). Two backends, switchable
|
||||
/// live via <see cref="useGpu"/>: GPU shader painting into RenderTextures (fast, default) and CPU
|
||||
/// rasterization (identical result). Both paint seamlessly across UV seams because the brush is
|
||||
/// evaluated in 3D local space around the hit point, capturing triangles on both sides of a seam.
|
||||
///
|
||||
/// The brush is a square stamp in the surface tangent plane whose shape is given by a
|
||||
/// grayscale mask texture (the mask value = per-texel opacity). A list of masks is
|
||||
/// assignable in the Inspector; if it is empty a procedural circle mask is used, which
|
||||
/// reproduces the classic round brush. Attach to the GameObject you want to paint.
|
||||
///
|
||||
/// A non-destructive brush preview (size + mask) is shown at the hover point without
|
||||
/// painting (<see cref="previewMode"/>): either a flat decal quad or a real on-surface
|
||||
/// overlay that wraps the mesh exactly like the paint would land.
|
||||
/// The brush is a square stamp in the surface tangent plane shaped by a grayscale mask (mask value =
|
||||
/// per-texel opacity); an empty mask list falls back to a procedural circle (classic round brush).
|
||||
/// A non-destructive preview (<see cref="previewMode"/>) shows size + mask at the hover point.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public partial class MeshPainter : MonoBehaviour
|
||||
@@ -62,13 +53,10 @@ public partial class MeshPainter : MonoBehaviour
|
||||
public bool IsReady => _setupValid;
|
||||
|
||||
/// <summary>
|
||||
/// Live camouflage knobs on the player's <c>Custom/PlayerCamoLit</c> material instance, surfaced so
|
||||
/// the paint HUD can tune them at runtime. Backed directly by the material instance, which is the
|
||||
/// authored source of truth: the getter reads it, the setter writes it. Both are a no-op when the
|
||||
/// material lacks the property (non-camo shader) or before Start builds the instance, so they never
|
||||
/// clobber the authored value. Runtime tuning is persisted by the HUD (PaintHud.Persistence saves them
|
||||
/// to the same JSON as the brush values and carries them across a scene-switch respawn) and replicated
|
||||
/// to other peers owner-authoritatively by <see cref="PlayerPaintSync"/>, so remotes show the same camo.
|
||||
/// Live camouflage knobs on the player's <c>Custom/PlayerCamoLit</c> material instance, surfaced for
|
||||
/// the paint HUD. The material is the source of truth (getter reads, setter writes); both are a
|
||||
/// no-op when the property is absent or before Start builds the instance, so the authored value is
|
||||
/// never clobbered. Persisted by PaintHud.Persistence, replicated by <see cref="PlayerPaintSync"/>.
|
||||
/// </summary>
|
||||
public float CamoHalfLambert
|
||||
{
|
||||
@@ -91,12 +79,9 @@ public partial class MeshPainter : MonoBehaviour
|
||||
set => SetMatFloat(SpecularDampenId, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Painted-height look knobs on the same <c>Custom/PlayerCamoLit</c> material instance, surfaced for
|
||||
/// the HUD like the camo knobs above (material-backed source of truth, no-op before the instance
|
||||
/// exists). All three persist with the painting (PaintHud.Persistence + the .paint slot) and replicate
|
||||
/// to peers via <see cref="PlayerPaintSync"/>. Defaults match the shader so the unpainted look is unchanged.
|
||||
/// </summary>
|
||||
// Painted-height look knobs on the same material instance, surfaced/guarded/persisted/replicated
|
||||
// like the camo knobs above. Defaults match the shader so the unpainted look is unchanged.
|
||||
|
||||
/// <summary>Painted-height AO darkening depth (shift, 0..1): AO fades from 1 down to (1 - this) at
|
||||
/// the deepest painted hole. 0 = no darkening (no black spots), 1 = full black at occ=0.</summary>
|
||||
public float OcclusionThreshold
|
||||
@@ -120,14 +105,11 @@ public partial class MeshPainter : MonoBehaviour
|
||||
set => SetMatFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase);
|
||||
}
|
||||
|
||||
/// <summary>Drives the see-through-walls X-ray overlay (see <c>PlayerCamoLit</c>): sets the TARGET for
|
||||
/// the ALPHA of the authored <c>_XRayColor</c>, so the rgb glow colour set on the material is preserved
|
||||
/// (target 0 = off/no effect, 1 = full glow). The material alpha eases toward this target over
|
||||
/// <see cref="xrayFadeDuration"/> seconds in <c>Update</c> (a smooth fade instead of a hard pop); set the
|
||||
/// duration to 0 for an instant toggle. This is a per-avatar runtime reveal, NOT a painted channel: it
|
||||
/// neither persists with the painting nor round-trips through the HUD. <see cref="NetworkPlayerSetup"/>
|
||||
/// raises it for the remaining hiders during the resolution phase and for a hider that buries itself
|
||||
/// too deep in geometry (over the concealment threshold).</summary>
|
||||
/// <summary>Drives the see-through-walls X-ray overlay: sets the TARGET for the alpha of the authored
|
||||
/// <c>_XRayColor</c> (the rgb glow colour is preserved); Update eases the material alpha toward it
|
||||
/// over <see cref="xrayFadeDuration"/> seconds. A per-avatar runtime reveal, NOT a painted channel
|
||||
/// (neither persists nor round-trips the HUD). <see cref="NetworkPlayerSetup"/> raises it during the
|
||||
/// resolution phase and for a hider buried too deep in geometry.</summary>
|
||||
public bool XRayRevealed
|
||||
{
|
||||
get => _xrayTarget > 0f;
|
||||
@@ -505,9 +487,8 @@ public partial class MeshPainter : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
// User mask: cache opacity (luma*alpha) for the CPU backend so it matches the GPU.
|
||||
// Non-readable textures are read back via the GPU (so even built-ins like
|
||||
// Default-Particle work on the CPU path, not just the GPU one).
|
||||
// User mask: cache opacity (luma*alpha) for the CPU backend so it matches the GPU. Non-readable
|
||||
// textures are read back via the GPU, so even built-ins like Default-Particle work on the CPU path.
|
||||
var px = ReadMaskPixels(_resolvedMask, out int w, out int h);
|
||||
_maskW = w;
|
||||
_maskH = h;
|
||||
@@ -672,9 +653,8 @@ public partial class MeshPainter : MonoBehaviour
|
||||
// Spray paints scattered dots, but the preview shows the round footprint (the area
|
||||
// the dots land in), so the hover ring stays a stable, readable size indicator.
|
||||
_previewMat.SetTexture(MaskTexId, spray ? _proceduralMask : _resolvedMask);
|
||||
// Preview stays fully opaque regardless of the brush's albedo alpha; only the
|
||||
// Preview Opacity property scales it (the hover ring is a size/shape indicator,
|
||||
// not a paint-strength preview).
|
||||
// Fully opaque regardless of the brush's albedo alpha; only Preview Opacity scales it
|
||||
// (a size/shape indicator, not a paint-strength preview).
|
||||
_previewMat.SetFloat(PreviewOpacityId, Mathf.Max(0f, previewOpacity));
|
||||
|
||||
int layer = gameObject.layer;
|
||||
@@ -688,10 +668,8 @@ public partial class MeshPainter : MonoBehaviour
|
||||
_previewMat.SetVector(BrushVId, v);
|
||||
_previewMat.SetVector(BrushNormalId, n);
|
||||
|
||||
// Draw the collider mesh again (object transform): the shader clips it to the
|
||||
// brush box, so only the footprint shows, wrapped onto the real surface. Using
|
||||
// the collider mesh keeps it in the same local space as the raycast hit and
|
||||
// valid for both skinned (baked) and static meshes.
|
||||
// Re-draw the collider mesh; the shader clips it to the brush box. The collider mesh is in
|
||||
// the same local space as the raycast hit and valid for skinned (baked) and static meshes.
|
||||
Mesh previewMesh = _collider ? _collider.sharedMesh : null;
|
||||
if (!previewMesh) return;
|
||||
Matrix4x4 m = transform.localToWorldMatrix;
|
||||
@@ -727,7 +705,6 @@ public partial class MeshPainter : MonoBehaviour
|
||||
RefreshEmissionColor(); // live-tunable emission intensity
|
||||
TickXRayFade(); // ease the see-through X-ray glow toward its on/off target (runs on every avatar)
|
||||
|
||||
// Painting disabled by the game mode (e.g. player-control mode): do nothing.
|
||||
if (!PaintingEnabled)
|
||||
{
|
||||
EndStrokeIfActive(); // finalize an in-progress stroke if the mode was left mid-paint
|
||||
@@ -761,20 +738,17 @@ public partial class MeshPainter : MonoBehaviour
|
||||
if (!TryGetMouse(out bool pressed, out Vector2 screenPos))
|
||||
return;
|
||||
|
||||
// Don't paint through the in-game UI: clicking a HUD slider/toggle must not
|
||||
// also stamp the mesh behind it.
|
||||
// Don't paint through the in-game UI (a HUD click must not also stamp the mesh behind it).
|
||||
if (pressed && IsPointerOverUi())
|
||||
pressed = false;
|
||||
|
||||
// Brush preview (size + mask) at the hover point - independent of painting AND of the
|
||||
// pipette, so you always see the brush footprint: before pressing the button, while the
|
||||
// momentary pipette holds Shift/Ctrl, and live as Shift/Ctrl+wheel resizes the brush.
|
||||
// Brush preview at the hover point - independent of painting AND of the pipette, so the
|
||||
// footprint stays visible before pressing, while the pipette holds, and during wheel resize.
|
||||
if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera)
|
||||
UpdatePreview(screenPos);
|
||||
|
||||
// Pipette (eyedropper): a fresh click samples ALL channel values at the hit point and adopts
|
||||
// them into the brush instead of painting. It takes over the click entirely (no painting),
|
||||
// but the brush preview above stays visible.
|
||||
// Pipette: a fresh click samples the channel values at the hit instead of painting. It takes
|
||||
// over the click entirely; the brush preview above stays visible.
|
||||
if (pipette)
|
||||
{
|
||||
EndStrokeIfActive(); // switching to the eyedropper ends any active stroke
|
||||
@@ -1020,12 +994,9 @@ public partial class MeshPainter : MonoBehaviour
|
||||
pLocal = transform.InverseTransformPoint(hit.point);
|
||||
hitTri = hit.triangleIndex;
|
||||
|
||||
// Brush radius (texels) -> constant local 3D radius via the mesh-wide average UV
|
||||
// density. Deliberately NOT the hit triangle's own density: that varied up to ~7.6x
|
||||
// across the mesh (spiking at UV seams / stretched islands), so the same brushSize
|
||||
// painted a visibly larger patch on seam triangles than on evenly-mapped ones. A single
|
||||
// global scale keeps the brush the same physical size everywhere; the stamp is still a
|
||||
// 3D box, so seams are bridged as before.
|
||||
// Brush radius (texels) -> constant local 3D radius via the MESH-WIDE average UV density -
|
||||
// deliberately not the hit triangle's own (that varied up to ~7.6x at seams/stretched islands,
|
||||
// painting visibly different sizes). The stamp stays a 3D box, so seams are bridged as before.
|
||||
radiusLocal = (brushSize / (float)textureSize) * _avgUvToLocal;
|
||||
if (radiusLocal <= 0f) return false;
|
||||
|
||||
@@ -1113,8 +1084,6 @@ public partial class MeshPainter : MonoBehaviour
|
||||
else if (_pipetteModHeld) pipette = _pipetteBeforeMod;
|
||||
_pipetteModHeld = mod;
|
||||
|
||||
// Shift or Ctrl + wheel resizes the brush (the orbit camera ignores the wheel while either is
|
||||
// held, so it does not also zoom).
|
||||
if (!mod) return;
|
||||
|
||||
float scroll = InputUtil.ReadScroll();
|
||||
|
||||
@@ -71,9 +71,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
/// neutral lobby Player prefab (full mode set). Baked per prefab variant.</summary>
|
||||
public bool ScopedModes => scopedModes;
|
||||
|
||||
// Server-authoritative "this hider has been shot/found". Server-write (a hunter's hit is validated on
|
||||
// the server, not by the hider's owner); read by everyone. When it flips true the body is hidden on
|
||||
// every client and the owning client drops into free-cam-only spectating. Stays false on hunters/lobby.
|
||||
// Server-authoritative "this hider has been shot/found" (a hit is validated on the server, not by the
|
||||
// hider's owner). When it flips true the body hides on every client and the owner drops to spectating.
|
||||
private readonly NetworkVariable<bool> _isFound =
|
||||
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
@@ -81,25 +80,21 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
/// target that is already out of play.</summary>
|
||||
public bool IsFound => _isFound.Value;
|
||||
|
||||
// Owner-authoritative "this hider is buried too deep" flag. Only the OWNER runs the ConcealmentMeter
|
||||
// (it is disabled on remotes), so the owner compares its EmbeddedPercent against MatchManager's
|
||||
// threshold and writes this; every client reads it to switch the through-walls X-ray glow on/off for
|
||||
// this avatar. Stays false on hunters / the lobby avatar and whenever no match is running.
|
||||
// Owner-authoritative "this hider is buried too deep" flag: only the OWNER runs the ConcealmentMeter,
|
||||
// so it compares EmbeddedPercent against the threshold and writes; every client reads it to switch
|
||||
// the through-walls X-ray glow.
|
||||
private readonly NetworkVariable<bool> _embeddedReveal =
|
||||
new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
||||
|
||||
// Conversion-mode in-place respawn position. The owner holds authority over the NetworkTransform, so the
|
||||
// server's Instantiate position is NOT honoured on the owner (it would otherwise snap to the prefab origin,
|
||||
// 0,0,0). The server writes the tagged world position here in its OnNetworkSpawn so it rides the initial
|
||||
// spawn snapshot to the owner, which then restores it itself. The NaN sentinel means "no override" (a
|
||||
// normal lobby/match-start spawn) -> the owner runs its usual spawn-area/ring placement.
|
||||
// Conversion-mode in-place respawn position. The owner holds transform authority, so the server's
|
||||
// Instantiate position is NOT honoured there; the server writes the tagged position here so it rides
|
||||
// the spawn snapshot and the owner restores it itself. NaN sentinel = no override (normal placement).
|
||||
private static readonly Vector3 NoSpawnOverride = new Vector3(float.NaN, float.NaN, float.NaN);
|
||||
private readonly NetworkVariable<Vector3> _spawnOverride =
|
||||
new NetworkVariable<Vector3>(NoSpawnOverride, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||
|
||||
// Server-only handoff from MatchManager.ConvertToHunter: the world position the converted hunter must keep.
|
||||
// Pushed into the replicated _spawnOverride during OnNetworkSpawn (where the variable is spawned and the
|
||||
// server is allowed to write it - a pre-spawn write is silently dropped); a plain field needs no replication.
|
||||
// Server-only handoff from MatchManager.ConvertToHunter, pushed into _spawnOverride during
|
||||
// OnNetworkSpawn (a pre-spawn write to the NetworkVariable is silently dropped).
|
||||
private Vector3? _conversionSpawnPos;
|
||||
|
||||
/// <summary>Server-only. Call on a freshly-instantiated Conversion hunter BEFORE spawning it so the owner
|
||||
@@ -126,8 +121,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
_isFound.OnValueChanged += OnFoundChanged;
|
||||
if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found
|
||||
|
||||
// Server pushes a Conversion respawn's tagged position into the replicated override before the spawn
|
||||
// snapshot goes out, so the owner (which owns the transform) can restore it in its own OnNetworkSpawn.
|
||||
// Push the Conversion position into the replicated override before the spawn snapshot goes out.
|
||||
if (IsServer && _conversionSpawnPos.HasValue)
|
||||
_spawnOverride.Value = _conversionSpawnPos.Value;
|
||||
|
||||
@@ -135,10 +129,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
|
||||
if (IsOwner)
|
||||
{
|
||||
// A Conversion-mode tag respawns the hider as this Hunter variant in place. The owner-authoritative
|
||||
// transform ignores the server's Instantiate position, so without help the owner snaps to the prefab
|
||||
// origin (0,0,0); the server delivers the tagged position via _spawnOverride, restored here. A normal
|
||||
// spawn (NaN override) runs the usual spawn-area/ring placement instead.
|
||||
// Conversion respawn: restore the server-tagged position (the owner-authoritative transform
|
||||
// ignores the server's Instantiate pos); NaN override = normal spawn placement.
|
||||
Vector3 overridePos = _spawnOverride.Value;
|
||||
if (float.IsNaN(overridePos.x))
|
||||
PlaceAtSpawn();
|
||||
@@ -157,36 +149,25 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
var cc = GetComponent<CharacterController>();
|
||||
if (cc) cc.enabled = false;
|
||||
|
||||
// Owner cleanup: the concealment meter is a per-frame skinned-mesh bake + voxelization +
|
||||
// physics overlap whose result only feeds the LOCAL player (HUD / gameplay + the embedded-reveal
|
||||
// flag). A remote avatar's concealment is computed on its own owner's machine, so running it here
|
||||
// is pure wasted CPU.
|
||||
// The concealment meter (per-frame bake + voxelization + overlap) only feeds the LOCAL player;
|
||||
// a remote's concealment is computed on its own owner's machine, so here it is wasted CPU.
|
||||
if (_concealment) _concealment.enabled = false;
|
||||
|
||||
// NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes. MeshPainter's stamp replay
|
||||
// (ApplyNetworkStamp -> DrawBrushGpu) rasterizes the baker's live, per-frame BakedMesh to map a
|
||||
// 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.
|
||||
// NOTE: SkinnedColliderBaker deliberately stays ENABLED on remotes - stamp replay rasterizes its
|
||||
// live per-frame BakedMesh to map 3D brush placements into UV space; freezing the bake would
|
||||
// mis-map replicated paint onto any non-rest pose.
|
||||
|
||||
// The rig must keep evaluating even while this body is HIDDEN on the local client. During the hide
|
||||
// phase a hider's body is hidden on hunter clients (UpdateVisibility sets renderer.enabled = false),
|
||||
// and a remote can also sit off-screen. The avatar's Animator is authored as CullUpdateTransforms,
|
||||
// which STOPS writing the bone transforms whenever its renderer isn't visible. PlayerPoseController
|
||||
// drives the rig through a PlayableGraph on this Animator and resets un-keyed bones to rest every
|
||||
// frame, so a culled Animator leaves the rig frozen at the rest/T-pose. SkinnedColliderBaker then
|
||||
// bakes that wrong pose, and any paint stamp replicated during the hidden window lands on the wrong
|
||||
// body part - a desync that persists once the body is shown again. Forcing AlwaysAnimate keeps the
|
||||
// pose (and thus the baked mesh paint replay rasterizes) correct regardless of visibility. Remote
|
||||
// replicas only: an owner is never hidden from its own camera.
|
||||
// Force AlwaysAnimate so the rig keeps evaluating while this body is HIDDEN locally (hide phase /
|
||||
// off-screen): the authored CullUpdateTransforms stops writing bones when the renderer isn't
|
||||
// visible, the pose reset then freezes the rig at rest, SkinnedColliderBaker bakes that wrong
|
||||
// pose, and replicated paint lands on the wrong body part - a desync that persists once shown
|
||||
// again. Remote replicas only (an owner is never hidden from its own camera).
|
||||
foreach (var anim in GetComponentsInChildren<Animator>(true))
|
||||
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
|
||||
|
||||
// Conversion mode: a hider shot during Seek is despawned and respawned in place as this Hunter
|
||||
// variant. Keep the fresh hunter hidden on this (remote) client for a short settle window so nobody
|
||||
// sees it appear or interpolate from the spawn frame - the shot hider is already gone, then the
|
||||
// hunter cleanly materializes in its place. Detected via the replicated override the server wrote
|
||||
// above; it rides the spawn snapshot, so it is already valid here. The owner returned early above
|
||||
// (first-person, own body self-hidden), so it is never gated.
|
||||
// Conversion: keep the fresh in-place hunter hidden on this remote client for a short settle
|
||||
// window so nobody sees it pop in or interpolate from the spawn frame. Detected via the
|
||||
// replicated override (already valid here); the owner returned early above, so it is never gated.
|
||||
if (!float.IsNaN(_spawnOverride.Value.x))
|
||||
{
|
||||
_renderGate = true;
|
||||
@@ -243,12 +224,10 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
UpdateResolutionFreeze();
|
||||
}
|
||||
|
||||
// Per-client. During the resolution phase every still-in-play hider is frozen in place on every machine:
|
||||
// its pose graph holds the current frame (PlayerPoseController.Frozen) and, on the owning client, its
|
||||
// movement input is cut (ControlEnabled) so the owner-authoritative transform stops and replicates as
|
||||
// frozen. Mirrors the resolution X-ray reveal predicate. The pose flag is change-guarded (toggled only on
|
||||
// a transition); the owner's ControlEnabled is re-asserted every frozen frame so a stray mode toggle can't
|
||||
// quietly re-enable movement during the reveal. Nothing is restored — EndMatch despawns this avatar.
|
||||
// Per-client. During Resolution every still-in-play hider is frozen: the pose graph holds the frame
|
||||
// (PlayerPoseController.Frozen, change-guarded) and the owner's movement input is cut - re-asserted
|
||||
// every frozen frame so a stray mode toggle can't quietly re-enable movement during the reveal.
|
||||
// Nothing is restored: EndMatch despawns this avatar.
|
||||
private void UpdateResolutionFreeze()
|
||||
{
|
||||
bool freeze = ComputeResolutionFreeze();
|
||||
@@ -277,10 +256,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
}
|
||||
|
||||
// Per-client visibility: each machine decides whether to render THIS avatar's body from the LOCAL
|
||||
// viewer's perspective (hunter bodies are always visible; only hider bodies are ever hidden). A found
|
||||
// hider (Classic) is hidden for everyone. Otherwise it depends on the match MODE - see ComputeVisible.
|
||||
// The local owner's own body is left alone unless found, so this never fights the FirstPersonController's
|
||||
// own-body self-hide.
|
||||
// viewer's perspective (see ComputeVisible). The owner's own body is left alone unless found, so this
|
||||
// never fights the FirstPersonController's own-body self-hide.
|
||||
private void UpdateVisibility()
|
||||
{
|
||||
bool found = _isFound.Value;
|
||||
@@ -341,10 +318,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
return true;
|
||||
}
|
||||
|
||||
// Owner-only. Publishes whether this hider is buried too deep to the replicated flag every client reads.
|
||||
// Only a Hider avatar during a running match qualifies (the anti-burrow reveal targets hiders); the meter
|
||||
// runs owner-side only, so this is where EmbeddedPercent is available. Cheap: a bool compare, one network
|
||||
// write only on an actual edge (NetworkVariable dirties on assignment, so guard against no-op writes).
|
||||
// Owner-only (the meter runs owner-side, so EmbeddedPercent is only available here). Publishes the
|
||||
// buried-too-deep flag; one network write per actual edge (guarded - the variable dirties on assignment).
|
||||
private void UpdateEmbeddedReveal()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
@@ -358,10 +333,9 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
if (over != _embeddedReveal.Value) _embeddedReveal.Value = over;
|
||||
}
|
||||
|
||||
// Per-client. Switches this avatar's see-through-walls X-ray glow on/off by driving the material's
|
||||
// _XRayColor alpha (via MeshPainter). Two independent triggers, OR-combined: the replicated embedded
|
||||
// flag (a buried hider, from its owner) and the local resolution phase (every un-found hider is revealed
|
||||
// to everyone at the end). Change-guarded so the material is only touched on an actual transition.
|
||||
// Per-client X-ray glow via the material's _XRayColor alpha (through MeshPainter). Two OR-combined
|
||||
// triggers: the replicated embedded flag and the local resolution phase. Change-guarded so the
|
||||
// material is only touched on an actual transition.
|
||||
private void UpdateXRayReveal()
|
||||
{
|
||||
if (!_painter) return;
|
||||
@@ -397,11 +371,9 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places the freshly-spawned owner. Prefers this avatar's role-specific spawn area (Hunter/Hider tag),
|
||||
/// then the shared <see cref="spawnAreaTag"/> area; if a matching area is loaded the owner drops at a
|
||||
/// random point inside that collider (designer-authored spawn region). With no area present it falls back
|
||||
/// to the deterministic client-id ring. Owner-authoritative either way - the NetworkTransform replicates
|
||||
/// the result, and PlayerCharacterController captures it on its first Update as the kill-plane rescue target.
|
||||
/// Places the freshly-spawned owner: role-specific spawn area first, then the shared area (a random
|
||||
/// point inside the collider), falling back to the deterministic client-id ring. Owner-authoritative -
|
||||
/// the NetworkTransform replicates the result.
|
||||
/// </summary>
|
||||
private void PlaceAtSpawn()
|
||||
{
|
||||
@@ -412,13 +384,10 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-asserts the server-delivered Conversion position on the owner. The owner-authoritative
|
||||
/// NetworkTransform makes the owner's transform the source of truth, so the server's Instantiate
|
||||
/// position alone doesn't stick - the owner must set it here, BEFORE PlayerCharacterController's first
|
||||
/// Update snapshots the kill-plane rescue target (otherwise a hider tagged mid-wall-climb spawns, falls
|
||||
/// off the fresh hunter capsule below the kill plane, and is rescued to the still-zero default target =
|
||||
/// origin). The CharacterController is toggled around the write because it otherwise overrides direct
|
||||
/// transform writes (same pattern as PlaceOnSpawnRing/PlaceRandomlyInArea).
|
||||
/// Re-asserts the server-delivered Conversion position on the owner (whose transform is the source of
|
||||
/// truth), BEFORE PlayerCharacterController's first Update snapshots the kill-plane rescue target -
|
||||
/// otherwise a hider tagged mid-climb could fall and be rescued to the still-zero default (origin).
|
||||
/// The CharacterController is toggled around the write (it overrides direct transform writes).
|
||||
/// </summary>
|
||||
private void ApplyOwnerPosition(Vector3 worldPos)
|
||||
{
|
||||
@@ -454,11 +423,9 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the owner at a random point inside the spawn-area collider. X/Z are sampled within the
|
||||
/// collider's footprint (rejection-sampled against the convex shape so the irregular outline is honoured,
|
||||
/// not just its bounding box); Y sits a touch above the area floor so gravity settles the avatar onto the
|
||||
/// ground. The CharacterController is toggled around the move because it otherwise overrides direct
|
||||
/// transform writes (same pattern as <see cref="PlaceOnSpawnRing"/>).
|
||||
/// Drops the owner at a random point inside the spawn-area collider: X/Z rejection-sampled against
|
||||
/// the convex shape (honours the real outline, not just the AABB), Y a touch above the floor so
|
||||
/// gravity settles. The CharacterController is toggled around the move (see ApplyOwnerPosition).
|
||||
/// </summary>
|
||||
private void PlaceRandomlyInArea(Collider area)
|
||||
{
|
||||
@@ -496,11 +463,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
|
||||
|
||||
/// <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.
|
||||
/// don't pile up at the shared prefab spawn point. Id 0 (host) keeps the origin; later joiners take
|
||||
/// ring slots, stepping the radius out once a ring fills. Only X/Z shift (gravity settles Y).
|
||||
/// </summary>
|
||||
private void PlaceOnSpawnRing()
|
||||
{
|
||||
|
||||
@@ -7,21 +7,12 @@ public partial class PaintHud
|
||||
{
|
||||
// --- Persistence: panel positions + the widget-bound painter values ---
|
||||
//
|
||||
// Saved when state actually changes - a panel drop writes immediately (OnPanelMoved), brush-value
|
||||
// edits are coalesced and flushed shortly after they settle (LateUpdate debounce) - and restored on
|
||||
// the next start, so the HUD opens where you left it with the brush settings you last used. There is
|
||||
// deliberately NO save on quit/teardown: standalone builds do not guarantee a clean shutdown
|
||||
// (OnApplicationQuit is skipped on force-close / kill / crash), so a teardown-only save silently
|
||||
// lost the whole session's layout in the build. This is small structured state (a handful of
|
||||
// positions and scalars), so it goes to a human-readable JSON file - unlike the painter's bulk pixel
|
||||
// maps (MeshPainter.Persistence) which use a raw byte dump. The two saves are independent: this one
|
||||
// owns the HUD layout + brush values, the painter owns the canvas pixels.
|
||||
//
|
||||
// The brush values are also mirrored into an in-memory snapshot (_carry) on this persistent HUD, so
|
||||
// they survive the avatar respawn that a scene switch (lobby -> map) triggers: the new painter gets
|
||||
// the carried values instead of its prefab defaults. The save key is derived from the HUD's OWN
|
||||
// scene (a stable persistent scene), not the active scene - the latter now changes across the
|
||||
// additive lobby/map loads, which would split save and restore into different files.
|
||||
// Saved when state actually changes (panel drop = instant, brush edits = debounced), restored on the
|
||||
// next start. Deliberately NO save on quit/teardown: standalone builds don't guarantee a clean
|
||||
// shutdown (OnApplicationQuit is skipped on force-close/kill/crash), which silently lost the whole
|
||||
// session's layout in the build. Small structured state -> human-readable JSON, independent of the
|
||||
// painter's bulk pixel maps (MeshPainter.Persistence). The brush values are also mirrored in-memory
|
||||
// (_carry) so they survive the avatar respawn a scene switch triggers.
|
||||
|
||||
[Header("Persistence")]
|
||||
[Tooltip("Save the panel positions and their values when you move a panel or change a brush value, " +
|
||||
@@ -35,9 +26,8 @@ public partial class PaintHud
|
||||
|
||||
private const int HudFileVersion = 1;
|
||||
|
||||
// Auto-save bookkeeping. Panel drops save instantly; brush-value edits are debounced because sliders
|
||||
// fire every frame while dragged - we push the due time forward and write once they settle. No
|
||||
// teardown save, so persistence never depends on a clean quit.
|
||||
// Auto-save bookkeeping. Brush edits are debounced (sliders fire every frame while dragged): the due
|
||||
// time is pushed forward and written once they settle.
|
||||
private const float HudSaveDebounce = 0.5f;
|
||||
private readonly HudValues _savedValues = new HudValues(); // mirror of what is currently on disk
|
||||
private readonly HudValues _lastSeenValues = new HudValues(); // painter values observed last frame (settle detection)
|
||||
@@ -45,31 +35,25 @@ public partial class PaintHud
|
||||
private bool _hudSavePending;
|
||||
private float _hudSaveDueTime;
|
||||
|
||||
// In-memory mirror of the brush values, kept on this persistent HUD so they survive the avatar
|
||||
// respawn a scene switch (lobby <-> map) triggers: the freshly spawned painter is seeded with these
|
||||
// instead of its prefab defaults. Single instance with mutable fields -> no per-frame allocation.
|
||||
// In-memory mirror seeding a freshly respawned painter (instead of its prefab defaults). Single
|
||||
// instance with mutable fields -> no per-frame allocation.
|
||||
private readonly HudValues _carry = new HudValues();
|
||||
private bool _hasCarry;
|
||||
|
||||
// Camo knobs (HalfLambert / brightness / specular dampen) live on the player's PlayerCamoLit material
|
||||
// instance, which only exists after the painter's Start. They are saved/restored with the brush values
|
||||
// above, but can't be WRITTEN until the material is ready - so a restored (disk) or carried (respawn)
|
||||
// value waits here and is applied in the LateUpdate ready-gate. _camoEverReady gates the carry/save so
|
||||
// the not-yet-ready 0 the getter returns never overwrites the material's authored value.
|
||||
// Camo knobs live on the material instance, which only exists after the painter's Start: a restored
|
||||
// (disk) or carried (respawn) value waits here and is applied in the LateUpdate ready-gate.
|
||||
// _camoEverReady gates carry/save so the not-yet-ready 0 never overwrites the authored value.
|
||||
private Vector3 _pendingCamo;
|
||||
private bool _hasPendingCamo;
|
||||
private bool _camoEverReady;
|
||||
|
||||
// Painted-height knobs (occlusion threshold, normal intensity, parallax intensity) ride the exact same
|
||||
// material-ready deferral as the camo knobs above: they become writable only after the painter's Start,
|
||||
// so a restored/carried value waits here and is applied in the LateUpdate ready-gate (shared timing).
|
||||
// Painted-height knobs ride the exact same material-ready deferral as the camo knobs above.
|
||||
private Vector3 _pendingHeight;
|
||||
private bool _hasPendingHeight;
|
||||
|
||||
// Authored defaults for the runtime reset (= "as if the saved layout file had been deleted"): the
|
||||
// prefab's panel positions, captured before any saved layout is restored over them, and the painter's
|
||||
// prefab/material default brush values, captured before carry/restore mutate the live painter. Camo is
|
||||
// material-backed (readable only once IsReady), so it is filled in the LateUpdate ready-gate instead.
|
||||
// Authored defaults for the runtime reset ("as if the save file had been deleted"): prefab panel
|
||||
// positions + prefab/material default brush values, captured before restore/carry mutate them. Camo
|
||||
// is material-backed (readable only once IsReady), so it is filled in the ready-gate instead.
|
||||
private readonly Dictionary<string, Vector2> _authoredPanelPos = new Dictionary<string, Vector2>();
|
||||
private bool _hasAuthoredLayout;
|
||||
private readonly HudValues _defaults = new HudValues();
|
||||
@@ -197,9 +181,8 @@ public partial class PaintHud
|
||||
SyncFromPainter();
|
||||
}
|
||||
|
||||
// Camo is material-backed and can't be written until the painter is ready -> stash the saved values
|
||||
// for the LateUpdate ready-gate. hasCamoValues guards pre-camo save files: without it we would apply
|
||||
// a 0 and clobber the material's authored camo.
|
||||
// Camo is material-backed -> stash for the LateUpdate ready-gate. hasCamoValues guards pre-camo
|
||||
// save files (applying their 0 would clobber the authored camo).
|
||||
if (state.hasCamoValues && state.values != null)
|
||||
{
|
||||
_pendingCamo = new Vector3(state.values.camoHalfLambert,
|
||||
@@ -208,8 +191,7 @@ public partial class PaintHud
|
||||
_hasPendingCamo = true;
|
||||
}
|
||||
|
||||
// Same deferral for the painted-height knobs; hasHeightValues guards pre-height save files so they
|
||||
// keep the material's authored defaults instead of being slammed to a 0 threshold/flat relief.
|
||||
// Same deferral + guard for the painted-height knobs.
|
||||
if (state.hasHeightValues && state.values != null)
|
||||
{
|
||||
_pendingHeight = new Vector3(state.values.occlusionThreshold,
|
||||
@@ -233,17 +215,13 @@ public partial class PaintHud
|
||||
{
|
||||
if (painter)
|
||||
{
|
||||
// Camo knobs are material-backed and only readable/writable after the painter's Start built the
|
||||
// instance (binding ran earlier, in the avatar's OnNetworkSpawn). Apply the restored (disk) or
|
||||
// carried (respawn) values to the material once it is ready, then seed the sliders from it. Done
|
||||
// BEFORE the carry mirror + change detection below so the just-applied value is already the
|
||||
// baseline and never re-triggers a redundant auto-save. With nothing pending (first session, no
|
||||
// save file) the material keeps its authored camo and the sliders just reflect it.
|
||||
// Apply restored/carried camo + height to the material once it is ready, then seed the
|
||||
// sliders. Runs BEFORE the carry mirror + change detection below, so the just-applied value
|
||||
// is already the baseline and never re-triggers a redundant auto-save.
|
||||
if (!_camoSynced && painter.IsReady)
|
||||
{
|
||||
// Capture the material's authored camo as the reset default BEFORE any pending (restored or
|
||||
// carried) camo is applied over it. Once per HUD: a later session/respawn keeps the very
|
||||
// first authored value, so a reset always returns to the genuine material default.
|
||||
// Capture the authored camo as the reset default BEFORE any pending value is applied over
|
||||
// it - once per HUD, so a reset always returns to the genuine material default.
|
||||
if (!_hasDefaultCamo)
|
||||
{
|
||||
_defaults.camoHalfLambert = painter.CamoHalfLambert;
|
||||
@@ -283,16 +261,12 @@ public partial class PaintHud
|
||||
}
|
||||
|
||||
// Mirror the live painter every frame; the next BindPainter (after a respawn) seeds the fresh
|
||||
// painter from this snapshot instead of its prefab defaults. Captured while the painter lives,
|
||||
// so it is immune to the destroy-pending timing of the despawn.
|
||||
// painter from this snapshot. Captured while the painter lives - immune to despawn timing.
|
||||
CaptureCarry();
|
||||
|
||||
// Debounced auto-save: (re)arm the settle timer only on frames where a value actually CHANGED
|
||||
// since the last frame - not merely differs from disk. Sliders fire every frame while dragged,
|
||||
// so this keeps the deadline ahead of the drag and writes once the values stop moving. Comparing
|
||||
// against the on-disk baseline instead would push the deadline forward forever (the edit always
|
||||
// differs from disk until it is written), so the save would never fire on its own - it only
|
||||
// landed when something else, e.g. a panel drop, wrote the state directly.
|
||||
// since the last frame - comparing against the on-disk baseline instead would push the
|
||||
// deadline forward forever (an edit differs from disk until written), so it would never fire.
|
||||
if (persistHud && _hasSavedBaseline && !ValuesEqual(painter, _lastSeenValues))
|
||||
{
|
||||
CopyValues(painter, _lastSeenValues);
|
||||
@@ -404,9 +378,8 @@ public partial class PaintHud
|
||||
v.sprayDotAlphaMin = from.sprayDotAlphaMin;
|
||||
v.sprayDotAlphaMax = from.sprayDotAlphaMax;
|
||||
|
||||
// Camo is material-backed: the getters read 0 until the painter's material instance exists, so only
|
||||
// mirror them once ready - otherwise preserve the desired values already in the target (the value a
|
||||
// restore/carry put there, which the ready-gate still has to apply).
|
||||
// Camo getters read 0 until the material exists - only mirror once ready, otherwise preserve the
|
||||
// desired values already in the target (a restore/carry the ready-gate still has to apply).
|
||||
if (from.IsReady)
|
||||
{
|
||||
v.camoHalfLambert = from.CamoHalfLambert;
|
||||
@@ -506,10 +479,8 @@ public partial class PaintHud
|
||||
[ContextMenu("Reset HUD Layout And Values")]
|
||||
public void ResetHudToDefaults()
|
||||
{
|
||||
// Delete the saved layout so a restart opens at the authored defaults as well (the literal intent).
|
||||
ClearSavedHudState();
|
||||
|
||||
// Panels -> authored prefab positions.
|
||||
if (_panel && _hasAuthoredLayout)
|
||||
{
|
||||
var root = _panel.transform;
|
||||
@@ -518,7 +489,6 @@ public partial class PaintHud
|
||||
rt.anchoredPosition = kv.Value;
|
||||
}
|
||||
|
||||
// Brush values -> painter prefab/material defaults, then push them to every widget.
|
||||
if (painter && _hasDefaults)
|
||||
{
|
||||
// Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than
|
||||
|
||||
+21
-42
@@ -8,21 +8,13 @@ using UnityEngine.InputSystem.UI;
|
||||
|
||||
/// <summary>
|
||||
/// In-game paint UI. Shows its panels only while the active <see cref="GameMode"/> is
|
||||
/// <see cref="PaintMode"/> (it follows the manager's ModeChanged event) and binds its
|
||||
/// widgets directly to the live <see cref="MeshPainter"/> fields, so edits apply immediately.
|
||||
/// <see cref="PaintMode"/> (follows the manager's ModeChanged) and binds its widgets directly to the
|
||||
/// live <see cref="MeshPainter"/> fields, so edits apply immediately.
|
||||
///
|
||||
/// The layout is a transparent root holding one independently positioned sub-panel per channel
|
||||
/// (Albedo, Metallic+Smoothness, Occlusion, Emission) plus a global Brush card, each with a title
|
||||
/// bar and draggable from any of its non-widget surface. The layout comes from one of two sources:
|
||||
/// - an authored prefab assigned to <see cref="layoutPrefab"/> (instantiated, then bound), or
|
||||
/// - generated in code (the fallback when no prefab is set).
|
||||
/// Either way the *bindings* (listeners, picker gradient sprites, painter values) live in code
|
||||
/// and are (re)applied at runtime by <see cref="WirePanel"/>. A prefab stores only the visual
|
||||
/// layout, never the behaviour, so both paths run the exact same wiring against the same stable
|
||||
/// widget names - there is no separate "dead" prefab path.
|
||||
///
|
||||
/// Widgets: channel toggles, an HSV colour picker for albedo, metallic / smoothness / occlusion
|
||||
/// sliders, an HSV picker + intensity for emission, and brush size. Attach to an empty GameObject.
|
||||
/// The layout (independently draggable sub-panels per channel + a global brush card) comes from an
|
||||
/// authored prefab (<see cref="layoutPrefab"/>) or is generated in code as the fallback. Either way
|
||||
/// the BINDINGS live in code and are (re)applied at runtime by <see cref="WirePanel"/> against the
|
||||
/// same stable widget names - a prefab stores only the visual layout, never behaviour.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public partial class PaintHud : MonoBehaviour
|
||||
@@ -137,10 +129,8 @@ public partial class PaintHud : MonoBehaviour
|
||||
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
|
||||
BindLibrary(_panel ? _panel.transform : null);
|
||||
|
||||
// Bind to a painter as soon as one exists. With networked spawn-on-connect there is none
|
||||
// at scene start; BindLocalPlayer then raises PainterChanged when the local avatar spawns
|
||||
// and OnPainterChanged wires the widgets. In a non-networked scene the painter is already
|
||||
// present and binds here immediately.
|
||||
// Bind as soon as a painter exists. With networked spawn-on-connect there is none at scene
|
||||
// start; PainterChanged then wires the widgets once the local avatar spawns.
|
||||
var p = painter;
|
||||
if (!p) p = modeManager ? modeManager.Painter : null;
|
||||
if (!p) p = FindFirstObjectByType<MeshPainter>();
|
||||
@@ -180,14 +170,12 @@ public partial class PaintHud : MonoBehaviour
|
||||
// Re-sync the brush-size slider when Shift+wheel resizes the brush in-world (painter -> HUD).
|
||||
painter.BrushSizeChanged += SyncBrushSize;
|
||||
|
||||
// Snapshot the painter's prefab-default brush values before carry/restore mutate them; the reset
|
||||
// button returns to exactly these. Once per HUD (a freshly spawned respawn painter holds the same
|
||||
// prefab defaults, so re-capturing would be redundant).
|
||||
// Snapshot the prefab-default brush values before carry/restore mutate them (the reset button
|
||||
// returns to exactly these). Once per HUD.
|
||||
CaptureDefaults();
|
||||
|
||||
// First bind this session: restore panel positions + brush values from disk (cross-session).
|
||||
// A later bind is an avatar respawn (scene switch) -> carry the in-session brush values onto the
|
||||
// fresh painter instead, so they are not reset to its prefab defaults.
|
||||
// First bind this session: restore from disk. A later bind is an avatar respawn -> carry the
|
||||
// in-session values onto the fresh painter instead.
|
||||
if (_hasCarry) ApplyCarry();
|
||||
else RestoreHudState();
|
||||
CaptureCarry(); // seed/refresh the mirror from the now-current painter
|
||||
@@ -207,9 +195,8 @@ public partial class PaintHud : MonoBehaviour
|
||||
|
||||
private void OnModeChanged(GameMode mode)
|
||||
{
|
||||
// Require a bound painter too: pre-spawn the manager sits in a player-less Paint mode (no
|
||||
// PlayerControl without an avatar), but there is nothing to paint yet - keep the HUD hidden until
|
||||
// the local avatar spawns and a painter is bound.
|
||||
// Require a bound painter too: pre-spawn the manager sits in a player-less Paint mode, but there
|
||||
// is nothing to paint yet - keep the HUD hidden until a painter is bound.
|
||||
bool paint = mode is PaintMode && painter;
|
||||
if (_panel) _panel.SetActive(paint); // toggles the brush panels and the library panel together
|
||||
if (paint)
|
||||
@@ -270,7 +257,6 @@ public partial class PaintHud : MonoBehaviour
|
||||
WireToggle(panel, ToggleSpray, painter && painter.spray, v => { if (painter) painter.spray = v; });
|
||||
WireToggle(panel, TogglePipette, painter && painter.pipette, v => { if (painter) painter.pipette = v; });
|
||||
|
||||
// Dim a channel panel to 0.5 alpha while its toggle is off, full alpha while on.
|
||||
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
|
||||
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
|
||||
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
|
||||
@@ -305,16 +291,13 @@ public partial class PaintHud : MonoBehaviour
|
||||
WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .2f, v => { if (painter) painter.sprayDotAlphaMin = v; });
|
||||
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : .6f, v => { if (painter) painter.sprayDotAlphaMax = v; });
|
||||
|
||||
// Camouflage knobs on the player's PlayerCamoLit material instance (material-backed, not painter
|
||||
// fields). The values are not readable here yet - the HUD binds in the avatar's OnNetworkSpawn,
|
||||
// which runs before the painter's Start builds the instance - so SyncCamoSliders re-seeds them
|
||||
// once the painter reports IsReady (see LateUpdate). Setting a slider before then is a safe no-op.
|
||||
// Camo knobs are material-backed and not readable yet (the HUD binds before the painter's Start);
|
||||
// SyncCamoSliders re-seeds them once IsReady. Setting a slider before then is a safe no-op.
|
||||
WireSlider(panel, SliderHalfLambert, painter ? painter.CamoHalfLambert : 0f, v => { if (painter) painter.CamoHalfLambert = v; });
|
||||
WireSlider(panel, SliderHalfLambertBrightness, painter ? painter.CamoHalfLambertBrightness : 0f, v => { if (painter) painter.CamoHalfLambertBrightness = v; });
|
||||
WireSlider(panel, SliderSpecularDamp, painter ? painter.CamoSpecularDampen : 0f, v => { if (painter) painter.CamoSpecularDampen = v; });
|
||||
|
||||
// Painted-height knobs: material-backed like the camo sliders above, so they are seeded once the
|
||||
// painter is ready (SyncCamoSliders) and a set before then is a safe no-op.
|
||||
// Painted-height knobs: material-backed like the camo sliders above.
|
||||
WireSlider(panel, SliderOcclusionThreshold, painter ? painter.OcclusionThreshold : 0.2f, v => { if (painter) painter.OcclusionThreshold = v; });
|
||||
WireSlider(panel, SliderNormalIntensity, painter ? painter.HeightNormalIntensity : 1.4f, v => { if (painter) painter.HeightNormalIntensity = v; });
|
||||
WireSlider(panel, SliderParallaxIntensity, painter ? painter.ParallaxIntensity : 1f, v => { if (painter) painter.ParallaxIntensity = v; });
|
||||
@@ -375,11 +358,9 @@ public partial class PaintHud : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the camouflage sliders from the player's live PlayerCamoLit material (painter -> HUD). The
|
||||
/// values live on the material instance the painter builds in its Start, but the HUD binds earlier
|
||||
/// (the avatar's OnNetworkSpawn fires before Start), so they are not readable at WirePanel time. This
|
||||
/// runs once the painter reports <see cref="MeshPainter.IsReady"/> (see LateUpdate) so the sliders
|
||||
/// show the material's authored values instead of the placeholder 0 from the bind-time read.
|
||||
/// Seeds the camo + height sliders from the live material (painter -> HUD). The HUD binds before the
|
||||
/// painter's Start builds the instance, so the values are not readable at WirePanel time; this runs
|
||||
/// once the painter reports <see cref="MeshPainter.IsReady"/> (see LateUpdate).
|
||||
/// </summary>
|
||||
private void SyncCamoSliders()
|
||||
{
|
||||
@@ -416,9 +397,7 @@ public partial class PaintHud : MonoBehaviour
|
||||
if (picker) picker.SetColor(color);
|
||||
}
|
||||
|
||||
// Albedo/emission colour is split across two widgets: the picker drives RGB (keeping the painter's
|
||||
// current alpha), the standalone alpha slider drives alpha (keeping RGB). Kept independent so neither
|
||||
// widget's edit clobbers the other's channel.
|
||||
// Split colour editing: the picker drives RGB, the alpha slider drives alpha - neither clobbers the other.
|
||||
private static Color WithRgb(Color current, Color rgb) => new Color(rgb.r, rgb.g, rgb.b, current.a);
|
||||
private static Color WithAlpha(Color current, float a) => new Color(current.r, current.g, current.b, a);
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ public class PaintMode : GameMode
|
||||
|
||||
public override void Enter()
|
||||
{
|
||||
//if (Manager.Insect)
|
||||
// Manager.Insect.enabled = false;
|
||||
|
||||
if (Manager.Player)
|
||||
{
|
||||
Manager.Player.enabled = true;
|
||||
|
||||
@@ -4,32 +4,18 @@ using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// CharacterController-based player: camera-relative WASD movement, manual gravity
|
||||
/// and jumping, sliding off too-steep slopes/edges, and a wall-climb mode.
|
||||
/// CharacterController-based player: camera-relative WASD movement, manual gravity and jumping,
|
||||
/// sliding off too-steep slopes/edges, and a wall/ceiling-climb mode.
|
||||
///
|
||||
/// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down the
|
||||
/// wall (velocity ramps in via a gentle acceleration for precise placement), A/D strafe left/right relative to the camera, and the body always
|
||||
/// faces the wall. Holding the left mouse button and dragging up/down leans the visual mesh out
|
||||
/// of or into the wall (only the model + its paint collider move; the capsule stays put, so the
|
||||
/// lean can sink into the wall); holding the right mouse button and dragging left/right turns the
|
||||
/// body. Either pauses the camera orbit; the lean offset eases back to zero while moving, the turn
|
||||
/// is held until leaving the wall.
|
||||
/// On leaving, the body eases back to its movement direction. Jumping detaches and works
|
||||
/// normally; re-grab is blocked while rising and resumes once falling and pushing into a wall.
|
||||
/// Reaching the top edge mantles the character over onto the surface.
|
||||
/// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down (velocity
|
||||
/// ramps in for precise placement), A/D strafe camera-relative, the body faces the wall. Left-drag
|
||||
/// leans the visual mesh out of / into the wall (capsule stays put), right-drag turns the body;
|
||||
/// either pauses the camera orbit. Jumping detaches; reaching the top edge mantles over. Ceiling-climb
|
||||
/// shares the mode (entered by jumping up into a ceiling or climbing a wall into an overhang): the
|
||||
/// body stays upright and moves camera-relative in the horizontal plane; jump lets go. On the ground
|
||||
/// the same mouse scheme raises/lowers the mesh and turns the body without moving the capsule.
|
||||
///
|
||||
/// Ceiling-climb shares the same mode: entered by jumping up into a ceiling or by climbing a
|
||||
/// wall up into an overhang. The body stays upright and clings to the ceiling, moving in the
|
||||
/// horizontal plane camera-relative (like on the ground). Jump (or reaching a ceiling edge)
|
||||
/// lets go and drops.
|
||||
///
|
||||
/// On the ground the same mouse scheme fine-tunes a pose without moving the capsule: holding the
|
||||
/// left mouse button and dragging up/down raises/lowers the visual mesh, holding the right mouse
|
||||
/// button and dragging left/right turns the body. The raise eases back while walking and holds while
|
||||
/// standing; the turn holds until movement re-aims the body. Both pause the camera orbit while held.
|
||||
///
|
||||
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's
|
||||
/// example "PlayerController".
|
||||
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's "PlayerController".
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
[DisallowMultipleComponent]
|
||||
@@ -188,18 +174,12 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
/// <summary>True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal).</summary>
|
||||
public bool IsOnCeiling => _isWallClimbing && _onCeiling;
|
||||
|
||||
/// <summary>
|
||||
/// True while grounded and actually moving above the run threshold (the ground-locomotion state).
|
||||
/// Airborne, wall/ceiling, and standing still all read false. This is the same signal that drives
|
||||
/// the run bool, exposed so the pose system can pick its walk/run locomotion clips.
|
||||
/// </summary>
|
||||
/// <summary>True while grounded and moving above the run threshold (the ground-locomotion state);
|
||||
/// airborne / wall / standing all read false. The pose system picks its walk/run clips off this.</summary>
|
||||
public bool IsMovingOnGround => _movingOnGround;
|
||||
|
||||
/// <summary>
|
||||
/// True while airborne and descending faster than <see cref="fallAnimationSpeed"/> (scaled by player
|
||||
/// size): not grounded and not wall-climbing. Rising after a jump, standing, moving on the ground, and
|
||||
/// wall/ceiling states all read false. Exposed so the pose system can pick its fall clip.
|
||||
/// </summary>
|
||||
/// <summary>True while airborne (not wall-climbing) and descending faster than
|
||||
/// <see cref="fallAnimationSpeed"/> (scaled by player size). The pose system picks its fall clip off this.</summary>
|
||||
public bool IsFalling => _isFalling;
|
||||
|
||||
/// <summary>
|
||||
@@ -524,11 +504,6 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
_slideSpeed = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enters wall-climb mode only when the move input actively pushes into a wall
|
||||
/// (not on mere contact). Leaving while climbing is handled in
|
||||
/// <see cref="ApplyWallClimbMovement"/>.
|
||||
/// </summary>
|
||||
// Hunters may not grab a wall during the hide phase (they wait it out); every other role/phase climbs
|
||||
// normally. The controller only runs for the local owner, so _setup is this avatar's own role.
|
||||
private bool WallClimbBlockedNow()
|
||||
@@ -538,6 +513,10 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
&& _setup && _setup.Role == PlayerRole.Hunter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enters wall-climb mode only when the move input actively pushes into a wall (not on mere
|
||||
/// contact). Leaving while climbing is handled in <see cref="ApplyWallClimbMovement"/>.
|
||||
/// </summary>
|
||||
private void UpdateWallClimbState(Vector3 moveDir)
|
||||
{
|
||||
if (_reentryCooldown > 0f)
|
||||
@@ -589,17 +568,11 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Movement while climbing, for both walls and ceilings (distinguished by the surface
|
||||
/// normal). On a WALL: W/S climb straight up/down at a constant speed (camera pitch never
|
||||
/// tilts them), A/D strafe left/right relative to the camera, the body faces the wall, the
|
||||
/// left mouse button + vertical drag leans the mesh out of / into the wall via
|
||||
/// <see cref="_wallOffset"/> (capsule stays put), and the right
|
||||
/// mouse button + horizontal drag turns the body via <see cref="_wallYaw"/>. On a
|
||||
/// CEILING: the body stays upright and moves WASD camera-relative in the horizontal plane
|
||||
/// (like on the ground), facing the move direction unless the right mouse button + horizontal
|
||||
/// drag turns it; the lean-out drag pulls down off the ceiling. Surface normal and gap are
|
||||
/// re-measured each frame; reaching a wall's top edge
|
||||
/// mantles, climbing a wall up into an overhang hands off to the ceiling.
|
||||
/// Movement while climbing, walls and ceilings (distinguished by the surface normal). WALL: W/S
|
||||
/// climb straight up/down, A/D strafe camera-relative, the body faces the wall; left-drag leans the
|
||||
/// mesh via <see cref="_wallOffset"/> (capsule stays put), right-drag turns via <see cref="_wallYaw"/>.
|
||||
/// CEILING: upright, WASD camera-relative in the horizontal plane like on the ground. The surface is
|
||||
/// re-probed each frame; a wall's top edge mantles, climbing into an overhang hands off to the ceiling.
|
||||
/// </summary>
|
||||
private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, bool sprinting, float dt)
|
||||
{
|
||||
@@ -614,13 +587,11 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
UpdateCeilingClass();
|
||||
bool onCeiling = _onCeiling;
|
||||
|
||||
// Map W/S to UP/DOWN by which key pushes INTO the wall, the same way A/D's _wallStrafeSign
|
||||
// picks sideways from the camera: whichever vertical key drives the player toward the wall
|
||||
// climbs up. Facing the wall that's W; with the camera on the wall's side (walking backward
|
||||
// into it) it's S. Without this remap the grab (decided on the camera-relative world
|
||||
// direction) and the base-release (decided on raw S) disagree, so the climb flickers on/off.
|
||||
// Latched once per press and held until released; only walls remap (the ceiling crawl is
|
||||
// already camera-relative). vEff is the effective climb axis used for every up/down test below.
|
||||
// Map W/S to UP/DOWN by which key pushes INTO the wall (like A/D's strafe sign): facing the wall
|
||||
// that's W; with the camera on the wall's side (walking backward into it) it's S. Without the
|
||||
// remap the grab (camera-relative) and the base-release (raw S) disagree and the climb flickers.
|
||||
// Latched once per press; only walls remap (the ceiling crawl is already camera-relative).
|
||||
// vEff is the effective climb axis used for every up/down test below.
|
||||
bool climbing = Mathf.Abs(v) > 1e-4f;
|
||||
if (!climbing)
|
||||
_wallClimbSign = 0;
|
||||
@@ -655,12 +626,10 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
_wallOffset = 0f;
|
||||
}
|
||||
|
||||
// Re-measure the surface each frame, probing the kind we're committed to. On a ceiling
|
||||
// probe straight UP (ProbeCeiling), NOT along -_wallNormal: the ceiling normal tilts a
|
||||
// little toward the wall in an inner corner, so the -_wallNormal ray would clip the wall
|
||||
// and demote us to "wall" — then the hand-off re-promotes next frame, flipping the body's
|
||||
// facing ~9 deg every frame (the corner twitch). Stay latched on the ceiling until it is
|
||||
// genuinely gone overhead; only then fall back to the wall probe.
|
||||
// Re-measure the surface each frame, probing the kind we're committed to. On a ceiling probe
|
||||
// straight UP, NOT along -_wallNormal: in an inner corner that ray would clip the wall and
|
||||
// demote us to "wall", the hand-off re-promotes next frame, and the body's facing flips ~9 deg
|
||||
// every frame (the corner twitch). Stay latched until the ceiling is genuinely gone overhead.
|
||||
bool found;
|
||||
float gap;
|
||||
if (onCeiling)
|
||||
@@ -718,21 +687,18 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
else
|
||||
{
|
||||
// W/S climb straight up/down the wall (camera pitch never tilts them; speed ramps in
|
||||
// below). Strafe along the wall's own horizontal tangent, not the live camera-right
|
||||
// vector: feeding camera-right in here coupled movement to the orbit camera's
|
||||
// W/S climb straight up/down (camera pitch never tilts them). Strafe along the wall's own
|
||||
// horizontal tangent, not live camera-right: that coupled movement to the orbit camera's
|
||||
// per-frame re-aim and jittered sideways motion. The camera only picks the sign.
|
||||
Vector3 wallUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
|
||||
if (wallUp.sqrMagnitude > 1e-6f) wallUp.Normalize();
|
||||
Vector3 wallRight = Vector3.Cross(_wallNormal, wallUp);
|
||||
if (wallRight.sqrMagnitude > 1e-6f) wallRight.Normalize();
|
||||
|
||||
// Pick the strafe sign ONCE from the camera when a strafe begins, then hold it.
|
||||
// Re-deciding it every frame flips wallRight while rounding a curved (cylindrical)
|
||||
// wall: exactly at 90 deg to the camera the dot crosses zero, so the sideways input
|
||||
// inverts frame-to-frame and you lock there, jittering back and forth. Latching keeps
|
||||
// a strafe continuous around the curve; it resets when the strafe input releases so
|
||||
// the next one re-syncs to the camera.
|
||||
// Pick the strafe sign ONCE from the camera when a strafe begins, then hold it: re-deciding
|
||||
// per frame flips wallRight while rounding a curved wall (at 90 deg to the camera the dot
|
||||
// crosses zero and the input inverts frame-to-frame, locking you there). Resets when the
|
||||
// strafe input releases so the next one re-syncs to the camera.
|
||||
bool strafing = Mathf.Abs(h) > 1e-4f;
|
||||
if (!strafing)
|
||||
_wallStrafeSign = 0;
|
||||
@@ -769,12 +735,9 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// Left button + vertical drag sets the lean offset (mouse up = out from the surface, mouse
|
||||
// down = into it; the offset is signed and moves only the visual mesh, so it can sink into
|
||||
// the wall where the capsule can't); right button + horizontal drag turns the body. The
|
||||
// offset eases back to zero while moving and never detaches. On a WALL the turn is a held
|
||||
// yaw offset from the wall normal (kept until leaving); on a CEILING the body is free like
|
||||
// on the ground, so the drag rotates the transform directly. Either drag pauses the orbit.
|
||||
// Left drag = signed lean (moves only the visual mesh, so it can sink into the wall where the
|
||||
// capsule can't); right drag = body turn (WALL: held yaw offset from the wall normal; CEILING:
|
||||
// rotates the transform directly). The lean eases home while moving; either drag pauses the orbit.
|
||||
bool offsetDrag = found && InputUtil.LeftMouseHeld();
|
||||
bool yawDrag = found && InputUtil.RightMouseHeld();
|
||||
if (offsetDrag || yawDrag)
|
||||
@@ -799,10 +762,9 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
|
||||
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
|
||||
if (sprinting) climbSpeed *= sprintSpeedMultiplier;
|
||||
// Ramp the along-surface velocity instead of snapping to full speed: a gentle acceleration
|
||||
// lets a brief tap move you only a little, so you can place yourself precisely (especially
|
||||
// without sprint). Sprint scales the accel too, so it still reaches its higher speed quickly;
|
||||
// releasing decelerates at the ground decel rate for a prompt, controllable stop.
|
||||
// Ramp the along-surface velocity instead of snapping: a brief tap moves only a little (precise
|
||||
// placement). Sprint scales the accel too, so it reaches its higher speed just as quickly;
|
||||
// releasing decelerates at the ground rate for a prompt, controllable stop.
|
||||
Vector3 targetAlong = alongSurface.sqrMagnitude > 1e-6f
|
||||
? alongSurface.normalized * climbSpeed
|
||||
: Vector3.zero;
|
||||
@@ -825,14 +787,12 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
|
||||
_controller.Move(velocity * dt);
|
||||
|
||||
// Reaching walkable ground detaches back to normal grounded movement. The top-out hop
|
||||
// (carry over the lip) fires ONLY when topping out upward over an edge (vEff > 0). Arriving
|
||||
// at the floor by descending, OR pressing down/away (the effective down key, vEff < 0) at the
|
||||
// wall's base while grounded, just lets go cleanly with no hop -- the latter is the only detach
|
||||
// path that doesn't require having left the ground first, so a wall grabbed at its base can be
|
||||
// released by pressing away. We test walkable ground under the feet rather than trusting
|
||||
// isGrounded: the standoff correction can lift the capsule just off the surface, so at a
|
||||
// slope's top isGrounded stays false and the old check never fired.
|
||||
// Reaching walkable ground detaches. The top-out hop fires ONLY when topping out upward
|
||||
// (vEff > 0); descending to the floor, or pressing away (vEff < 0) at the base while grounded,
|
||||
// lets go cleanly with no hop - the latter is the only detach that doesn't require having left
|
||||
// the ground first, so a wall grabbed at its base can be released by pressing away. Walkable
|
||||
// ground is probed under the feet rather than trusting isGrounded (the standoff correction can
|
||||
// lift the capsule just off the surface, so at a slope's top isGrounded stays false).
|
||||
bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow();
|
||||
if (!onWalkableGround)
|
||||
{
|
||||
@@ -901,10 +861,8 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
|
||||
/// <summary>
|
||||
/// Offsets only the visual model (mesh + its baked paint collider) along the wall normal by the
|
||||
/// given signed distance (+ = out from the wall, - = into it), relative to the model's authored
|
||||
/// local position. The CharacterController capsule is untouched, so the lean can sink into the
|
||||
/// wall where the capsule would be blocked. Applied in the player's local space so it tracks the
|
||||
/// body yaw; offset 0 restores the authored pose.
|
||||
/// given signed distance (+ = out, - = into the wall), relative to its authored local position.
|
||||
/// The capsule is untouched, so the lean can sink into the wall where the capsule would be blocked.
|
||||
/// </summary>
|
||||
private void ApplyMeshOffset(float offset) => SetMeshLean(_wallNormal, offset);
|
||||
|
||||
@@ -922,11 +880,9 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the resting mesh offset every non-climbing frame in a single write of
|
||||
/// <see cref="meshOffsetTarget"/>: eases the leftover wall lean back toward the authored pose
|
||||
/// (latched exit normal) while composing in the ground mouse raise and any pose y-offset. Writing
|
||||
/// unconditionally (even at rest) keeps this the sole owner of the offset transform, so a held
|
||||
/// ground raise stays applied after the residual has reached zero. Offset 0 restores the pose.
|
||||
/// Re-applies the resting mesh offset every non-climbing frame in a single write: eases the
|
||||
/// leftover wall lean home (along the latched exit normal) while composing in the ground raise.
|
||||
/// Writing unconditionally keeps this the sole owner of the offset transform.
|
||||
/// </summary>
|
||||
private void EaseMeshOffsetToRest(float dt)
|
||||
{
|
||||
@@ -936,13 +892,11 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ground pose-adjust via the mouse, mirroring the wall-climb scheme: holding the LEFT button and
|
||||
/// dragging up/down raises/lowers the visual mesh vertically (<see cref="_groundOffset"/>, capsule
|
||||
/// untouched), holding the RIGHT button and dragging left/right turns the body (world-up yaw). The
|
||||
/// raise eases back to the authored height while moving and holds while standing or dragging; the
|
||||
/// turn persists until movement re-aims the body. Returns true while a right-button turn is active
|
||||
/// so the caller skips auto-rotate-toward-movement that frame. Call site gates on ControlEnabled so
|
||||
/// paint mode keeps the left button for painting.
|
||||
/// Ground pose-adjust, mirroring the wall-climb mouse scheme: LEFT drag raises/lowers the visual
|
||||
/// mesh (<see cref="_groundOffset"/>, capsule untouched), RIGHT drag turns the body. The raise
|
||||
/// eases back while moving and holds while standing; the turn persists until movement re-aims the
|
||||
/// body. Returns true while a right-button turn is active so the caller skips
|
||||
/// rotate-toward-movement that frame. The call site gates on ControlEnabled (paint mode owns the left button).
|
||||
/// </summary>
|
||||
private bool HandleGroundPoseAdjust(float h, float v, float dt)
|
||||
{
|
||||
@@ -1013,12 +967,10 @@ public class PlayerCharacterController : MonoBehaviour
|
||||
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).
|
||||
/// True if the collider belongs to ANY player avatar (self or a networked remote): avatars still
|
||||
/// collide physically, but another chameleon's body must never register as climbable. Finds the
|
||||
/// controller even on remotes, where the component is disabled (GetComponentInParent only checks
|
||||
/// the GameObject's active state, not the component's enabled flag).
|
||||
/// </summary>
|
||||
private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent<PlayerCharacterController>();
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ public class PlayerControlMode : GameMode
|
||||
|
||||
public override void Enter()
|
||||
{
|
||||
//if (Manager.Insect)
|
||||
// Manager.Insect.enabled = false;
|
||||
|
||||
if (Manager.Player)
|
||||
{
|
||||
Manager.Player.enabled = true;
|
||||
|
||||
@@ -4,42 +4,28 @@ using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Replicates mesh painting across the network as brush-stamp commands (not pixels). The owner
|
||||
/// captures every committed stamp from its <see cref="MeshPainter"/>, batches the frame's stamps and
|
||||
/// forwards them; remote avatars replay each stamp through their own painter onto the identical
|
||||
/// mesh/UVs. Position/animation come from the other sync components; this only carries the tiny paint
|
||||
/// commands (~tens of bytes each), so two players see each other paint live without streaming the
|
||||
/// 12 MB of paint maps.
|
||||
/// Replicates mesh painting across the network as brush-stamp COMMANDS (tens of bytes each), not
|
||||
/// pixels: the owner batches each frame's committed stamps and forwards them; remote avatars replay
|
||||
/// them through their own painter onto the identical mesh/UVs - live painting without streaming maps.
|
||||
///
|
||||
/// Undo/redo replicates as commands too, not pixels: every replica keeps the SAME per-stroke history
|
||||
/// (see MeshPainter.Undo), so the owner just forwards a per-stroke CHECKPOINT marker and an undo/redo
|
||||
/// RESTORE id. Each peer reproduces the move from its own history. A peer that lacks the target id (a
|
||||
/// late joiner asked to undo past its join) heals by pulling a fresh snapshot from the OWNER - the only
|
||||
/// guaranteed-correct source of the post-restore state, so this works for a remote client and for the
|
||||
/// host's own replica alike. Stamps are capped per frame but their overflow carries to the next frame
|
||||
/// rather than being decimated, and checkpoint/restore markers never overtake the stamps they finalize,
|
||||
/// so every replica's history stays aligned and the heal path stays a rare exception, not the norm.
|
||||
/// Undo/redo replicates as commands too: every replica keeps the SAME per-stroke history (see
|
||||
/// MeshPainter.Undo), so the owner forwards per-stroke CHECKPOINT markers and undo/redo RESTORE ids.
|
||||
/// A peer that lacks the target id (undo past its join point) heals by pulling a fresh snapshot from
|
||||
/// the OWNER - the only guaranteed-correct source. Stamps are capped per frame with the overflow
|
||||
/// carried over (never dropped), and markers never overtake the stamps they finalize, so histories
|
||||
/// stay aligned and the heal path stays rare.
|
||||
///
|
||||
/// Late join: a client that connects AFTER painting happened gets the accumulated result. On spawn it
|
||||
/// pulls a one-time compressed snapshot of the whole map set from the server (chunked, throttled to
|
||||
/// respect the transport's reliable window), buffers any live events that arrive during the transfer,
|
||||
/// and after applying the snapshot replays the buffered events newer than the snapshot's cutoff
|
||||
/// sequence - so no stroke is lost or double-applied across the join window. The applied snapshot also
|
||||
/// becomes the joiner's history baseline.
|
||||
/// Late join: on spawn the client pulls a compressed snapshot from the server (chunked, throttled),
|
||||
/// buffers live events during the transfer, and afterwards replays only those newer than the
|
||||
/// snapshot's cutoff sequence - nothing lost or double-applied across the join window.
|
||||
///
|
||||
/// Respawn carry: a scene switch (lobby <-> map) despawns the avatar and spawns a new prefab, which
|
||||
/// would otherwise start blank. On despawn the owner parks a snapshot of its painting on the persistent
|
||||
/// MatchManager; the replacement avatar's owner takes it on spawn and, once its painter is ready, loads it
|
||||
/// and BroadcastLoadedPaint()s it - the exact path a manual load uses - so the painting follows the player
|
||||
/// across the respawn for every peer. A real disconnect clears the parked copy, so sessions don't bleed.
|
||||
/// On respawn this carry-broadcast and the replica's own late-join request both deliver a full snapshot at
|
||||
/// once, so transfers are id-tagged (only the active one reassembles/applies) and cutoff-ordered (an older
|
||||
/// snapshot never overwrites a newer one) - otherwise the two would share a buffer and decode corrupted.
|
||||
/// Respawn carry: a scene switch despawns the avatar, which would otherwise respawn blank. The owner
|
||||
/// parks a snapshot on the persistent MatchManager; the replacement avatar loads and rebroadcasts it
|
||||
/// (the manual-load path). The carry-broadcast and the replica's own late-join request can overlap, so
|
||||
/// transfers are id-tagged and cutoff-ordered - they never mix buffers or regress to older state.
|
||||
///
|
||||
/// Emission intensity is the one paint property that is NOT baked into the maps (it's a global
|
||||
/// multiplier on the material's _EmissionColor), so it can't ride the stamp/snapshot path. It rides its
|
||||
/// own owner-authoritative NetworkVariable; the remote painter's per-frame RefreshEmissionColor picks up
|
||||
/// the applied field, and a late joiner gets the current value on spawn for free.
|
||||
/// Emission intensity is NOT baked into the maps (a global material multiplier), so it rides its own
|
||||
/// owner-authoritative NetworkVariable instead of the stamp/snapshot path.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class PlayerPaintSync : NetworkBehaviour
|
||||
@@ -78,26 +64,21 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
}
|
||||
private readonly List<OutItem> _outgoing = new List<OutItem>();
|
||||
|
||||
// Global emission-intensity multiplier (MeshPainter.emissionIntensity) — a per-player material value,
|
||||
// not part of the painted maps. Owner-authoritative like the rest; the remote applies it to its
|
||||
// painter, whose per-frame RefreshEmissionColor pushes it to the material (no extra material poke).
|
||||
// Global emission-intensity multiplier - a per-player material value, not part of the painted maps.
|
||||
// The remote applies it to its painter; RefreshEmissionColor pushes it to the material per frame.
|
||||
private readonly NetworkVariable<float> _emissionIntensity =
|
||||
new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
||||
|
||||
// Per-player camouflage knobs on the PlayerCamoLit material (x = HalfLambert, y = HalfLambert
|
||||
// brightness, z = specular dampen). Exactly like emission intensity these are per-player material
|
||||
// values, not painted into the maps, so they ride their own owner-authoritative NetworkVariable. The
|
||||
// remote applies them to its material once its painter is ready and again on every change; a late
|
||||
// joiner gets the current value on spawn for free. The default mirrors Player.mat's authored camo so
|
||||
// the brief pre-publish spawn frame shows no flash (the owner re-publishes the real value once ready).
|
||||
// Per-player camo knobs on the PlayerCamoLit material (x = HalfLambert, y = its brightness,
|
||||
// z = specular dampen) - material values like emission intensity, applied once the remote painter
|
||||
// is ready and on every change. The default mirrors Player.mat's authored camo so the brief
|
||||
// pre-publish spawn frame shows no flash.
|
||||
private readonly NetworkVariable<Vector3> _camo =
|
||||
new(new Vector3(0.5f, 0f, 0.1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
||||
private bool _camoApplyPending; // remote: apply _camo.Value to the material once IsReady / on change
|
||||
|
||||
// Painted-height knobs (x = occlusion AO threshold, y = relief normal intensity, z = parallax
|
||||
// intensity). Same per-player material values as the camo knobs - not baked into the maps - so they
|
||||
// ride their own owner-authoritative NetworkVariable and apply once the remote painter is ready. The
|
||||
// default mirrors the PlayerCamoLit shader defaults (0.2 / 1.4 / 1).
|
||||
// intensity). Same treatment as the camo knobs; the default mirrors the shader defaults.
|
||||
private readonly NetworkVariable<Vector3> _height =
|
||||
new(new Vector3(0.2f, 1.4f, 1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
||||
private bool _heightApplyPending; // remote: apply _height.Value to the material once IsReady / on change
|
||||
@@ -125,20 +106,16 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
private bool _snapReadyToApply; // last chunk arrived; apply once the painter is ready
|
||||
private readonly List<NetEvent> _buffered = new List<NetEvent>();
|
||||
|
||||
// A scene-switch respawn delivers TWO full snapshots to the same replica at once: this replica's own
|
||||
// late-join RequestSnapshotRpc AND the owner's carry BroadcastLoadedPaint. Without isolation they share
|
||||
// one reassembly buffer and one End() trigger, so a half-filled/mixed Deflate blob gets decoded -> the
|
||||
// "Corrupted data" warning, paint never applies. Each outgoing transfer carries a unique id; only the
|
||||
// active one fills the buffer and applies, and a stale chunk/End from a superseded transfer is ignored.
|
||||
// A scene-switch respawn delivers TWO snapshots at once (the replica's late-join request AND the
|
||||
// owner's carry broadcast); without isolation they'd share one reassembly buffer and decode a mixed
|
||||
// blob. Each transfer carries a unique id; only the active one fills the buffer and applies.
|
||||
private int _activeXferId;
|
||||
// Highest snapshot cutoff accepted so far. A transfer carrying OLDER state is dropped, so a late blank
|
||||
// late-join (server replica still empty at capture) can't overwrite the newer carry. Equal is accepted
|
||||
// so the owner-snapshot heal path (same cutoff, but the diverged peer genuinely needs it) still works.
|
||||
// Highest snapshot cutoff accepted so far: an OLDER transfer is dropped (a late blank late-join must
|
||||
// not overwrite the newer carry); EQUAL is accepted so the owner-snapshot heal path still works.
|
||||
private int _acceptedCutoffFloor = int.MinValue;
|
||||
|
||||
// Owner-only: painting carried over from the avatar a scene switch just despawned (taken from the
|
||||
// persistent MatchManager on spawn). Applied in LateUpdate once this fresh painter is ready, then
|
||||
// rebroadcast to peers via the same path a manual load uses. Null = nothing to carry.
|
||||
// Owner-only: painting carried over from the just-despawned avatar (taken from MatchManager on
|
||||
// spawn), applied in LateUpdate once this fresh painter is ready. Null = nothing to carry.
|
||||
private byte[] _pendingCarry;
|
||||
|
||||
private void Awake() => _painter = GetComponentInChildren<MeshPainter>(true);
|
||||
@@ -154,8 +131,6 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
_painter.OnHistoryCheckpoint += OnLocalCheckpoint;
|
||||
_painter.OnHistoryRestore += OnLocalRestore;
|
||||
|
||||
// Carry the painting from the avatar a scene switch just despawned (if any) onto this fresh one.
|
||||
// Applied in LateUpdate once the painter has finished setup, then broadcast to peers.
|
||||
if (MatchManager.Instance) _pendingCarry = MatchManager.Instance.TakeCarriedPaint();
|
||||
}
|
||||
else
|
||||
@@ -167,8 +142,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
_emissionIntensity.OnValueChanged += OnEmissionChanged;
|
||||
_painter.emissionIntensity = _emissionIntensity.Value;
|
||||
|
||||
// Track the owner's camo knobs; apply the current (late-join) value once the material exists
|
||||
// (deferred to LateUpdate - the setter is a no-op before the painter's Start builds the instance).
|
||||
// Deferred to LateUpdate - the camo setters are a no-op before the painter's Start builds
|
||||
// the material instance.
|
||||
_camo.OnValueChanged += OnCamoChanged;
|
||||
_camoApplyPending = true;
|
||||
|
||||
@@ -176,8 +151,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
_height.OnValueChanged += OnHeightChanged;
|
||||
_heightApplyPending = true;
|
||||
|
||||
// The server (host) has been present since the start, so its replica already holds the full
|
||||
// paint via the live stream; only a genuine late joiner needs the baseline pulled over.
|
||||
// The host's replica already holds the full paint via the live stream; only a genuine
|
||||
// late joiner needs the baseline pulled over.
|
||||
if (!IsServer)
|
||||
{
|
||||
_awaitingSnapshot = true;
|
||||
@@ -191,9 +166,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
{
|
||||
if (_painter && IsOwner)
|
||||
{
|
||||
// Park the current painting so the avatar this scene switch spawns next can restore it. A real
|
||||
// disconnect clears the parked copy (MatchManager.ReturnToMainOnly), so a fresh session never
|
||||
// inherits it. Captured here, the latest possible point, so last-second strokes are included.
|
||||
// Park the painting for the avatar the scene switch spawns next. Captured here, the latest
|
||||
// possible point, so last-second strokes are included (a real disconnect clears the copy).
|
||||
if (MatchManager.Instance && _painter.TryCaptureSnapshot(out byte[] snap))
|
||||
MatchManager.Instance.StoreCarriedPaint(snap);
|
||||
|
||||
@@ -255,8 +229,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
_awaitingSnapshot = false;
|
||||
}
|
||||
|
||||
// Remote replica: apply the owner's camo onto this avatar's material once it exists / whenever it
|
||||
// changed. The owner drives its own material via the HUD + persistence, so it never applies here.
|
||||
// Remote replica: apply the owner's camo once the material exists / on change (the owner drives
|
||||
// its own material via HUD + persistence).
|
||||
if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady)
|
||||
{
|
||||
Vector3 c = _camo.Value;
|
||||
@@ -279,9 +253,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
if (!IsOwner)
|
||||
return;
|
||||
|
||||
// Restore a carried-over painting onto this fresh avatar once the painter is ready, then propagate
|
||||
// it to peers exactly like a manual load (BroadcastLoadedPaint). One-shot: cleared whether or not it
|
||||
// took, so a failed/empty carry never retries every frame.
|
||||
// Restore a carried-over painting once the painter is ready, then propagate it like a manual
|
||||
// load. One-shot: cleared whether or not it took, so a failed carry never retries every frame.
|
||||
if (_pendingCarry != null && _painter && _painter.IsPaintReady)
|
||||
{
|
||||
byte[] carry = _pendingCarry;
|
||||
@@ -293,21 +266,18 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// Publish the owner's global emission intensity when the HUD slider changes it (delta-only; the
|
||||
// NetworkVariable dirties only on an actual change, so assigning the same value is a no-op).
|
||||
// Delta-only publish: the NetworkVariable dirties only on an actual change.
|
||||
if (_painter && _emissionIntensity.Value != _painter.emissionIntensity)
|
||||
_emissionIntensity.Value = _painter.emissionIntensity;
|
||||
|
||||
// Publish the owner's camo knobs when the HUD/persistence changes them (delta-only; the
|
||||
// NetworkVariable dirties only on an actual change). Gated on IsReady so the pre-Start 0-read is
|
||||
// never published - that would briefly flatten remotes' lighting until the real value lands.
|
||||
// Camo/height publish, delta-only and gated on IsReady so the pre-Start 0-read is never
|
||||
// published (it would briefly flatten remotes' lighting until the real value lands).
|
||||
if (_painter && _painter.IsReady)
|
||||
{
|
||||
var camo = new Vector3(_painter.CamoHalfLambert, _painter.CamoHalfLambertBrightness, _painter.CamoSpecularDampen);
|
||||
if (_camo.Value != camo)
|
||||
_camo.Value = camo;
|
||||
|
||||
// Publish the painted-height knobs the same way (delta-only, ready-gated).
|
||||
var height = new Vector3(_painter.OcclusionThreshold, _painter.HeightNormalIntensity, _painter.ParallaxIntensity);
|
||||
if (_height.Value != height)
|
||||
_height.Value = height;
|
||||
@@ -494,10 +464,9 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A peer that can't reproduce a restore from its own history asks the OWNER - the only guaranteed
|
||||
/// correct source of the post-restore state - for a fresh snapshot, addressed back to just this peer.
|
||||
/// Works uniformly for a remote client and for the host's own replica (which can't snapshot itself);
|
||||
/// the previous host short-circuit silently dropped the heal and left the host diverged.
|
||||
/// A peer that can't reproduce a restore from its own history asks the OWNER (the only guaranteed
|
||||
/// correct source of the post-restore state) for a fresh snapshot addressed back to just this peer.
|
||||
/// Works uniformly for a remote client and for the host's own replica.
|
||||
/// </summary>
|
||||
private void RequestSnapshotFallback()
|
||||
{
|
||||
@@ -533,9 +502,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
if (!_painter || !_painter.TryCaptureSnapshot(out byte[] data))
|
||||
return;
|
||||
|
||||
// Everything the captured maps already contain: the owner's send counter for our own avatar,
|
||||
// otherwise the last message this server replica has applied. The maps represent the painter's
|
||||
// current history entry, so its checkpoint id tags the baseline the joiner adopts.
|
||||
// Cutoff = everything the captured maps already contain (owner: its send counter; otherwise the
|
||||
// last message this replica applied). The checkpoint id tags the baseline the joiner adopts.
|
||||
int capturedSeq = IsOwner ? _seq : _seqHorizon;
|
||||
int baselineId = _painter.CurrentCheckpointId;
|
||||
LogSync($"serving late-join snapshot to client {p.Receive.SenderClientId} (cutoff {capturedSeq}, baseline {baselineId}, {data.Length} bytes)");
|
||||
@@ -612,12 +580,10 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
|
||||
// ---- Load re-sync (owner -> server -> every other peer) ----
|
||||
//
|
||||
// Loading a saved painting replaces the owner's whole map set at once (no stamps) and clears its undo
|
||||
// history, so the live stream cannot carry it. The owner captures a snapshot, uploads it to the server
|
||||
// chunked (same throttling as the download), and the server re-broadcasts it to every other peer
|
||||
// through the existing snapshot client RPCs - including the host's own replica via its client-id
|
||||
// target. The snapshot's baseline id makes every peer reset its history to the loaded baseline too. A
|
||||
// cutoff sequence drops any in-flight/buffered events already superseded by the loaded baseline.
|
||||
// Loading a saved painting replaces the whole map set and clears undo history, so the live stream
|
||||
// cannot carry it. The owner uploads a snapshot to the server (chunked), which re-broadcasts it to
|
||||
// every other peer; the baseline id resets each peer's history and the cutoff sequence drops any
|
||||
// in-flight/buffered events the loaded baseline supersedes.
|
||||
|
||||
private byte[] _uploadBuffer; // server-side reassembly of the owner's uploaded snapshot
|
||||
private int _uploadCutoff;
|
||||
@@ -687,9 +653,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
||||
{
|
||||
if (_uploadBuffer == null) return;
|
||||
|
||||
// Load broadcast: fan out to every connected peer except the owner (who already loaded it
|
||||
// locally). Fallback heal: send only to the single requester. The host, when it is not the owner,
|
||||
// is included by its client id and applies it like any other remote.
|
||||
// Load broadcast: every peer except the owner (who already loaded it locally). Fallback heal:
|
||||
// only the single requester. A non-owner host is included by its client id like any remote.
|
||||
ulong[] targets = _uploadBroadcast ? OtherClientIds(OwnerClientId) : new[] { _uploadTarget };
|
||||
if (targets.Length > 0)
|
||||
StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff, _uploadBaselineId));
|
||||
|
||||
@@ -8,26 +8,16 @@ using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Sits next to the <see cref="PlayerCharacterController"/> and holds a list of <see cref="PlayerPose"/>s
|
||||
/// plus the active index. The pose system owns all character animation — the Animator is left empty —
|
||||
/// so it drives locomotion itself: standing still (or airborne / on a wall / on the ceiling) holds the
|
||||
/// active pose's idle clip, walking on the ground plays that pose's optional walk clip, and sprinting
|
||||
/// on the ground plays its optional run clip. Either locomotion clip is optional; when a pose leaves
|
||||
/// one null, the idle pose is simply held in that state.
|
||||
/// Holds a list of <see cref="PlayerPose"/>s plus the active index. The pose system owns ALL character
|
||||
/// animation (the Animator is left empty): standing / airborne / wall / ceiling holds the active pose's
|
||||
/// idle clip, walking/sprinting on the ground plays its optional walk/run clip (unset = idle held).
|
||||
///
|
||||
/// One key cycles to the next pose (wrapping), and sprinting in any state (ground, air, wall, ceiling)
|
||||
/// snaps the selection back to the default pose. The selection is held continuously, so cycling or the
|
||||
/// sprint reset shows up immediately instead of only once the player comes to a stop.
|
||||
///
|
||||
/// Clips are played through a tiny <see cref="PlayableGraph"/> (a two-input
|
||||
/// <see cref="AnimationMixerPlayable"/> on an <see cref="AnimationPlayableOutput"/>) that drives the
|
||||
/// Animator directly. Switching clip — between poses, or between idle/walk/run within a pose —
|
||||
/// crossfades the outgoing and incoming clips over <see cref="poseBlendDuration"/> instead of snapping.
|
||||
/// Each pose also carries its own CharacterController capsule (center Y / radius / height): the active
|
||||
/// pose's dimensions are eased toward and written through the controller so a low pose (ball, star) can
|
||||
/// crawl under furniture the default standing capsule is too tall for. A per-pose camera y-offset is
|
||||
/// likewise eased and routed to the controller's camera target, framing each pose for the
|
||||
/// ThirdPersonCamera without moving the body.
|
||||
/// One key cycles poses (wrapping); sprinting in any state snaps back to the default pose, held
|
||||
/// continuously so the reset shows up immediately. Clips play through a tiny <see cref="PlayableGraph"/>
|
||||
/// (two-input mixer on an <see cref="AnimationPlayableOutput"/>) that crossfades every clip switch over
|
||||
/// <see cref="poseBlendDuration"/>. Each pose also carries its own CharacterController capsule (eased,
|
||||
/// so a low pose can crawl under furniture) and per-pose camera y-offsets (third-person framing and
|
||||
/// first-person eye).
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(PlayerCharacterController))]
|
||||
[DisallowMultipleComponent]
|
||||
@@ -83,11 +73,9 @@ public class PlayerPoseController : MonoBehaviour
|
||||
/// <summary>Locomotion category that selects which clip the active pose plays.</summary>
|
||||
public enum Locomotion { Idle, Walk, Run, Fall }
|
||||
|
||||
// Network drive: a remote avatar's PlayerCharacterController and CharacterController are disabled
|
||||
// (NetworkTransform owns its transform), so its movement-state flags are dead. Instead of reading
|
||||
// local input/physics, such an avatar takes its pose index and locomotion category from the owner
|
||||
// via SetNetworkState (pushed by PlayerAnimationSync). _currentLocomotion is the category resolved
|
||||
// this frame, published by the owner for replication.
|
||||
// Network drive: a remote avatar's controllers are disabled (NetworkTransform owns the transform),
|
||||
// so its movement flags are dead - pose index + locomotion come from the owner via SetNetworkState.
|
||||
// _currentLocomotion is the category resolved this frame, published by the owner for replication.
|
||||
private bool _networkControlled;
|
||||
private Locomotion _networkLoco;
|
||||
private Locomotion _currentLocomotion;
|
||||
@@ -118,13 +106,10 @@ public class PlayerPoseController : MonoBehaviour
|
||||
private float _capCenterY, _capRadius, _capHeight;
|
||||
private float _capBlend;
|
||||
|
||||
// Rest pose snapshot of the whole Animator subtree, taken in Awake before the Animator first
|
||||
// evaluates. An AnimationClipPlayable only writes the bones its curves cover, so a clip that
|
||||
// doesn't key a bone (an empty clip like the T-pose, or a partial one) would otherwise leave that
|
||||
// bone frozen on the previous animation. Restoring this before each pose start makes every pose
|
||||
// define the full body. The controller's model-offset transform (meshOffsetTarget) is explicitly
|
||||
// dropped from this set in CaptureRestPose — with the humanoid rig the Animator sits on that node,
|
||||
// so it is the root of this subtree, and resetting it would wipe the wall-lean / ground raise.
|
||||
// Rest-pose snapshot of the Animator subtree, taken before the Animator first evaluates. A clip only
|
||||
// writes the bones its curves cover, so un-keyed bones would stay frozen on the previous animation;
|
||||
// restoring rest first makes every pose define the full body. The controller's meshOffsetTarget is
|
||||
// dropped from the set (see CaptureRestPose) so the wall-lean / ground raise is never wiped.
|
||||
private Transform[] _restBones;
|
||||
private Vector3[] _restPos;
|
||||
private Quaternion[] _restRot;
|
||||
@@ -175,11 +160,10 @@ public class PlayerPoseController : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Resolution freeze: hold the currently-evaluated pose. Keep the graph playing (so it re-asserts the
|
||||
// frozen frame every render, even under Animator culling) but zero the mixer's speed so clip time
|
||||
// stops advancing, and skip everything below so no locomotion/rest-reset moves a bone. The per-clip
|
||||
// speeds PlayPose writes onto the mixer INPUTS are unaffected; only the mixer's own multiplier is
|
||||
// zeroed here and restored on the first un-frozen frame just below.
|
||||
// Resolution freeze: keep the graph playing (it re-asserts the frozen frame every render, even
|
||||
// under Animator culling) but zero the MIXER's speed so clip time stops advancing; skip everything
|
||||
// below so no locomotion/rest-reset moves a bone. The per-input clip speeds are untouched; the
|
||||
// mixer multiplier is restored on the first un-frozen frame just below.
|
||||
if (Frozen)
|
||||
{
|
||||
if (_graph.IsValid())
|
||||
@@ -192,8 +176,7 @@ public class PlayerPoseController : MonoBehaviour
|
||||
if (_mixer.IsValid() && PlayableExtensions.GetSpeed(_mixer) == 0d)
|
||||
PlayableExtensions.SetSpeed(_mixer, 1d);
|
||||
|
||||
// Local control: the cycle key and the sprint reset choose the pose. A network-driven (remote)
|
||||
// avatar skips this — its pose index is pushed in from the owner via SetNetworkState.
|
||||
// Local control only - a remote's pose index is pushed in via SetNetworkState.
|
||||
if (!_networkControlled)
|
||||
{
|
||||
if (CycleKeyPressed())
|
||||
@@ -207,12 +190,9 @@ public class PlayerPoseController : MonoBehaviour
|
||||
|
||||
PlayerPose pose = ActivePose();
|
||||
|
||||
// Resolve the locomotion category (idle / walk / run / fall) and pick its clip. Locally the
|
||||
// category comes from the controller's movement state; on a remote avatar it is the replicated
|
||||
// value. It is stored so the owner's PlayerAnimationSync can publish it. ClipFor falls back to
|
||||
// the idle clip for an unset optional clip, so a remote avatar (same pose asset) resolves to
|
||||
// exactly the clip the owner shows; PlayPose crossfades on any change and hands the rig back
|
||||
// only when there is no clip at all (empty list / null idle clip).
|
||||
// Resolve the locomotion category and its clip: locally from the controller's movement state, on
|
||||
// a remote the replicated value. ClipFor falls back to idle for unset optional clips, so a remote
|
||||
// (same pose asset) resolves to exactly the clip the owner shows.
|
||||
_currentLocomotion = _networkControlled ? _networkLoco : ResolveLocalLocomotion();
|
||||
AnimationClip clip = ClipFor(pose, _currentLocomotion, out float speed);
|
||||
PlayPose(clip, speed);
|
||||
@@ -222,10 +202,8 @@ public class PlayerPoseController : MonoBehaviour
|
||||
if (_networkControlled)
|
||||
return;
|
||||
|
||||
// CharacterController capsule: each pose carries its own center Y / radius / height so a low pose
|
||||
// (ball, star) can crawl where the default standing capsule is too tall. Eased toward the active
|
||||
// pose's dimensions when the pose changes so the collider resizes smoothly instead of popping; a
|
||||
// pose that leaves them unset (height/radius <= 0) keeps the default capsule.
|
||||
// Per-pose collision capsule so a low pose can crawl where the standing capsule is too tall;
|
||||
// unset dimensions (<= 0) keep the default capsule.
|
||||
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
|
||||
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
|
||||
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
|
||||
@@ -240,9 +218,8 @@ public class PlayerPoseController : MonoBehaviour
|
||||
: cameraPoseTarget;
|
||||
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
|
||||
|
||||
// First-person eye: a separate per-pose offset, eased the same way but NOT routed to the shared
|
||||
// camera target (that would also move the ThirdPersonCamera). The FirstPersonController reads this
|
||||
// and applies it to its own eye position only.
|
||||
// Separate per-pose first-person eye offset - NOT routed to the shared camera target (that would
|
||||
// also move the ThirdPersonCamera); the FirstPersonController reads it directly.
|
||||
float firstPersonTarget = pose ? pose.FirstPersonCameraTargetYOffset : 0f;
|
||||
_firstPersonCameraOffset = cameraOffsetSpeed > 0f
|
||||
? Mathf.MoveTowards(_firstPersonCameraOffset, firstPersonTarget, cameraOffsetSpeed * Time.deltaTime)
|
||||
@@ -364,7 +341,7 @@ public class PlayerPoseController : MonoBehaviour
|
||||
|
||||
private bool CycleKeyPressed()
|
||||
{
|
||||
if (TypingGuard.IsTyping) return false; // don't cycle pose while typing in a text field
|
||||
if (TypingGuard.IsTyping) return false;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var keyboard = Keyboard.current;
|
||||
return keyboard != null && keyboard[cycleKey].wasPressedThisFrame;
|
||||
@@ -396,16 +373,13 @@ public class PlayerPoseController : MonoBehaviour
|
||||
|
||||
AdvanceBlend(Time.deltaTime);
|
||||
|
||||
// Set every frame (not just on clip change) so tuning a pose's speed slider live in the inspector
|
||||
// takes effect immediately and re-selecting the same clip keeps its speed. The outgoing pose on
|
||||
// input 0 keeps the speed it carried, so a crossfade fades it out at its own pace.
|
||||
// Set every frame so live speed-slider tuning takes effect immediately; the outgoing pose on
|
||||
// input 0 keeps the speed it carried, fading out at its own pace.
|
||||
if (_toPlayable.IsValid())
|
||||
PlayableExtensions.SetSpeed(_toPlayable, speed);
|
||||
|
||||
// Snap un-keyed bones to rest every frame, so each mixer input blends relative to rest: a
|
||||
// partial or empty clip (e.g. the T-pose) defines the whole body, and a crossfade interpolates
|
||||
// both poses against the same baseline. The graph overrides the keyed bones, weighted, when it
|
||||
// evaluates later this frame.
|
||||
// Snap un-keyed bones to rest every frame so each mixer input blends relative to the same
|
||||
// baseline; the graph then overrides the keyed bones when it evaluates later this frame.
|
||||
ResetToRestPose();
|
||||
|
||||
if (!_graph.IsPlaying())
|
||||
@@ -516,12 +490,9 @@ public class PlayerPoseController : MonoBehaviour
|
||||
_restScale[i] = t.localScale;
|
||||
}
|
||||
|
||||
// Drop the model-offset transform from the reset set so the per-frame ResetToRestPose never
|
||||
// touches it: the wall-lean / ground raise (PlayerCharacterController.SetMeshLean) is its sole
|
||||
// writer. With the humanoid rig the Animator shares this node, putting it in the captured subtree;
|
||||
// resetting it each frame is exactly what was wiping the offsets. Nulling the slot makes
|
||||
// ResetToRestPose's null-guard skip it. _player is resolved (and meshOffsetTarget too, since the
|
||||
// controller's Awake runs first at its lower execution order) before this runs.
|
||||
// Drop the model-offset transform from the reset set: the wall-lean / ground raise (SetMeshLean)
|
||||
// is its sole writer, and with the humanoid rig the Animator shares this node - resetting it each
|
||||
// frame was exactly what wiped the offsets. Nulling the slot makes ResetToRestPose skip it.
|
||||
Transform offsetTarget = _player ? _player.MeshOffsetTarget : null;
|
||||
if (offsetTarget)
|
||||
{
|
||||
|
||||
@@ -139,12 +139,10 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
void OnDisable()
|
||||
{
|
||||
InputUtil.SetCursorLocked(false);
|
||||
// Deliberately DON'T zero the pan here. The only thing that disables this component is FreeCamMode
|
||||
// taking over, and the freecam snaps to the live (still panned) view. Resetting the offset now would
|
||||
// jump the orbit camera the brain blends FROM to center while the freecam sits at the panned pose -
|
||||
// the visible "snap to 0, then translate to the offset" on the paint->freecam switch. Leaving the
|
||||
// offset frozen keeps blend-from == blend-to (both panned) -> a seamless handoff. The pan then glides
|
||||
// home on its own once the orbit input runs again in a non-paint mode (PanEnabled false).
|
||||
// Deliberately DON'T zero the pan: only FreeCamMode disables this component, and the freecam
|
||||
// snaps to the live (still panned) view - resetting now would jump the blend-FROM camera to
|
||||
// center while the freecam sits at the panned pose (the visible snap on the paint->freecam
|
||||
// switch). Frozen offset = blend-from == blend-to; the pan glides home once the orbit runs again.
|
||||
}
|
||||
|
||||
/// <summary>Uniform scale of the current follow target (1 when none, or when Scale With Target is off).
|
||||
@@ -157,13 +155,10 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
return t ? Mathf.Max(0.0001f, t.lossyScale.x) : 1f;
|
||||
}
|
||||
|
||||
/// <summary>How much the pan SENSITIVITY scales with the live zoom distance. Dividing the orbit radius by the
|
||||
/// default radius makes this exactly 1 at the default zoom (so the authored panSensitivity keeps its feel
|
||||
/// there), smaller when zoomed in (finer pan) and larger when zoomed out. The body scale cancels out because
|
||||
/// the radius already embeds it - a bigger player orbits proportionally farther - so the pan stays
|
||||
/// body-relative across player sizes at every zoom. The result: the model slides the same amount ON SCREEN
|
||||
/// per pixel regardless of how far the wheel has zoomed. The clamp does NOT use this - panMaxDistance is
|
||||
/// body-relative only (CurrentScale), so the world-space pan limit is constant across zoom levels.</summary>
|
||||
/// <summary>Pan-SENSITIVITY scale with the live zoom: radius / defaultRadius = 1 at the default zoom,
|
||||
/// finer zoomed in, coarser zoomed out - the model slides the same amount ON SCREEN per pixel at any
|
||||
/// zoom. Body scale cancels out (the radius already embeds it). The clamp deliberately does NOT use
|
||||
/// this: panMaxDistance stays body-relative only, constant across zoom levels.</summary>
|
||||
float PanZoomScale() => _orbit ? _orbit.Radius / Mathf.Max(0.0001f, defaultRadius) : 1f;
|
||||
|
||||
/// <summary>Snaps the zoom to the scaled default radius. Called when a new avatar binds (see
|
||||
@@ -177,13 +172,11 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
_zoomInit = true;
|
||||
}
|
||||
|
||||
/// <summary>Snaps the orbit axes so the camera faces along Camera.main's current view direction, so the
|
||||
/// next orbit mode keeps that facing instead of restoring the orbit's last-used angle. Called when
|
||||
/// switching out of a non-orbit rig (first person): Camera.main still shows that rig's view this frame
|
||||
/// (the brain hasn't blended yet), so the read is exact. With WorldSpace binding + Sphere orbit + a
|
||||
/// centered composer the camera look rotation is exactly Euler(VerticalAxis, HorizontalAxis, 0), so
|
||||
/// HorizontalAxis = world yaw and VerticalAxis = world pitch (positive = looking down). No-op without a
|
||||
/// live camera/orbit.</summary>
|
||||
/// <summary>Snaps the orbit axes to Camera.main's current view direction so the next orbit mode keeps
|
||||
/// that facing instead of the orbit's last-used angle. Called when switching out of a non-orbit rig:
|
||||
/// Camera.main still shows that rig's view this frame (the brain hasn't blended yet), so the read is
|
||||
/// exact. With WorldSpace binding + Sphere orbit + a centered composer the look rotation is exactly
|
||||
/// Euler(VerticalAxis, HorizontalAxis, 0).</summary>
|
||||
public void SyncToView()
|
||||
{
|
||||
if (!_orbit) return;
|
||||
@@ -250,9 +243,8 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
float scroll = (InputUtil.ShiftHeld() || InputUtil.CtrlHeld()) ? 0f : InputUtil.ReadScroll();
|
||||
if (!Mathf.Approximately(scroll, 0f))
|
||||
{
|
||||
// One wheel notch = one zoom step (sign only), independent of the platform's raw wheel
|
||||
// magnitude. Scrolling up zooms in. Step scales with the player so the number of notches to
|
||||
// cross the range is the same at every size.
|
||||
// One wheel notch = one zoom step (sign only, platform-independent). The step scales with
|
||||
// the player so the notch count to cross the range is the same at every size.
|
||||
_targetRadius -= Mathf.Sign(scroll) * zoomSpeed * scale;
|
||||
}
|
||||
|
||||
@@ -308,11 +300,9 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
axis.Value = value;
|
||||
}
|
||||
|
||||
/// <summary>Middle-mouse pan: while PaintMode enables it, holding the middle button and dragging slides the
|
||||
/// whole framing along the screen right/up axes (via the camera-offset extension), then eases the offset
|
||||
/// back to center on release. A temporary framing offset to line up an awkward spot of the model for
|
||||
/// painting - the player body and its paint collider are untouched. Disabled (eases home) outside paint mode
|
||||
/// and while the orbit is parked in <see cref="OrbitLookMode.Disabled"/> (pause / no-player).</summary>
|
||||
/// <summary>Middle-mouse pan (while PaintMode enables it): slides the whole framing along the screen
|
||||
/// right/up axes via the camera-offset extension - a temporary offset to line up an awkward spot of
|
||||
/// the model for painting. Eases home outside paint mode / while the orbit is Disabled.</summary>
|
||||
void UpdatePan(float dt)
|
||||
{
|
||||
if (!enablePan || !_camOffset)
|
||||
@@ -324,10 +314,8 @@ public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
if (active && InputUtil.MiddleMouseHeld())
|
||||
{
|
||||
Vector2 d = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
|
||||
// Subtracting the drag gives a hand-tool feel (the model follows the cursor: drag right -> model
|
||||
// slides right). The step scales with the orbit distance so it feels the same on screen at every
|
||||
// zoom (finer when zoomed in). The clamp stays body-relative only (no zoom factor), so the maximum
|
||||
// pan distance in world units is the same at every zoom level.
|
||||
// Subtracting gives a hand-tool feel (the model follows the cursor). The step scales with the
|
||||
// zoom (same on-screen feel, see PanZoomScale); the clamp stays body-relative only.
|
||||
_pan -= d * (panSensitivity * zoom);
|
||||
_pan = Vector2.ClampMagnitude(_pan, panMaxDistance * CurrentScale());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user