From 066e308cde4d303985b6b3412190abcab9506db1 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 2 Jul 2026 10:08:28 +0200 Subject: [PATCH] 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 --- Assets/Scripts/ConcealmentColliderBaker.cs | 1 - Assets/Scripts/FingerIK.cs | 34 ++-- Assets/Scripts/GameModeManager.cs | 78 +++------ Assets/Scripts/HandIK.cs | 22 +-- Assets/Scripts/HeroCutout.cs | 39 ++--- Assets/Scripts/MatchManager.cs | 168 ++++++------------- Assets/Scripts/MeshPainter.Cpu.cs | 17 +- Assets/Scripts/MeshPainter.Net.cs | 23 +-- Assets/Scripts/MeshPainter.Pick.cs | 78 +++------ Assets/Scripts/MeshPainter.Setup.cs | 28 ++-- Assets/Scripts/MeshPainter.Undo.cs | 22 +-- Assets/Scripts/MeshPainter.cs | 101 ++++------- Assets/Scripts/NetworkPlayerSetup.cs | 134 ++++++--------- Assets/Scripts/PaintHud.Persistence.cs | 90 ++++------ Assets/Scripts/PaintHud.cs | 63 +++---- Assets/Scripts/PaintMode.cs | 3 - Assets/Scripts/PlayerCharacterController.cs | 176 +++++++------------- Assets/Scripts/PlayerControlMode.cs | 3 - Assets/Scripts/PlayerPaintSync.cs | 147 +++++++--------- Assets/Scripts/PlayerPoseController.cs | 101 ++++------- Assets/Scripts/ThirdPersonOrbitInput.cs | 52 +++--- 21 files changed, 478 insertions(+), 902 deletions(-) diff --git a/Assets/Scripts/ConcealmentColliderBaker.cs b/Assets/Scripts/ConcealmentColliderBaker.cs index 3301877..93663b6 100644 --- a/Assets/Scripts/ConcealmentColliderBaker.cs +++ b/Assets/Scripts/ConcealmentColliderBaker.cs @@ -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(); var pts = new List(); List cl = clusters[ci]; diff --git a/Assets/Scripts/FingerIK.cs b/Assets/Scripts/FingerIK.cs index 0ba033c..3906e6f 100644 --- a/Assets/Scripts/FingerIK.cs +++ b/Assets/Scripts/FingerIK.cs @@ -2,32 +2,20 @@ using System.Collections.Generic; using UnityEngine; /// -/// 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. (0..1) lerps every joint from its rest angle -/// (0) toward its per-joint (closed fist). This is written first -/// every frame and is the SEED pose for the IK pass. -/// 2. IK reach. When a is assigned and > 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: (0..1) lerps every joint from rest toward its per-joint +/// - written first each frame, and the SEED pose for the IK pass. +/// 2. IK reach: with a and > 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 , or wired by -/// hand via . By default the owning drives the -/// solve once per frame; set 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 , or wired by hand +/// via ; set only when using this component alone. /// [ExecuteAlways] [DisallowMultipleComponent] diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs index f9935aa..06d8d80 100644 --- a/Assets/Scripts/GameModeManager.cs +++ b/Assets/Scripts/GameModeManager.cs @@ -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; /// 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. @@ -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(FindObjectsInactive.Include); if (!firstPerson) firstPerson = FindFirstObjectByType(FindObjectsInactive.Include); - //if (!insect) insect = FindFirstObjectByType(); 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(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 } /// - /// 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 enabled 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). /// 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 diff --git a/Assets/Scripts/HandIK.cs b/Assets/Scripts/HandIK.cs index f0332c5..484c488 100644 --- a/Assets/Scripts/HandIK.cs +++ b/Assets/Scripts/HandIK.cs @@ -3,22 +3,14 @@ using UnityEngine; /// /// Hand-level IK manager for the WhiteHand rig. Auto-discovers the five fingers under -/// (Thumb, IndexFinger, MiddleFinger, RingFinger, LittleFinger), -/// walks each single-child bone spine to its *_end leaf, and creates/configures a -/// 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). +/// , walks each single-child bone spine to its *_end leaf, and +/// creates/configures a 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". /// [ExecuteAlways] [DisallowMultipleComponent] diff --git a/Assets/Scripts/HeroCutout.cs b/Assets/Scripts/HeroCutout.cs index 23f9fa3..9696747 100644 --- a/Assets/Scripts/HeroCutout.cs +++ b/Assets/Scripts/HeroCutout.cs @@ -2,34 +2,21 @@ using UnityEngine; using Unity.Cinemachine; /// -/// 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 (_HeroCutoutTarget, -/// _HeroCutoutParams) that the Custom/HeroCutoutLit 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 (_HeroCutoutTarget, _HeroCutoutParams) +/// that Custom/HeroCutoutLit 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 () 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 () 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 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. /// [DisallowMultipleComponent] public class HeroCutout : MonoBehaviour diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index f9085b1..7a5f5d5 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -7,52 +7,22 @@ using UnityEngine.SceneManagement; /// /// 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 (). 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). +/// is nulled and avatars are spawned manually once the +/// gameplay scene is loaded. Main holds zero network objects. /// -/// 1. Clients sync ADDITIVELY ( = -/// ). 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 ) 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 -> : a -/// configurable number of random connected clients are rolled Hunter (, -/// 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 -> -/// (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 () 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 /// Server-local phase guard. Not replicated - see class 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(); if (!_net) _net = FindFirstObjectByType(); - // 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()) gameObject.AddComponent(); } @@ -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(_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(); 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(); if (setup) setup.MarkConversionSpawn(pos); go.GetComponent().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(FindObjectsInactive.Include); if (modes) modes.ReleaseToNoPlayer(); } diff --git a/Assets/Scripts/MeshPainter.Cpu.cs b/Assets/Scripts/MeshPainter.Cpu.cs index e86514d..24ccfb6 100644 --- a/Assets/Scripts/MeshPainter.Cpu.cs +++ b/Assets/Scripts/MeshPainter.Cpu.cs @@ -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); diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs index d716aee..102a23d 100644 --- a/Assets/Scripts/MeshPainter.Net.cs +++ b/Assets/Scripts/MeshPainter.Net.cs @@ -4,15 +4,11 @@ using Unity.Netcode; using UnityEngine; /// -/// 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 , -/// 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). /// public struct PaintStampNet : INetworkSerializable { @@ -239,11 +235,10 @@ public partial class MeshPainter } /// - /// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a fresh - /// local baseline - exactly like 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 , 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). /// public bool LoadPaintFromSnapshot(byte[] compressed) { diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs index 4a2512f..1f7dbeb 100644 --- a/Assets/Scripts/MeshPainter.Pick.cs +++ b/Assets/Scripts/MeshPainter.Pick.cs @@ -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; /// - /// 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 + /// ). 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 } /// - /// 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: 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. /// 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; } /// - /// Foreign-object pick: the data channels (metallic/smoothness/emission) always come - /// texel-exact from the material via (raw, unlit). Albedo - /// follows : 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 (). + /// Falls back to a pure screen pick when no texel-exact material read is possible. /// 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(); 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 --- diff --git a/Assets/Scripts/MeshPainter.Setup.cs b/Assets/Scripts/MeshPainter.Setup.cs index c597895..545ada1 100644 --- a/Assets/Scripts/MeshPainter.Setup.cs +++ b/Assets/Scripts/MeshPainter.Setup.cs @@ -82,13 +82,11 @@ public partial class MeshPainter } /// - /// 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. /// 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"); diff --git a/Assets/Scripts/MeshPainter.Undo.cs b/Assets/Scripts/MeshPainter.Undo.cs index 1a04d6b..6006e8b 100644 --- a/Assets/Scripts/MeshPainter.Undo.cs +++ b/Assets/Scripts/MeshPainter.Undo.cs @@ -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; " + diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index 1008111..e5cc201 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -8,25 +8,16 @@ using UnityEngine.InputSystem; using Random = UnityEngine.Random; // System (for Action) also defines Random -> keep the UnityEngine one /// -/// 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 ): -/// 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 : 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 (): 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 () shows size + mask at the hover point. /// [DisallowMultipleComponent] public partial class MeshPainter : MonoBehaviour @@ -62,13 +53,10 @@ public partial class MeshPainter : MonoBehaviour public bool IsReady => _setupValid; /// - /// Live camouflage knobs on the player's Custom/PlayerCamoLit 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 , so remotes show the same camo. + /// Live camouflage knobs on the player's Custom/PlayerCamoLit 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 . /// public float CamoHalfLambert { @@ -91,12 +79,9 @@ public partial class MeshPainter : MonoBehaviour set => SetMatFloat(SpecularDampenId, value); } - /// - /// Painted-height look knobs on the same Custom/PlayerCamoLit 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 . Defaults match the shader so the unpainted look is unchanged. - /// + // 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. + /// 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. public float OcclusionThreshold @@ -120,14 +105,11 @@ public partial class MeshPainter : MonoBehaviour set => SetMatFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase); } - /// Drives the see-through-walls X-ray overlay (see PlayerCamoLit): sets the TARGET for - /// the ALPHA of the authored _XRayColor, 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 - /// seconds in Update (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. - /// 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). + /// Drives the see-through-walls X-ray overlay: sets the TARGET for the alpha of the authored + /// _XRayColor (the rgb glow colour is preserved); Update eases the material alpha toward it + /// over seconds. A per-avatar runtime reveal, NOT a painted channel + /// (neither persists nor round-trips the HUD). raises it during the + /// resolution phase and for a hider buried too deep in geometry. 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(); diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index d4067ea..9dd3171 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -71,9 +71,8 @@ public class NetworkPlayerSetup : NetworkBehaviour /// neutral lobby Player prefab (full mode set). Baked per prefab variant. 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 _isFound = new NetworkVariable(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); @@ -81,25 +80,21 @@ public class NetworkPlayerSetup : NetworkBehaviour /// target that is already out of play. 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 _embeddedReveal = new NetworkVariable(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 _spawnOverride = new NetworkVariable(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; /// 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(); 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(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 } /// - /// Places the freshly-spawned owner. Prefers this avatar's role-specific spawn area (Hunter/Hider tag), - /// then the shared 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. /// private void PlaceAtSpawn() { @@ -412,13 +384,10 @@ public class NetworkPlayerSetup : NetworkBehaviour } /// - /// 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). /// private void ApplyOwnerPosition(Vector3 worldPos) { @@ -454,11 +423,9 @@ public class NetworkPlayerSetup : NetworkBehaviour } /// - /// 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 ). + /// 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). /// private void PlaceRandomlyInArea(Collider area) { @@ -496,11 +463,8 @@ public class NetworkPlayerSetup : NetworkBehaviour /// /// 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). /// private void PlaceOnSpawnRing() { diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs index 300adc9..18b9ed8 100644 --- a/Assets/Scripts/PaintHud.Persistence.cs +++ b/Assets/Scripts/PaintHud.Persistence.cs @@ -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 _authoredPanelPos = new Dictionary(); 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 diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs index 3318d64..6e988ef 100644 --- a/Assets/Scripts/PaintHud.cs +++ b/Assets/Scripts/PaintHud.cs @@ -8,21 +8,13 @@ using UnityEngine.InputSystem.UI; /// /// In-game paint UI. Shows its panels only while the active is -/// (it follows the manager's ModeChanged event) and binds its -/// widgets directly to the live fields, so edits apply immediately. +/// (follows the manager's ModeChanged) and binds its widgets directly to the +/// live 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 (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 . 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 () or is generated in code as the fallback. Either way +/// the BINDINGS live in code and are (re)applied at runtime by against the +/// same stable widget names - a prefab stores only the visual layout, never behaviour. /// [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(); @@ -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 } /// - /// 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 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 LateUpdate). /// 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); diff --git a/Assets/Scripts/PaintMode.cs b/Assets/Scripts/PaintMode.cs index 3d2be60..b03fd26 100644 --- a/Assets/Scripts/PaintMode.cs +++ b/Assets/Scripts/PaintMode.cs @@ -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; diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index fbf01cd..76fae74 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -4,32 +4,18 @@ using UnityEngine.InputSystem; #endif /// -/// 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". /// [RequireComponent(typeof(CharacterController))] [DisallowMultipleComponent] @@ -188,18 +174,12 @@ public class PlayerCharacterController : MonoBehaviour /// True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal). public bool IsOnCeiling => _isWallClimbing && _onCeiling; - /// - /// 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. - /// + /// 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. public bool IsMovingOnGround => _movingOnGround; - /// - /// True while airborne and descending faster than (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. - /// + /// True while airborne (not wall-climbing) and descending faster than + /// (scaled by player size). The pose system picks its fall clip off this. public bool IsFalling => _isFalling; /// @@ -524,11 +504,6 @@ public class PlayerCharacterController : MonoBehaviour _slideSpeed = 0f; } - /// - /// Enters wall-climb mode only when the move input actively pushes into a wall - /// (not on mere contact). Leaving while climbing is handled in - /// . - /// // 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; } + /// + /// Enters wall-climb mode only when the move input actively pushes into a wall (not on mere + /// contact). Leaving while climbing is handled in . + /// private void UpdateWallClimbState(Vector3 moveDir) { if (_reentryCooldown > 0f) @@ -589,17 +568,11 @@ public class PlayerCharacterController : MonoBehaviour } /// - /// 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 - /// (capsule stays put), and the right - /// mouse button + horizontal drag turns the body via . 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 (capsule stays put), right-drag turns via . + /// 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. /// 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 /// /// 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. /// private void ApplyMeshOffset(float offset) => SetMeshLean(_wallNormal, offset); @@ -922,11 +880,9 @@ public class PlayerCharacterController : MonoBehaviour } /// - /// Re-applies the resting mesh offset every non-climbing frame in a single write of - /// : 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. /// private void EaseMeshOffsetToRest(float dt) { @@ -936,13 +892,11 @@ public class PlayerCharacterController : MonoBehaviour } /// - /// 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 (, 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 (, 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). /// 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) /// - /// 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). /// private static bool IsPlayerCollider(Collider c) => c.GetComponentInParent(); diff --git a/Assets/Scripts/PlayerControlMode.cs b/Assets/Scripts/PlayerControlMode.cs index 2e58e56..71ffaf3 100644 --- a/Assets/Scripts/PlayerControlMode.cs +++ b/Assets/Scripts/PlayerControlMode.cs @@ -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; diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs index 48a56ff..932d03c 100644 --- a/Assets/Scripts/PlayerPaintSync.cs +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -4,42 +4,28 @@ using Unity.Netcode; using UnityEngine; /// -/// Replicates mesh painting across the network as brush-stamp commands (not pixels). The owner -/// captures every committed stamp from its , 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. /// [DisallowMultipleComponent] public class PlayerPaintSync : NetworkBehaviour @@ -78,26 +64,21 @@ public class PlayerPaintSync : NetworkBehaviour } private readonly List _outgoing = new List(); - // 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 _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 _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 _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 _buffered = new List(); - // 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(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 } /// - /// 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. /// 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)); diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs index fc1e7eb..e500065 100644 --- a/Assets/Scripts/PlayerPoseController.cs +++ b/Assets/Scripts/PlayerPoseController.cs @@ -8,26 +8,16 @@ using UnityEngine.InputSystem; #endif /// -/// Sits next to the and holds a list of 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 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 (a two-input -/// on an ) that drives the -/// Animator directly. Switching clip — between poses, or between idle/walk/run within a pose — -/// crossfades the outgoing and incoming clips over 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 +/// (two-input mixer on an ) that crossfades every clip switch over +/// . 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). /// [RequireComponent(typeof(PlayerCharacterController))] [DisallowMultipleComponent] @@ -83,11 +73,9 @@ public class PlayerPoseController : MonoBehaviour /// Locomotion category that selects which clip the active pose plays. 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) { diff --git a/Assets/Scripts/ThirdPersonOrbitInput.cs b/Assets/Scripts/ThirdPersonOrbitInput.cs index 9c03c7d..222767e 100644 --- a/Assets/Scripts/ThirdPersonOrbitInput.cs +++ b/Assets/Scripts/ThirdPersonOrbitInput.cs @@ -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. } /// 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; } - /// 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. + /// 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. float PanZoomScale() => _orbit ? _orbit.Radius / Mathf.Max(0.0001f, defaultRadius) : 1f; /// 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; } - /// 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. + /// 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). 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; } - /// 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 (pause / no-player). + /// 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. 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()); }