diff --git a/Assets/Scripts/CharacterTeleport.cs b/Assets/Scripts/CharacterTeleport.cs new file mode 100644 index 0000000..2c686ff --- /dev/null +++ b/Assets/Scripts/CharacterTeleport.cs @@ -0,0 +1,21 @@ +using UnityEngine; + +/// Disables a CharacterController around a transform write so it cannot snap the transform back. +public static class CharacterTeleport +{ + public static void Move(CharacterController cc, Transform t, Vector3 position) + { + bool reenable = cc && cc.enabled; + if (cc) cc.enabled = false; + t.position = position; + if (cc) cc.enabled = reenable; + } + + public static void Move(CharacterController cc, Transform t, Vector3 position, Quaternion rotation) + { + bool reenable = cc && cc.enabled; + if (cc) cc.enabled = false; + t.SetPositionAndRotation(position, rotation); + if (cc) cc.enabled = reenable; + } +} diff --git a/Assets/Scripts/CharacterTeleport.cs.meta b/Assets/Scripts/CharacterTeleport.cs.meta new file mode 100644 index 0000000..546e61a --- /dev/null +++ b/Assets/Scripts/CharacterTeleport.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52a1fdeaf96581840bc09373ff2c7ad0 \ No newline at end of file diff --git a/Assets/Scripts/ConcealmentColliderBaker.cs b/Assets/Scripts/ConcealmentColliderBaker.cs index 93663b6..ae05d1a 100644 --- a/Assets/Scripts/ConcealmentColliderBaker.cs +++ b/Assets/Scripts/ConcealmentColliderBaker.cs @@ -198,16 +198,7 @@ public class ConcealmentColliderBaker : MonoBehaviour int made = 0; for (int ci = 0; ci < clusters.Count; ci++) { - var pset = new HashSet(); - var pts = new List(); - List cl = clusters[ci]; - for (int j = 0; j < cl.Count; j++) - { - int t = cl[j]; - if (pset.Add(triA[t])) pts.Add(wv[triA[t]]); - if (pset.Add(triB[t])) pts.Add(wv[triB[t]]); - if (pset.Add(triC[t])) pts.Add(wv[triC[t]]); - } + List pts = CollectClusterPoints(clusters[ci], wv, triA, triB, triC); float thick = SlabThickness(pts, out Vector3 normal); List hullPts; @@ -389,13 +380,25 @@ public class ConcealmentColliderBaker : MonoBehaviour if (set.Add(c)) { pos.Add(wv[c]); addedOut?.Add(c); } } - /// Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis. - private static float SlabThickness(List pts, out Vector3 normal) + /// Unique welded vertex positions of a triangle cluster. + private static List CollectClusterPoints(List cluster, List wv, List triA, List triB, List triC) { - normal = Vector3.up; - int n = pts.Count; - if (n < 2) return 0f; + var pts = new List(cluster.Count * 3); + var seen = new HashSet(); + for (int j = 0; j < cluster.Count; j++) + { + int t = cluster[j]; + if (seen.Add(triA[t])) pts.Add(wv[triA[t]]); + if (seen.Add(triB[t])) pts.Add(wv[triB[t]]); + if (seen.Add(triC[t])) pts.Add(wv[triC[t]]); + } + return pts; + } + /// Mean-centered 3x3 covariance of the point cloud, eigendecomposed via . + private static void CovarianceEigen(List pts, out double[,] vec, out double[] d) + { + int n = pts.Count; Vector3 mean = Vector3.zero; for (int i = 0; i < n; i++) mean += pts[i]; mean /= n; @@ -409,7 +412,17 @@ public class ConcealmentColliderBaker : MonoBehaviour } var a = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } }; - Jacobi3(a, out double[,] v, out double[] d); + Jacobi3(a, out vec, out d); + } + + /// Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis. + private static float SlabThickness(List pts, out Vector3 normal) + { + normal = Vector3.up; + int n = pts.Count; + if (n < 2) return 0f; + + CovarianceEigen(pts, out double[,] v, out double[] d); int mi = 0; if (d[1] < d[mi]) mi = 1; @@ -483,15 +496,7 @@ public class ConcealmentColliderBaker : MonoBehaviour /// private static float ClusterFill(List cluster, List wv, List triA, List triB, List triC, out Vector3 splitAxis) { - var pts = new List(cluster.Count * 3); - var seen = new HashSet(); - for (int j = 0; j < cluster.Count; j++) - { - int t = cluster[j]; - if (seen.Add(triA[t])) pts.Add(wv[triA[t]]); - if (seen.Add(triB[t])) pts.Add(wv[triB[t]]); - if (seen.Add(triC[t])) pts.Add(wv[triC[t]]); - } + List pts = CollectClusterPoints(cluster, wv, triA, triB, triC); PcaFrame(pts, out Vector3 u, out Vector3 v, out _); @@ -555,23 +560,9 @@ public class ConcealmentColliderBaker : MonoBehaviour private static void PcaFrame(List pts, out Vector3 u, out Vector3 v, out Vector3 n) { u = Vector3.right; v = Vector3.up; n = Vector3.forward; - int cnt = pts.Count; - if (cnt < 2) return; + if (pts.Count < 2) return; - Vector3 mean = Vector3.zero; - for (int i = 0; i < cnt; i++) mean += pts[i]; - mean /= cnt; - - double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0; - for (int i = 0; i < cnt; i++) - { - double x = pts[i].x - mean.x, y = pts[i].y - mean.y, z = pts[i].z - mean.z; - c00 += x * x; c01 += x * y; c02 += x * z; - c11 += y * y; c12 += y * z; c22 += z * z; - } - - var mat = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } }; - Jacobi3(mat, out double[,] vec, out double[] d); + CovarianceEigen(pts, out double[,] vec, out double[] d); int hi = 0, mid = 1, lo = 2; if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; } diff --git a/Assets/Scripts/ConcealmentMeter.cs b/Assets/Scripts/ConcealmentMeter.cs index 70f53e4..efe6339 100644 --- a/Assets/Scripts/ConcealmentMeter.cs +++ b/Assets/Scripts/ConcealmentMeter.cs @@ -88,7 +88,6 @@ public class ConcealmentMeter : MonoBehaviour private readonly List _concave = new List(); private readonly List _volumes = new List(); - // Gizmo cache. private readonly List _gizPos = new List(); private readonly List _gizEmbedded = new List(); private float _gizVoxelWorld; @@ -132,9 +131,7 @@ public class ConcealmentMeter : MonoBehaviour // else a scaled player is measured at scale² (voxel world positions, broadphase box AND gizmos), which // also corrupts EmbeddedPercent. No-op at scale 1; the uniform divide is rotation-independent, which the // player's uniform scale guarantees. Mirrors SkinnedColliderBaker's identical fixup. - Vector3 ls = targetRenderer.transform.lossyScale; - if (ls.x != 1f || ls.y != 1f || ls.z != 1f) - RescaleBakedToUnitScale(ls); + GeometryUtil.RescaleToUnitScale(_baked, targetRenderer.transform.lossyScale, _vbuf); _verts = _baked.vertices; _tris = _baked.triangles; @@ -156,7 +153,7 @@ public class ConcealmentMeter : MonoBehaviour Matrix4x4 l2w = targetRenderer.transform.localToWorldMatrix; // Derive the broadphase box from the ACTUAL transformed geometry, not SkinnedMeshRenderer.bounds // (the latter is a root-bone approximation that can be badly offset for a deformed/posed rig). - Bounds worldBounds = TransformBounds(l2w, lb); + Bounds worldBounds = GeometryUtil.TransformBounds(l2w, lb); BuildEnvironmentCandidates(worldBounds); bool anyEnv = _convex.Count > 0 || @@ -213,24 +210,6 @@ public class ConcealmentMeter : MonoBehaviour HasResult = true; } - /// Divides the player's world scale back out of the freshly baked mesh so it is true unit-scale - /// local geometry (the space this class raises to world with). Reuses — no per-eval alloc. - private void RescaleBakedToUnitScale(Vector3 lossyScale) - { - float ix = lossyScale.x != 0f ? 1f / lossyScale.x : 0f; - float iy = lossyScale.y != 0f ? 1f / lossyScale.y : 0f; - float iz = lossyScale.z != 0f ? 1f / lossyScale.z : 0f; - _baked.GetVertices(_vbuf); - for (int i = 0; i < _vbuf.Count; i++) - { - Vector3 v = _vbuf[i]; - v.x *= ix; v.y *= iy; v.z *= iz; - _vbuf[i] = v; - } - _baked.SetVertices(_vbuf); - _baked.RecalculateBounds(); - } - /// /// Rasterizes every triangle into the XZ column grid; each column accumulates the Y values /// where the surface crosses it (one per triangle whose XZ projection covers the column center). @@ -389,22 +368,6 @@ public class ConcealmentMeter : MonoBehaviour return (hits & 1) == 1; } - /// World AABB enclosing a local AABB after an affine transform (encapsulates the 8 transformed corners). - private static Bounds TransformBounds(Matrix4x4 m, Bounds local) - { - Vector3 mn = local.min, mx = local.max; - Vector3 c0 = m.MultiplyPoint3x4(mn); - Bounds b = new Bounds(c0, Vector3.zero); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mn.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mn.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mn.y, mx.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mx.y, mn.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mx.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mx.z))); - b.Encapsulate(m.MultiplyPoint3x4(mx)); - return b; - } - private static float AverageScale(Transform t) { Vector3 s = t.lossyScale; diff --git a/Assets/Scripts/ConcealmentVolume.cs b/Assets/Scripts/ConcealmentVolume.cs index 624697d..4a95df8 100644 --- a/Assets/Scripts/ConcealmentVolume.cs +++ b/Assets/Scripts/ConcealmentVolume.cs @@ -60,16 +60,9 @@ public class ConcealmentVolume : MonoBehaviour { Vector3 mn = origin; Vector3 mx = origin + new Vector3(nx * voxelSize, ny * voxelSize, nz * voxelSize); - Matrix4x4 m = transform.localToWorldMatrix; - Bounds b = new Bounds(m.MultiplyPoint3x4(mn), Vector3.zero); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mn.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mn.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mn.y, mx.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mx.y, mn.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mx.z))); - b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mx.z))); - b.Encapsulate(m.MultiplyPoint3x4(mx)); - return b; + var local = new Bounds(); + local.SetMinMax(mn, mx); + return GeometryUtil.TransformBounds(transform.localToWorldMatrix, local); } } @@ -184,13 +177,7 @@ public class ConcealmentVolume : MonoBehaviour bool enclosed = useFlood && !outside[idx]; bool solid = false; if (useWinding) - { - Vector3 cWorldLocal = new Vector3( - origin.x + (ix + 0.5f) * vs, - origin.y + (iy + 0.5f) * vs, - origin.z + (iz + 0.5f) * vs); - solid = WindingNumber(cWorldLocal, verts, tris) > 0.5; - } + solid = WindingNumber(VoxelCenter(origin, ix, iy, iz, vs), verts, tris) > 0.5; bool inside = enclosed || solid; if (!inside) continue; bits[idx >> 5] |= 1u << (idx & 31); @@ -253,6 +240,10 @@ public class ConcealmentVolume : MonoBehaviour return sum / (4.0 * System.Math.PI); } + /// Local-space center of grid voxel (ix, iy, iz). + private static Vector3 VoxelCenter(Vector3 origin, int ix, int iy, int iz, float size) + => new Vector3(origin.x + (ix + 0.5f) * size, origin.y + (iy + 0.5f) * size, origin.z + (iz + 0.5f) * size); + private void OnDrawGizmosSelected() { if (!drawBakedGizmos || !HasBake) return; @@ -265,11 +256,7 @@ public class ConcealmentVolume : MonoBehaviour { int idx = ix + nx * (iy + ny * iz); if ((bits[idx >> 5] & (1u << (idx & 31))) == 0) continue; - Vector3 c = new Vector3( - origin.x + (ix + 0.5f) * voxelSize, - origin.y + (iy + 0.5f) * voxelSize, - origin.z + (iz + 0.5f) * voxelSize); - Gizmos.DrawCube(c, cube); + Gizmos.DrawCube(VoxelCenter(origin, ix, iy, iz, voxelSize), cube); } } #endif diff --git a/Assets/Scripts/FingerIK.cs b/Assets/Scripts/FingerIK.cs index 3906e6f..7273366 100644 --- a/Assets/Scripts/FingerIK.cs +++ b/Assets/Scripts/FingerIK.cs @@ -306,11 +306,8 @@ public class FingerIK : MonoBehaviour // World hinge axis (invariant under the joint's own twist about local X). Vector3 axisW = (j.rotation * HingeAxis(i)).normalized; - Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW); - Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW); - if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) continue; + if (!TryInPlaneAngle(toTip, toTarget, axisW, out float hingeAng)) continue; - float hingeAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW); j.rotation = Quaternion.AngleAxis(hingeAng, axisW) * j.rotation; ConstrainToHinge(i); @@ -367,16 +364,25 @@ public class FingerIK : MonoBehaviour Vector3 toTip = tip.position - b.position; Vector3 toTarget = target.position - b.position; - float latAng = 0f; - Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axisW); - Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axisW); - if (tipPlane.sqrMagnitude > 1e-10f && tgtPlane.sqrMagnitude > 1e-10f) - latAng = Vector3.SignedAngle(tipPlane, tgtPlane, axisW); + TryInPlaneAngle(toTip, toTarget, axisW, out float latAng); // degenerate -> 0 (spread bias still applies) latAng = Mathf.Clamp(latAng + spread, spreadLimits.x, spreadLimits.y); _solvedLocal[0] = _solvedLocal[0] * Quaternion.AngleAxis(latAng, SpreadAxis); } + // Signed angle from toTip to toTarget projected onto the plane orthogonal to axis; false when + // either projection is degenerate (angle is then 0 and must not be applied as a solve step). + private static bool TryInPlaneAngle(Vector3 toTip, Vector3 toTarget, Vector3 axis, out float angle) + { + angle = 0f; + Vector3 tipPlane = Vector3.ProjectOnPlane(toTip, axis); + Vector3 tgtPlane = Vector3.ProjectOnPlane(toTarget, axis); + if (tipPlane.sqrMagnitude < 1e-10f || tgtPlane.sqrMagnitude < 1e-10f) return false; + + angle = Vector3.SignedAngle(tipPlane, tgtPlane, axis); + return true; + } + // Base-joint spread/opposition about local Z, composed AFTER curl so the two DOFs don't // fight. Applied on the final base rotation when IK isn't driving the base laterally. private void ApplySpread() diff --git a/Assets/Scripts/FirstPersonController.cs b/Assets/Scripts/FirstPersonController.cs index 935826c..7ec7d27 100644 --- a/Assets/Scripts/FirstPersonController.cs +++ b/Assets/Scripts/FirstPersonController.cs @@ -53,12 +53,7 @@ public class FirstPersonController : MonoBehaviour CinemachineCamera _vcam; PlayerCharacterController _player; // body whose yaw we pin to the camera while active PlayerPoseController _poseController; // supplies the active pose's per-pose first-person eye offset - float _yaw; // look target (raw input accumulates here) - float _pitch; - float _displayYaw; // smoothed value actually applied to the transform - float _displayPitch; - float _yawSmoothRef; - float _pitchSmoothRef; + SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with the freecam bool _resolved; void Awake() @@ -89,7 +84,7 @@ public class FirstPersonController : MonoBehaviour SyncToCurrentView(); InputUtil.SetCursorLocked(true); ApplyTransform(); // place at the eye on the same frame so the blend starts from there - if (_player) _player.FaceYaw = _displayYaw; // body faces the camera from frame one + if (_player) _player.FaceYaw = _look.DisplayYaw; // body faces the camera from frame one } else { @@ -105,32 +100,18 @@ public class FirstPersonController : MonoBehaviour // LateUpdate so the player has already moved the CameraTarget this frame. LookTick(); ApplyTransform(); - if (_player) _player.FaceYaw = _displayYaw; // keep the body's yaw locked to the camera + if (_player) _player.FaceYaw = _look.DisplayYaw; // keep the body's yaw locked to the camera } void LookTick() { Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier; - _yaw += delta.x * horizontalSensitivity; - float vs = verticalSensitivity * (invertY ? 1f : -1f); - _pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch); - - if (lookSmoothTime > 0f) - { - // SmoothDampAngle is frame-rate-independent and wraps yaw correctly. - _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, lookSmoothTime); - _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, lookSmoothTime); - } - else - { - _displayYaw = _yaw; - _displayPitch = _pitch; - } + _look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime); } void ApplyTransform() { - Quaternion rot = Quaternion.Euler(_displayPitch, _displayYaw, 0f); + Quaternion rot = _look.Rotation; if (cameraTarget) { // Sit at the first-person eye anchor, then raise/lower the eye by the active pose's first-person @@ -151,18 +132,9 @@ public class FirstPersonController : MonoBehaviour { var cam = Camera.main; if (cam) - { - Vector3 e = cam.transform.rotation.eulerAngles; - _yaw = e.y; - float p = e.x > 180f ? e.x - 360f : e.x; - _pitch = Mathf.Clamp(p, -maxPitch, maxPitch); - } - - // Snap the smoothed state to the fresh pose so entering never lags/jumps to catch up. - _displayYaw = _yaw; - _displayPitch = _pitch; - _yawSmoothRef = 0f; - _pitchSmoothRef = 0f; + _look.SyncTo(cam.transform.rotation, maxPitch); + else + _look.Snap(); // no camera: keep the current angles but still snap the smoothed state } void SetMeshHidden(bool hidden) diff --git a/Assets/Scripts/FirstPersonMode.cs b/Assets/Scripts/FirstPersonMode.cs index b86bf98..4fe1855 100644 --- a/Assets/Scripts/FirstPersonMode.cs +++ b/Assets/Scripts/FirstPersonMode.cs @@ -14,18 +14,9 @@ public class FirstPersonMode : GameMode public override void Enter() { // Player stays fully controllable; only the view changes. - if (Manager.Player) - { - Manager.Player.enabled = true; - Manager.Player.ControlEnabled = true; - } - - if (Manager.Painter) - Manager.Painter.PaintingEnabled = false; - - // The orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame. - if (Manager.CameraInput) - Manager.CameraInput.enabled = false; + SetPlayerControl(true); + SetPainting(false); + SetOrbitInputEnabled(false); if (Manager.FirstPerson) Manager.FirstPerson.SetActive(true); @@ -38,9 +29,9 @@ public class FirstPersonMode : GameMode if (Manager.FirstPerson) Manager.FirstPerson.SetActive(false); + SetOrbitInputEnabled(true); if (Manager.CameraInput) { - Manager.CameraInput.enabled = true; // Carry the first-person facing into the orbit camera so the next orbit mode (Paint / Player // Control) keeps the current view angle instead of snapping back to the orbit's last-used angle. // Camera.main still shows the first-person view this frame (the brain hasn't blended yet). @@ -94,7 +85,7 @@ public class FirstPersonMode : GameMode for (int i = 0; i < count; i++) { var netObj = _hits[i].collider.GetComponentInParent(); - if (netObj && self && netObj == self) continue; // ignore our own collider + if (netObj && self && netObj == self) continue; if (!netObj || !netObj.IsPlayerObject) return; // first solid hit is world geometry -> blocked var setup = netObj.GetComponent(); diff --git a/Assets/Scripts/FreeCamController.cs b/Assets/Scripts/FreeCamController.cs index 1670e03..4e7e694 100644 --- a/Assets/Scripts/FreeCamController.cs +++ b/Assets/Scripts/FreeCamController.cs @@ -57,12 +57,7 @@ public class FreeCamController : MonoBehaviour CharacterController _controller; CinemachineCamera _vcam; - float _yaw; // look target (raw input accumulates here) - float _pitch; - float _displayYaw; // smoothed value actually applied to the transform - float _displayPitch; - float _yawSmoothRef; - float _pitchSmoothRef; + SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with first person Vector3 _moveVelocity; // smoothed world-space flight velocity Vector3 _moveVelocityRef; bool _ignoredPlayer; @@ -110,24 +105,10 @@ public class FreeCamController : MonoBehaviour void LookTick() { Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier; - _yaw += delta.x * horizontalSensitivity; - float vs = verticalSensitivity * (invertY ? 1f : -1f); - _pitch = Mathf.Clamp(_pitch + delta.y * vs, -maxPitch, maxPitch); - - if (lookSmoothTime > 0f) - { - // SmoothDampAngle is frame-rate-independent and wraps yaw correctly. - _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, lookSmoothTime); - _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, lookSmoothTime); - } - else - { - _displayYaw = _yaw; - _displayPitch = _pitch; - } + _look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime); // Rotation is unconstrained by the CharacterController; only position is. - transform.rotation = Quaternion.Euler(_displayPitch, _displayYaw, 0f); + transform.rotation = _look.Rotation; } void MoveTick() @@ -153,22 +134,9 @@ public class FreeCamController : MonoBehaviour var cam = Camera.main; if (!cam) return; - // CharacterController forbids direct transform writes while enabled, so toggle it for the teleport. - bool wasEnabled = _controller.enabled; - _controller.enabled = false; - transform.SetPositionAndRotation(cam.transform.position, cam.transform.rotation); - _controller.enabled = wasEnabled; + CharacterTeleport.Move(_controller, transform, cam.transform.position, cam.transform.rotation); - Vector3 e = transform.rotation.eulerAngles; - _yaw = e.y; - float p = e.x > 180f ? e.x - 360f : e.x; - _pitch = Mathf.Clamp(p, -maxPitch, maxPitch); - - // Snap the smoothed state to the fresh pose so entering freecam never lags/jumps to catch up. - _displayYaw = _yaw; - _displayPitch = _pitch; - _yawSmoothRef = 0f; - _pitchSmoothRef = 0f; + _look.SyncTo(transform.rotation, maxPitch); _moveVelocity = Vector3.zero; _moveVelocityRef = Vector3.zero; } diff --git a/Assets/Scripts/FreeCamMode.cs b/Assets/Scripts/FreeCamMode.cs index 7d2b62f..8294c94 100644 --- a/Assets/Scripts/FreeCamMode.cs +++ b/Assets/Scripts/FreeCamMode.cs @@ -11,18 +11,9 @@ public class FreeCamMode : GameMode public override void Enter() { // Keep the player component alive (gravity/grounding) but reject input, like Paint mode. - if (Manager.Player) - { - Manager.Player.enabled = true; - Manager.Player.ControlEnabled = false; - } - - if (Manager.Painter) - Manager.Painter.PaintingEnabled = false; - - // The orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame. - if (Manager.CameraInput) - Manager.CameraInput.enabled = false; + SetPlayerControl(false); + SetPainting(false); + SetOrbitInputEnabled(false); if (Manager.FreeCam) Manager.FreeCam.SetActive(true); @@ -35,7 +26,6 @@ public class FreeCamMode : GameMode if (Manager.FreeCam) Manager.FreeCam.SetActive(false); - if (Manager.CameraInput) - Manager.CameraInput.enabled = true; + SetOrbitInputEnabled(true); } } diff --git a/Assets/Scripts/GameMode.cs b/Assets/Scripts/GameMode.cs index 62f8d19..08effe0 100644 --- a/Assets/Scripts/GameMode.cs +++ b/Assets/Scripts/GameMode.cs @@ -21,4 +21,29 @@ public abstract class GameMode /// Called every frame while this mode is active. public virtual void Tick() { } + + /// Enables the player component and sets whether it accepts movement input. + protected void SetPlayerControl(bool controlEnabled) + { + if (Manager.Player) + { + Manager.Player.enabled = true; + Manager.Player.ControlEnabled = controlEnabled; + } + } + + /// Toggles painting on the mesh painter. + protected void SetPainting(bool on) + { + if (Manager.Painter) + Manager.Painter.PaintingEnabled = on; + } + + /// Enables/disables the orbit camera input component. Off while a dedicated camera rig is + /// live: the orbit rig would otherwise keep grabbing the cursor and reading the mouse every frame. + protected void SetOrbitInputEnabled(bool on) + { + if (Manager.CameraInput) + Manager.CameraInput.enabled = on; + } } diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs index 06d8d80..4e83f5c 100644 --- a/Assets/Scripts/GameModeManager.cs +++ b/Assets/Scripts/GameModeManager.cs @@ -145,14 +145,25 @@ public class GameModeManager : MonoBehaviour _paused = false; _resumeIndex = -1; + ClearPlayerBindings(); + TearDownActiveMode(); + BuildSpectatorModes(); + SwitchTo(0); + } + + /// Drops the player/painter bindings and notifies painter-following UI. + private void ClearPlayerBindings() + { player = null; painter = null; PainterChanged?.Invoke(null); + } + /// Exits the active mode and marks no mode active. + private void TearDownActiveMode() + { ActiveMode?.Exit(); _activeIndex = -1; - BuildSpectatorModes(); - SwitchTo(0); } /// @@ -197,8 +208,7 @@ public class GameModeManager : MonoBehaviour firstPerson.SetPlayer(localPlayer); // Tear down the pre-spawn (player-less) mode and rebuild scoped to this avatar's role. - ActiveMode?.Exit(); - _activeIndex = -1; + TearDownActiveMode(); // 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. @@ -224,12 +234,9 @@ public class GameModeManager : MonoBehaviour _paused = false; _resumeIndex = -1; - ActiveMode?.Exit(); - _activeIndex = -1; + TearDownActiveMode(); _modes.Clear(); - player = null; - painter = null; - PainterChanged?.Invoke(null); + ClearPlayerBindings(); // 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. @@ -308,7 +315,7 @@ public class GameModeManager : MonoBehaviour else if (painter) painter.PaintingEnabled = false; // degenerate paint-only set: at least stop painting } - if (WasSwitchPressed()) + if (InputUtil.GameplayKeyPressed(switchKey)) { int next = (_activeIndex + 1) % _modes.Count; if (suppressPaint && _modes[next] is PaintMode) @@ -355,14 +362,4 @@ public class GameModeManager : MonoBehaviour Debug.Log($"[GameModeManager] Mode -> {ActiveMode.DisplayName}", this); ModeChanged?.Invoke(ActiveMode); } - - private bool WasSwitchPressed() - { - if (TypingGuard.IsTyping) return false; -#if ENABLE_INPUT_SYSTEM - return Keyboard.current != null && Keyboard.current[switchKey].wasPressedThisFrame; -#else - return Input.GetKeyDown(switchKey); -#endif - } } diff --git a/Assets/Scripts/GeometryUtil.cs b/Assets/Scripts/GeometryUtil.cs new file mode 100644 index 0000000..6ef3fd1 --- /dev/null +++ b/Assets/Scripts/GeometryUtil.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using UnityEngine; + +/// Shared mesh/bounds geometry helpers. +public static class GeometryUtil +{ + /// Divides a world scale back out of a freshly baked mesh so it is true unit-scale local + /// geometry (BakeMesh freezes the transform's lossyScale into the vertices). No-op at scale 1; + /// is reused so nothing is allocated per bake. + public static void RescaleToUnitScale(Mesh baked, Vector3 lossyScale, List scratch) + { + if (lossyScale.x == 1f && lossyScale.y == 1f && lossyScale.z == 1f) return; + + float ix = lossyScale.x != 0f ? 1f / lossyScale.x : 0f; + float iy = lossyScale.y != 0f ? 1f / lossyScale.y : 0f; + float iz = lossyScale.z != 0f ? 1f / lossyScale.z : 0f; + baked.GetVertices(scratch); + for (int i = 0; i < scratch.Count; i++) + { + Vector3 v = scratch[i]; + v.x *= ix; v.y *= iy; v.z *= iz; + scratch[i] = v; + } + baked.SetVertices(scratch); + baked.RecalculateBounds(); + } + + /// World AABB enclosing a local AABB after an affine transform (encapsulates the 8 transformed corners). + public static Bounds TransformBounds(Matrix4x4 m, Bounds local) + { + Vector3 mn = local.min, mx = local.max; + Vector3 c0 = m.MultiplyPoint3x4(mn); + Bounds b = new Bounds(c0, Vector3.zero); + b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mn.z))); + b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mn.z))); + b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mn.y, mx.z))); + b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mx.y, mn.z))); + b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mx.x, mn.y, mx.z))); + b.Encapsulate(m.MultiplyPoint3x4(new Vector3(mn.x, mx.y, mx.z))); + b.Encapsulate(m.MultiplyPoint3x4(mx)); + return b; + } +} diff --git a/Assets/Scripts/GeometryUtil.cs.meta b/Assets/Scripts/GeometryUtil.cs.meta new file mode 100644 index 0000000..62229be --- /dev/null +++ b/Assets/Scripts/GeometryUtil.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: deab7e342bb598049b9972454a8399e5 \ No newline at end of file diff --git a/Assets/Scripts/HsvColorPicker.cs b/Assets/Scripts/HsvColorPicker.cs index 8bd68ff..ac4104e 100644 --- a/Assets/Scripts/HsvColorPicker.cs +++ b/Assets/Scripts/HsvColorPicker.cs @@ -110,11 +110,7 @@ public class HsvColorPicker : MonoBehaviour _svHandle = sv.Find("Handle") as RectTransform; - if (sv.TryGetComponent(out UiPointerSurface svPointer)) - { - svPointer.Picked -= OnSvPicked; - svPointer.Picked += OnSvPicked; - } + Rehook(sv, OnSvPicked); } var hue = transform.Find("Hue") as RectTransform; @@ -123,11 +119,7 @@ public class HsvColorPicker : MonoBehaviour if (hue.TryGetComponent(out Image hueImg)) hueImg.sprite = PaintHud.HueSprite(); _hueHandle = hue.Find("Handle") as RectTransform; - if (hue.TryGetComponent(out UiPointerSurface huePointer)) - { - huePointer.Picked -= OnHuePicked; - huePointer.Picked += OnHuePicked; - } + Rehook(hue, OnHuePicked); } // Alpha now lives on a standalone slider (PaintHud), not in the picker. Hide any alpha bar a @@ -141,6 +133,16 @@ public class HsvColorPicker : MonoBehaviour Refresh(); } + // Idempotently hooks the handler to the surface's drag relay (unsubscribe-then-subscribe). + private static void Rehook(Component surface, Action handler) + { + if (surface.TryGetComponent(out UiPointerSurface pointer)) + { + pointer.Picked -= handler; + pointer.Picked += handler; + } + } + private void OnSvPicked(Vector2 p) { _s = p.x; diff --git a/Assets/Scripts/InputUtil.cs b/Assets/Scripts/InputUtil.cs index d770f95..ad720f3 100644 --- a/Assets/Scripts/InputUtil.cs +++ b/Assets/Scripts/InputUtil.cs @@ -104,4 +104,21 @@ public static class InputUtil return Input.GetMouseButtonDown(0); #endif } + + /// Gameplay key pressed on this exact frame (edge), suppressed while a UI text field is + /// focused so typing never triggers gameplay bindings. The parameter type follows the active input + /// backend, matching how the callers' serialized key fields are declared. +#if ENABLE_INPUT_SYSTEM + public static bool GameplayKeyPressed(Key key) + { + if (TypingGuard.IsTyping) return false; + return Keyboard.current != null && Keyboard.current[key].wasPressedThisFrame; + } +#else + public static bool GameplayKeyPressed(KeyCode key) + { + if (TypingGuard.IsTyping) return false; + return Input.GetKeyDown(key); + } +#endif } diff --git a/Assets/Scripts/MatchHud.cs b/Assets/Scripts/MatchHud.cs index ec1bc9b..ed748ce 100644 --- a/Assets/Scripts/MatchHud.cs +++ b/Assets/Scripts/MatchHud.cs @@ -82,25 +82,19 @@ public class MatchHud : MonoBehaviour private void EnsureStyles() { - if (_phaseStyle == null) + if (_phaseStyle == null) _phaseStyle = MakeCenteredBoldLabel(22); + if (_timeStyle == null) _timeStyle = MakeCenteredBoldLabel(28); + } + + private static GUIStyle MakeCenteredBoldLabel(int fontSize) + { + var style = new GUIStyle(GUI.skin.label) { - _phaseStyle = new GUIStyle(GUI.skin.label) - { - alignment = TextAnchor.MiddleCenter, - fontSize = 22, - fontStyle = FontStyle.Bold, - }; - _phaseStyle.normal.textColor = Color.white; - } - if (_timeStyle == null) - { - _timeStyle = new GUIStyle(GUI.skin.label) - { - alignment = TextAnchor.MiddleCenter, - fontSize = 28, - fontStyle = FontStyle.Bold, - }; - _timeStyle.normal.textColor = Color.white; - } + alignment = TextAnchor.MiddleCenter, + fontSize = fontSize, + fontStyle = FontStyle.Bold, + }; + style.normal.textColor = Color.white; + return style; } } diff --git a/Assets/Scripts/MatchManager.cs b/Assets/Scripts/MatchManager.cs index 7a5f5d5..5cce81a 100644 --- a/Assets/Scripts/MatchManager.cs +++ b/Assets/Scripts/MatchManager.cs @@ -233,20 +233,26 @@ public class MatchManager : MonoBehaviour _net.OnClientStarted -= OnClientStarted; _net.OnServerStopped -= OnNetStopped; _net.OnClientStopped -= OnNetStopped; - _net.OnClientConnectedCallback -= OnClientConnected; - _net.OnClientDisconnectCallback -= OnClientDisconnected; - if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; - if (_net.CustomMessagingManager != null) - { - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase); - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); - } + UnhookSessionCallbacks(); } if (Instance == this) Instance = null; } + // Unhooks the per-session callbacks and named-message handlers (shared by OnDisable and OnNetStopped). + private void UnhookSessionCallbacks() + { + _net.OnClientConnectedCallback -= OnClientConnected; + _net.OnClientDisconnectCallback -= OnClientDisconnected; + if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; + if (_net.CustomMessagingManager != null) + { + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase); + _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); + } + } + private void OnServerStarted() { _cleanedUp = false; @@ -313,19 +319,7 @@ public class MatchManager : MonoBehaviour Phase = MatchPhase.Lobby; ApplyPhaseLocal(MatchSubPhase.None, 0f, _localMode); _pendingHiders.Clear(); - if (_net) - { - _net.OnClientConnectedCallback -= OnClientConnected; - _net.OnClientDisconnectCallback -= OnClientDisconnected; - if (_net.SceneManager != null) _net.SceneManager.OnSceneEvent -= OnSceneEvent; - if (_net.CustomMessagingManager != null) - { - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgAuthorityGranted); - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgRequestEndMatch); - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgPhase); - _net.CustomMessagingManager.UnregisterNamedMessageHandler(MsgReportHit); - } - } + if (_net) UnhookSessionCallbacks(); ReturnToMainOnly(); } @@ -463,15 +457,7 @@ public class MatchManager : MonoBehaviour private IEnumerator MatchSequence(HashSet hunterIds) { - var sm = _net.SceneManager; - - // 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); - - yield return SceneOp(() => sm.LoadScene(matchSceneName, LoadSceneMode.Additive), - SceneEventType.LoadEventCompleted, matchSceneName); + yield return SwapScenes(lobbySceneName, matchSceneName); RespawnAll(id => hunterIds.Contains(id) ? hunterPrefab : hiderPrefab, matchSpawnCenter); @@ -500,7 +486,7 @@ public class MatchManager : MonoBehaviour yield return WaitPhase(resolveDuration, null); _phaseRoutine = null; - EndMatch(); // swaps the map back to the lobby and respawns everyone as the neutral Player + EndMatch(); } // Waits up to 'seconds' of server real time, returning early the moment 'until' (if supplied) is true. @@ -691,15 +677,7 @@ public class MatchManager : MonoBehaviour private IEnumerator EndMatchSequence() { - var sm = _net.SceneManager; - - // 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); - - yield return SceneOp(() => sm.LoadScene(lobbySceneName, LoadSceneMode.Additive), - SceneEventType.LoadEventCompleted, lobbySceneName); + yield return SwapScenes(matchSceneName, lobbySceneName); RespawnAll(_ => _playerPrefab, lobbySpawnCenter); @@ -707,6 +685,20 @@ public class MatchManager : MonoBehaviour Debug.Log("[MatchManager] Back in lobby.", this); } + // Unloads one gameplay scene (if loaded), then additively loads the next (shared by the match + // start/end sequences). + private IEnumerator SwapScenes(string unloadSceneName, string loadSceneName) + { + var sm = _net.SceneManager; + + Scene old = SceneManager.GetSceneByName(unloadSceneName); + if (old.IsValid() && old.isLoaded) + yield return SceneOp(() => sm.UnloadScene(old), SceneEventType.UnloadEventCompleted, unloadSceneName); + + yield return SceneOp(() => sm.LoadScene(loadSceneName, LoadSceneMode.Additive), + SceneEventType.LoadEventCompleted, loadSceneName); + } + // Runs an NGO scene op and waits for its server-side completion event. NGO rejects a new op while one // is in flight, so callers must await this before issuing the next. private IEnumerator SceneOp(System.Func op, SceneEventType completed, string sceneName) @@ -823,18 +815,22 @@ public class MatchManager : MonoBehaviour public bool IsLocalSceneLobby => SceneManager.GetActiveScene().name == lobbySceneName; public bool IsLocalSceneMap => SceneManager.GetActiveScene().name == matchSceneName; - // Server -> client: tell the designated client it is the match authority (so it reads Esc locally). - private void SendAuthorityGrant(ulong clientId) + // Sends a named message whose payload carries no information (a named message still needs a buffer, + // so a single dummy byte is written). + private void SendEmptyNamedMessage(string messageName, ulong targetClientId) { var cmm = _net != null ? _net.CustomMessagingManager : null; if (cmm == null) return; using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp)) { - writer.WriteByteSafe(1); // payload unused; a named message still needs a buffer - cmm.SendNamedMessage(MsgAuthorityGranted, clientId, writer); + writer.WriteByteSafe(1); + cmm.SendNamedMessage(messageName, targetClientId, writer); } } + // Server -> client: tell the designated client it is the match authority (so it reads Esc locally). + private void SendAuthorityGrant(ulong clientId) => SendEmptyNamedMessage(MsgAuthorityGranted, clientId); + // Client handler: this machine has been granted match authority. private void OnAuthorityGrantedMessage(ulong senderClientId, FastBufferReader reader) { @@ -844,16 +840,7 @@ public class MatchManager : MonoBehaviour // Client -> server: ask the server to end the match. Only honored from the authority client (and only // while a match is running - EndMatch guards the phase). - private void RequestEndMatchFromServer() - { - var cmm = _net != null ? _net.CustomMessagingManager : null; - if (cmm == null) return; - using (var writer = new FastBufferWriter(sizeof(byte), Allocator.Temp)) - { - writer.WriteByteSafe(1); - cmm.SendNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId, writer); - } - } + private void RequestEndMatchFromServer() => SendEmptyNamedMessage(MsgRequestEndMatch, NetworkManager.ServerClientId); // Server handler: an authority client asked to end the match. private void OnRequestEndMatchMessage(ulong senderClientId, FastBufferReader reader) diff --git a/Assets/Scripts/MeshPainter.Backend.cs b/Assets/Scripts/MeshPainter.Backend.cs index 0915bca..d259c48 100644 --- a/Assets/Scripts/MeshPainter.Backend.cs +++ b/Assets/Scripts/MeshPainter.Backend.cs @@ -26,11 +26,7 @@ public partial class MeshPainter var canonical = new Color32[MAP_COUNT][]; for (int mi = 0; mi < MAP_COUNT; mi++) canonical[mi] = ReadCanonical(mi); - if (_gpuActive) TeardownGpu(); - else TeardownCpu(); - - SetupBackend(toGpu, canonical); - _hasLastScreen = false; + RebuildBackend(toGpu, canonical); Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this); } @@ -40,14 +36,44 @@ public partial class MeshPainter /// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for /// the new content. The input arrays are never mutated (SetupBackend clones its seed). /// - private void ReseedCanonical(Color32[][] canonical) + private void ReseedCanonical(Color32[][] canonical) => RebuildBackend(useGpu, canonical); + + /// Tears down the active backend and sets up the requested one on the given maps. + private void RebuildBackend(bool gpu, Color32[][] canonical) { if (_gpuActive) TeardownGpu(); else TeardownCpu(); - SetupBackend(useGpu, canonical); + SetupBackend(gpu, canonical); _hasLastScreen = false; } + /// Seed source for a map: the supplied canonical data when valid, else the material seed. + private Color32[] ResolveSeed(int mi, Color32[][] canonical) + { + return (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize) + ? canonical[mi] : _seedCanonical[mi]; + } + + // Backend-agnostic stroke dispatch (GPU vs CPU), shared by local painting and replayed net stamps. + + private void BeginBackendStroke() + { + if (_gpuActive) BeginGpuStroke(); + else ResetCpuDirty(); + } + + private void DrawBackendStamp(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n) + { + if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, n); + else PaintStamp3D(pLocal, radiusLocal, u, v, n); + } + + private void EndBackendStroke() + { + if (_gpuActive) EndGpuStroke(); + else EndCpuStroke(); + } + /// Reads a map from the active backend into canonical (material) layout. private Color32[] ReadCanonical(int mi) { diff --git a/Assets/Scripts/MeshPainter.Cpu.cs b/Assets/Scripts/MeshPainter.Cpu.cs index 24ccfb6..b0c3480 100644 --- a/Assets/Scripts/MeshPainter.Cpu.cs +++ b/Assets/Scripts/MeshPainter.Cpu.cs @@ -34,8 +34,7 @@ public partial class MeshPainter /// CPU pixels are stored directly in material layout; just guarantee a sane alpha for the color maps. private Color32[] SeedCpuPixels(int mi, Color32[][] canonical) { - var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize) - ? canonical[mi] : _seedCanonical[mi]; + var src = ResolveSeed(mi, canonical); var px = (Color32[])src.Clone(); if (mi != MAP_MSO) // albedo / emission: opaque. The mask map keeps alpha = smoothness. for (int i = 0; i < px.Length; i++) px[i].a = 255; diff --git a/Assets/Scripts/MeshPainter.Gpu.cs b/Assets/Scripts/MeshPainter.Gpu.cs index f4fcd74..98474f1 100644 --- a/Assets/Scripts/MeshPainter.Gpu.cs +++ b/Assets/Scripts/MeshPainter.Gpu.cs @@ -71,8 +71,7 @@ public partial class MeshPainter /// Seeds a GPU map's display RT from canonical data (coverage alpha = UV islands), then packs MS to final. private void SeedGpuMap(int mi, Color32[][] canonical) { - var src = (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize) - ? canonical[mi] : _seedCanonical[mi]; + var src = ResolveSeed(mi, canonical); var gm = _gpu[mi]; if (mi == MAP_MSO) @@ -108,8 +107,7 @@ public partial class MeshPainter Graphics.SetRenderTarget(rt); if (_brushMat.SetPass(1)) { - int sub = _bakedMesh.subMeshCount; - for (int i = 0; i < sub; i++) Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i); + DrawBakedMeshNow(); } else { @@ -258,6 +256,12 @@ public partial class MeshPainter private void DrawStamp() { if (!_brushMat.SetPass(0)) return; + DrawBakedMeshNow(); + } + + /// Draws the baked mesh once per sub-mesh with the pass the caller just set. + private void DrawBakedMeshNow() + { int sub = _bakedMesh.subMeshCount; for (int i = 0; i < sub; i++) Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i); diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs index 102a23d..31c7ab5 100644 --- a/Assets/Scripts/MeshPainter.Net.cs +++ b/Assets/Scripts/MeshPainter.Net.cs @@ -139,18 +139,9 @@ public partial class MeshPainter if (spray) BuildSprayStampMaskSeeded(s.SpraySeed); else { ResolveActiveMask(); UseResolvedStampMask(); } - if (_gpuActive) - { - BeginGpuStroke(); - DrawBrushGpu(s.P, s.R, s.U, s.V, s.N); - EndGpuStroke(); - } - else - { - ResetCpuDirty(); - PaintStamp3D(s.P, s.R, s.U, s.V, s.N); - EndCpuStroke(); - } + BeginBackendStroke(); + DrawBackendStamp(s.P, s.R, s.U, s.V, s.N); + EndBackendStroke(); // Accumulate this peer's stroke dirty mask so a network checkpoint marker can commit the same // history entry as the owner (same stamps -> same touched maps -> COW-aligned with the owner). @@ -189,21 +180,7 @@ public partial class MeshPainter w.Write(textureSize); w.Write(MAP_COUNT); - var bytes = new byte[n * 4]; - for (int mi = 0; mi < MAP_COUNT; mi++) - { - Color32[] px = ReadCanonical(mi); - int count = Mathf.Min(n, px.Length); - for (int i = 0; i < count; i++) - { - int o = i * 4; - bytes[o] = px[i].r; - bytes[o + 1] = px[i].g; - bytes[o + 2] = px[i].b; - bytes[o + 3] = px[i].a; - } - w.Write(bytes, 0, count * 4); - } + WriteCanonicalMaps(w); } compressed = Deflate(raw.ToArray()); } @@ -217,19 +194,8 @@ public partial class MeshPainter /// public void ApplyPaintSnapshot(byte[] compressed, int baselineId) { - if (!_setupValid || compressed == null || compressed.Length == 0) return; + if (!TryDecodeSnapshot(compressed, "Paint", out Color32[][] canonical)) return; - Color32[][] canonical; - try { canonical = DecodeSnapshot(compressed); } - catch (System.Exception e) - { - Debug.LogWarning($"[MeshPainter] Paint snapshot decode failed: {e.Message}", this); - return; - } - if (canonical == null) return; - - // Re-seed exactly like SwitchBackend, but with externally supplied maps, so the island/dilation - // seeding path runs and the edge padding is rebuilt correctly for the loaded content. ReseedCanonical(canonical); SetHistoryBaseline(canonical, baselineId); } @@ -242,22 +208,28 @@ public partial class MeshPainter /// public bool LoadPaintFromSnapshot(byte[] compressed) { - if (!_setupValid || compressed == null || compressed.Length == 0) return false; - - Color32[][] canonical; - try { canonical = DecodeSnapshot(compressed); } - catch (System.Exception e) - { - Debug.LogWarning($"[MeshPainter] Carry snapshot decode failed: {e.Message}", this); - return false; - } - if (canonical == null) return false; + if (!TryDecodeSnapshot(compressed, "Carry", out Color32[][] canonical)) return false; ReseedCanonical(canonical); ResetHistoryToCurrent(canonical); return true; } + /// Guard + decode for an incoming snapshot blob; false = not ready / empty / undecodable. + private bool TryDecodeSnapshot(byte[] compressed, string context, out Color32[][] canonical) + { + canonical = null; + if (!_setupValid || compressed == null || compressed.Length == 0) return false; + + try { canonical = DecodeSnapshot(compressed); } + catch (System.Exception e) + { + Debug.LogWarning($"[MeshPainter] {context} snapshot decode failed: {e.Message}", this); + return false; + } + return canonical != null; + } + private Color32[][] DecodeSnapshot(byte[] compressed) { byte[] raw = Inflate(compressed); @@ -272,22 +244,7 @@ public partial class MeshPainter // Resolution / version mismatch: ignore rather than smear; the painter keeps its state. if (ver != SnapshotVersion || size != textureSize) return null; - int n = textureSize * textureSize; - var result = new Color32[MAP_COUNT][]; - int load = Mathf.Min(maps, MAP_COUNT); - for (int mi = 0; mi < load; mi++) - { - byte[] b = r.ReadBytes(n * 4); - if (b.Length != n * 4) break; // truncated -> remaining maps stay at the material seed - var px = new Color32[n]; - for (int i = 0; i < n; i++) - { - int o = i * 4; - px[i] = new Color32(b[o], b[o + 1], b[o + 2], b[o + 3]); - } - result[mi] = px; - } - return result; + return ReadCanonicalMaps(r, maps); } } diff --git a/Assets/Scripts/MeshPainter.Persistence.cs b/Assets/Scripts/MeshPainter.Persistence.cs index dba769c..7f1c154 100644 --- a/Assets/Scripts/MeshPainter.Persistence.cs +++ b/Assets/Scripts/MeshPainter.Persistence.cs @@ -36,13 +36,16 @@ public partial class MeshPainter private static string PaintLibraryDir => Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved"); - private static string SanitizeKey(string s) + /// Filesystem-safe key: invalid filename characters and spaces become underscores. + internal static string SanitizeFileKey(string s) { - if (string.IsNullOrEmpty(s)) return "unnamed"; foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); return s.Replace(' ', '_'); } + private static string SanitizeKey(string s) => + string.IsNullOrEmpty(s) ? "unnamed" : SanitizeFileKey(s); + /// Disk path for a slot. The display name lives inside the file; the file name is a sanitized key. private static string PathForName(string name) => Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt); @@ -86,7 +89,6 @@ public partial class MeshPainter try { Directory.CreateDirectory(PaintLibraryDir); - int n = textureSize * textureSize; using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write)) using (var w = new BinaryWriter(fs)) @@ -111,21 +113,7 @@ public partial class MeshPainter w.Write(HeightNormalIntensity); w.Write(ParallaxIntensity); - var bytes = new byte[n * 4]; - for (int mi = 0; mi < MAP_COUNT; mi++) - { - Color32[] px = ReadCanonical(mi); - int count = Mathf.Min(n, px.Length); - for (int i = 0; i < count; i++) - { - int o = i * 4; - bytes[o] = px[i].r; - bytes[o + 1] = px[i].g; - bytes[o + 2] = px[i].b; - bytes[o + 3] = px[i].a; - } - w.Write(bytes, 0, count * 4); - } + WriteCanonicalMaps(w); } return true; } @@ -147,8 +135,6 @@ public partial class MeshPainter Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars); if (canonical == null) return false; - // Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path - // runs and the edge padding is rebuilt correctly for the loaded content. ReseedCanonical(canonical); // A load replaces the whole painting and is NOT undoable: the loaded maps become the new history @@ -202,9 +188,7 @@ public partial class MeshPainter using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) using (var r = new BinaryReader(fs, Encoding.UTF8)) { - if (r.ReadUInt32() != PaintFileMagic) return null; - int ver = r.ReadInt32(); - if (ver < 2 || ver > PaintFileVersion) return null; // list legacy v2..v3 slots + current + if (!TryReadPaintVersion(r, out _)) return null; return r.ReadString(); } } @@ -228,9 +212,7 @@ public partial class MeshPainter using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) using (var r = new BinaryReader(fs, Encoding.UTF8)) { - if (r.ReadUInt32() != PaintFileMagic) return null; - int ver = r.ReadInt32(); - if (ver < 2 || ver > PaintFileVersion) return null; // read legacy v2..v3 + current + if (!TryReadPaintVersion(r, out int ver)) return null; r.ReadString(); // display name (header only; not needed for the pixel load) int w = r.ReadInt32(); int h = r.ReadInt32(); @@ -264,23 +246,7 @@ public partial class MeshPainter return null; } - int n = textureSize * textureSize; - var result = new Color32[MAP_COUNT][]; - int load = Mathf.Min(maps, MAP_COUNT); - for (int mi = 0; mi < load; mi++) - { - byte[] raw = r.ReadBytes(n * 4); - if (raw.Length != n * 4) break; // truncated file -> remaining maps stay seeded - - var px = new Color32[n]; - for (int i = 0; i < n; i++) - { - int o = i * 4; - px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]); - } - result[mi] = px; - } - return result; + return ReadCanonicalMaps(r, maps); } } catch (System.Exception e) @@ -289,4 +255,55 @@ public partial class MeshPainter return null; } } + + /// Validates the paint-file magic and version range (legacy v2 .. current). + private static bool TryReadPaintVersion(BinaryReader r, out int ver) + { + if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; } + ver = r.ReadInt32(); + return ver >= 2 && ver <= PaintFileVersion; + } + + /// Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot). + private void WriteCanonicalMaps(BinaryWriter w) + { + int n = textureSize * textureSize; + var bytes = new byte[n * 4]; + for (int mi = 0; mi < MAP_COUNT; mi++) + { + Color32[] px = ReadCanonical(mi); + int count = Mathf.Min(n, px.Length); + for (int i = 0; i < count; i++) + { + int o = i * 4; + bytes[o] = px[i].r; + bytes[o + 1] = px[i].g; + bytes[o + 2] = px[i].b; + bytes[o + 3] = px[i].a; + } + w.Write(bytes, 0, count * 4); + } + } + + /// Reads up to MAP_COUNT canonical map blocks; a truncated stream leaves the remaining maps null (seeded). + private Color32[][] ReadCanonicalMaps(BinaryReader r, int mapsInFile) + { + int n = textureSize * textureSize; + var result = new Color32[MAP_COUNT][]; + int load = Mathf.Min(mapsInFile, MAP_COUNT); + for (int mi = 0; mi < load; mi++) + { + byte[] raw = r.ReadBytes(n * 4); + if (raw.Length != n * 4) break; // truncated -> remaining maps stay at the material seed + + var px = new Color32[n]; + for (int i = 0; i < n; i++) + { + int o = i * 4; + px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]); + } + result[mi] = px; + } + return result; + } } diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs index 1f7dbeb..34976aa 100644 --- a/Assets/Scripts/MeshPainter.Pick.cs +++ b/Assets/Scripts/MeshPainter.Pick.cs @@ -95,6 +95,14 @@ public partial class MeshPainter Picked?.Invoke(); } + /// Adopts a sampled colour as the brush albedo, preserving the brush's paint opacity (alpha). + private void AdoptAlbedo(Color c) => + brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a); + + /// Zeroes the emission colour, preserving its paint opacity (alpha). + private void ClearEmissionRgb() => + emissionColor = new Color(0f, 0f, 0f, emissionColor.a); + /// /// Picks from our own painted maps at the hit texel (bit-exact, backend-agnostic). /// @@ -149,7 +157,7 @@ public partial class MeshPainter { // 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); + AdoptAlbedo(lit); return true; } @@ -204,7 +212,7 @@ public partial class MeshPainter Color albedo = baseCol; if (hasUv && baseTex) albedo = SampleTexel(baseTex, ApplyST(mat, "_BaseMap", "_MainTex", uv), true) * baseCol; - brushColor = new Color(Mathf.Clamp01(albedo.r), Mathf.Clamp01(albedo.g), Mathf.Clamp01(albedo.b), brushColor.a); + AdoptAlbedo(albedo); // --- Metallic / Smoothness: MetallicGloss map (linear) --- // URP/Lit: metallic = map.r ; smoothness = map.a * _Smoothness (or _Metallic/_Glossiness scalars). @@ -307,10 +315,10 @@ public partial class MeshPainter if (weightSum <= 1e-4f) return false; float inv = 1f / weightSum; // alphamap weights normally sum to 1, but guard anyway - brushColor = new Color(Mathf.Clamp01(albedo.r * inv), Mathf.Clamp01(albedo.g * inv), Mathf.Clamp01(albedo.b * inv), brushColor.a); + AdoptAlbedo(albedo * inv); metallic = Mathf.Clamp01(metallicVal * inv); smoothness = Mathf.Clamp01(smoothVal * inv); - emissionColor = new Color(0f, 0f, 0f, emissionColor.a); // occlusion left untouched (now painted height) + ClearEmissionRgb(); // occlusion left untouched (now painted height) return true; } @@ -344,10 +352,10 @@ public partial class MeshPainter { if (!TrySampleScreenColor(screenPos, out Color c)) return false; - brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a); + AdoptAlbedo(c); metallic = 0f; smoothness = 0f; - emissionColor = new Color(0f, 0f, 0f, emissionColor.a); // occlusion left untouched (now painted height) + ClearEmissionRgb(); // occlusion left untouched (now painted height) return true; } diff --git a/Assets/Scripts/MeshPainter.Setup.cs b/Assets/Scripts/MeshPainter.Setup.cs index 545ada1..32fc2ae 100644 --- a/Assets/Scripts/MeshPainter.Setup.cs +++ b/Assets/Scripts/MeshPainter.Setup.cs @@ -176,8 +176,6 @@ public partial class MeshPainter /// Builds the albedo base-color texels from the existing material texture or baseColor. private Color32[] BuildBaseColors() { - var px = new Color32[textureSize * textureSize]; - Texture sourceTex = null; var shared = _renderer.sharedMaterial; if (shared) @@ -197,9 +195,7 @@ public partial class MeshPainter catch { /* not readable -> fall back to baseColor */ } } - Color32 c = baseColor; - for (int i = 0; i < px.Length; i++) px[i] = c; - return px; + return FillCanonical(baseColor); } /// Enables the URP keywords for the painted PBR maps and seeds the material scalars. diff --git a/Assets/Scripts/MeshPainter.Spray.cs b/Assets/Scripts/MeshPainter.Spray.cs index 7f2df3f..eac70ed 100644 --- a/Assets/Scripts/MeshPainter.Spray.cs +++ b/Assets/Scripts/MeshPainter.Spray.cs @@ -143,13 +143,7 @@ public partial class MeshPainter { if (_sprayTex) return; - _sprayTex = new Texture2D(SprayRes, SprayRes, TextureFormat.RGBA32, false, true) // linear: raw mask values - { - name = "MeshPainter_SprayMask", - wrapMode = TextureWrapMode.Clamp, - filterMode = FilterMode.Bilinear, - hideFlags = HideFlags.HideAndDontSave - }; + _sprayTex = NewMaskTexture(SprayRes, "MeshPainter_SprayMask"); _sprayPixels = new Color32[SprayRes * SprayRes]; _sprayOpacity = new float[SprayRes * SprayRes]; } diff --git a/Assets/Scripts/MeshPainter.Undo.cs b/Assets/Scripts/MeshPainter.Undo.cs index 6006e8b..df91cb9 100644 --- a/Assets/Scripts/MeshPainter.Undo.cs +++ b/Assets/Scripts/MeshPainter.Undo.cs @@ -209,10 +209,17 @@ public partial class MeshPainter private void RestoreCurrentState() { - ReseedCanonical(_undoStates[_undoIndex]); + RestoreEntry(_undoIndex); + OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id + } + + /// Shows history entry : re-seeds the backend and clears any in-progress stroke. + private void RestoreEntry(int idx) + { + _undoIndex = idx; + ReseedCanonical(_undoStates[idx]); _strokeActive = false; _strokeDirtyMaps = 0; - OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id } /// @@ -225,10 +232,7 @@ public partial class MeshPainter int idx = _undoIds.IndexOf(id); if (idx < 0) return false; - _undoIndex = idx; - ReseedCanonical(_undoStates[idx]); - _strokeActive = false; - _strokeDirtyMaps = 0; + RestoreEntry(idx); return true; } diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index e5cc201..80c931f 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -105,6 +105,20 @@ public partial class MeshPainter : MonoBehaviour set => SetMatFloat(ParallaxId, Mathf.Max(0f, value) * ParallaxBase); } + /// The three camo knobs as one vector (x = half-Lambert, y = its brightness, z = specular dampen). + public Vector3 CamoKnobs + { + get => new(CamoHalfLambert, CamoHalfLambertBrightness, CamoSpecularDampen); + set { CamoHalfLambert = value.x; CamoHalfLambertBrightness = value.y; CamoSpecularDampen = value.z; } + } + + /// The three painted-height knobs as one vector (x = occlusion threshold, y = relief normal intensity, z = parallax intensity). + public Vector3 HeightKnobs + { + get => new(OcclusionThreshold, HeightNormalIntensity, ParallaxIntensity); + set { OcclusionThreshold = value.x; HeightNormalIntensity = value.y; ParallaxIntensity = value.z; } + } + /// 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 @@ -438,13 +452,7 @@ public partial class MeshPainter : MonoBehaviour if (_proceduralMask) return; const int s = 256; - _proceduralMask = new Texture2D(s, s, TextureFormat.RGBA32, false, true) // linear: raw mask values - { - name = "MeshPainter_CircleMask", - wrapMode = TextureWrapMode.Clamp, - filterMode = FilterMode.Bilinear, - hideFlags = HideFlags.HideAndDontSave - }; + _proceduralMask = NewMaskTexture(s, "MeshPainter_CircleMask"); var px = new Color32[s * s]; _proceduralOpacity = new float[s * s]; @@ -468,6 +476,18 @@ public partial class MeshPainter : MonoBehaviour _procH = s; } + /// Creates a square RGBA32 brush-mask texture (linear: raw mask values, not colors). + private static Texture2D NewMaskTexture(int size, string texName) + { + return new Texture2D(size, size, TextureFormat.RGBA32, false, true) + { + name = texName, + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave + }; + } + /// Picks the active mask (list entry or procedural circle) and refreshes the CPU opacity cache on change. private void ResolveActiveMask() { @@ -802,8 +822,7 @@ public partial class MeshPainter : MonoBehaviour if (stamp) { - if (_gpuActive) BeginGpuStroke(); - else ResetCpuDirty(); + BeginBackendStroke(); if (!firstPress && paintInterval <= 0f) { @@ -819,8 +838,7 @@ public partial class MeshPainter : MonoBehaviour PaintAtScreen(screenPos); } - if (_gpuActive) EndGpuStroke(); - else EndCpuStroke(); + EndBackendStroke(); } _lastScreen = screenPos; @@ -850,8 +868,7 @@ public partial class MeshPainter : MonoBehaviour } else UseResolvedStampMask(); - if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, nLocal); - else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal); + DrawBackendStamp(pLocal, radiusLocal, u, v, nLocal); EmitStamp(pLocal, radiusLocal, u, v, nLocal); MarkStrokePainted(); // a stamp landed -> this stroke is undoable diff --git a/Assets/Scripts/NetworkPlayerSetup.cs b/Assets/Scripts/NetworkPlayerSetup.cs index 9dd3171..06d1d84 100644 --- a/Assets/Scripts/NetworkPlayerSetup.cs +++ b/Assets/Scripts/NetworkPlayerSetup.cs @@ -145,9 +145,7 @@ public class NetworkPlayerSetup : NetworkBehaviour // Remote avatar: NetworkTransform owns the transform here, so disable the local // simulation (input/movement) and the CharacterController's own depenetration. - if (controller) controller.enabled = false; - var cc = GetComponent(); - if (cc) cc.enabled = false; + DisableLocalSimulation(); // 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. @@ -206,16 +204,22 @@ public class NetworkPlayerSetup : NetworkBehaviour if (IsOwner) { - var controller = GetComponent(); - if (controller) controller.enabled = false; - var cc = GetComponent(); - if (cc) cc.enabled = false; + DisableLocalSimulation(); var manager = FindFirstObjectByType(FindObjectsInactive.Include); if (manager) manager.BindSpectator(); } } + // Stops this avatar simulating locally: movement input and the CharacterController's own depenetration. + private void DisableLocalSimulation() + { + var controller = GetComponent(); + if (controller) controller.enabled = false; + var cc = GetComponent(); + if (cc) cc.enabled = false; + } + private void Update() { UpdateVisibility(); @@ -352,11 +356,9 @@ public class NetworkPlayerSetup : NetworkBehaviour // Buried-hider reveal: already gated to hider + running match on the owner before it is published. if (_embeddedReveal.Value) return true; - // Resolution reveals every remaining (un-found) hider through walls, on every client. - var match = MatchManager.Instance; - return match && match.MatchActive && - match.LocalSubPhase == MatchManager.MatchSubPhase.Resolution && - role == PlayerRole.Hider && !_isFound.Value; + // Resolution reveals every remaining (un-found) hider through walls, on every client - + // exactly the still-in-play-hider-during-Resolution predicate the freeze uses. + return ComputeResolutionFreeze(); } // True if THIS machine's local player is a Hunter role variant (so it must not see hiders during hide). @@ -392,10 +394,7 @@ public class NetworkPlayerSetup : NetworkBehaviour private void ApplyOwnerPosition(Vector3 worldPos) { var cc = GetComponent(); - bool reenable = cc && cc.enabled; - if (cc) cc.enabled = false; - transform.position = worldPos; - if (cc) cc.enabled = reenable; + CharacterTeleport.Move(cc, transform, worldPos); } // Resolves the spawn-area collider for this avatar: the Role-specific area (Hunter/Hider) takes priority, @@ -446,12 +445,7 @@ public class NetworkPlayerSetup : NetworkBehaviour } Vector3 target = new Vector3(pick.x, b.min.y + spawnAreaGroundLift, pick.z); - - var cc = GetComponent(); - bool reenable = cc && cc.enabled; - if (cc) cc.enabled = false; - transform.position = target; - if (cc) cc.enabled = reenable; + ApplyOwnerPosition(target); } // Collider.ClosestPoint only constrains to the real shape for primitives and CONVEX mesh colliders; @@ -481,11 +475,6 @@ public class NetworkPlayerSetup : NetworkBehaviour // Half-slot stagger per ring so outer slots don't line up radially with inner ones. float angle = (slot / (float)spawnRingSlots + ring * 0.5f / spawnRingSlots) * Mathf.PI * 2f; Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius; - - var cc = GetComponent(); - bool reenable = cc && cc.enabled; - if (cc) cc.enabled = false; - transform.position += offset; - if (cc) cc.enabled = reenable; + ApplyOwnerPosition(transform.position + offset); } } diff --git a/Assets/Scripts/OnlineSessionManager.cs b/Assets/Scripts/OnlineSessionManager.cs index 5d80884..650d575 100644 --- a/Assets/Scripts/OnlineSessionManager.cs +++ b/Assets/Scripts/OnlineSessionManager.cs @@ -149,14 +149,30 @@ public class OnlineSessionManager : MonoBehaviour } } - private async void HostAsync() + // Shared busy/sign-in/error skeleton around one session operation; the body carries the + // op-specific statuses and state. + private async System.Threading.Tasks.Task RunSessionOpAsync(string failLabel, Func body) { - if (_busy || IsConnected) return; SetBusy(true); try { SetStatus("Signing in…"); await EnsureSignedInAsync(); + await body(); + } + catch (Exception e) + { + SetStatus($"{failLabel} failed: {e.Message}"); + Debug.LogException(e); + } + finally { SetBusy(false); } + } + + private async void HostAsync() + { + if (_busy || IsConnected) return; + await RunSessionOpAsync("Host", async () => + { SetStatus("Creating session…"); var options = new SessionOptions { @@ -165,55 +181,31 @@ public class OnlineSessionManager : MonoBehaviour }.WithRelayNetwork(); _session = await MultiplayerService.Instance.CreateSessionAsync(options); SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})"); - } - catch (Exception e) - { - SetStatus($"Host failed: {e.Message}"); - Debug.LogException(e); - } - finally { SetBusy(false); } + }); } private async void RefreshAsync() { if (_busy) return; - SetBusy(true); - try + await RunSessionOpAsync("Refresh", async () => { - SetStatus("Signing in…"); - await EnsureSignedInAsync(); SetStatus("Querying sessions…"); var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions()); _sessions = new List(results.Sessions); SessionsChanged?.Invoke(); SetStatus($"{_sessions.Count} session(s) found"); - } - catch (Exception e) - { - SetStatus($"Refresh failed: {e.Message}"); - Debug.LogException(e); - } - finally { SetBusy(false); } + }); } private async void JoinAsync(string sessionId) { if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return; - SetBusy(true); - try + await RunSessionOpAsync("Join", async () => { - SetStatus("Signing in…"); - await EnsureSignedInAsync(); SetStatus("Joining session…"); _session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId); SetStatus($"Joined \"{_session.Name}\""); - } - catch (Exception e) - { - SetStatus($"Join failed: {e.Message}"); - Debug.LogException(e); - } - finally { SetBusy(false); } + }); } private async void DisconnectAsync() diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs index 18b9ed8..2d3f175 100644 --- a/Assets/Scripts/PaintHud.Persistence.cs +++ b/Assets/Scripts/PaintHud.Persistence.cs @@ -73,11 +73,7 @@ public partial class PaintHud return Path.Combine(HudSaveDir, key + ".hudlayout"); } - private static string SanitizeHudKey(string s) - { - foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); - return s.Replace(' ', '_'); - } + private static string SanitizeHudKey(string s) => MeshPainter.SanitizeFileKey(s); [Serializable] private class HudState @@ -224,27 +220,18 @@ public partial class PaintHud // it - once per HUD, so a reset always returns to the genuine material default. if (!_hasDefaultCamo) { - _defaults.camoHalfLambert = painter.CamoHalfLambert; - _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; - _defaults.camoSpecularDampen = painter.CamoSpecularDampen; - _defaults.occlusionThreshold = painter.OcclusionThreshold; - _defaults.heightNormalIntensity = painter.HeightNormalIntensity; - _defaults.parallaxIntensity = painter.ParallaxIntensity; + CaptureCamoDefaults(); _hasDefaultCamo = true; } if (_hasPendingCamo) { - painter.CamoHalfLambert = _pendingCamo.x; - painter.CamoHalfLambertBrightness = _pendingCamo.y; - painter.CamoSpecularDampen = _pendingCamo.z; + painter.CamoKnobs = _pendingCamo; _hasPendingCamo = false; } if (_hasPendingHeight) { - painter.OcclusionThreshold = _pendingHeight.x; - painter.HeightNormalIntensity = _pendingHeight.y; - painter.ParallaxIntensity = _pendingHeight.z; + painter.HeightKnobs = _pendingHeight; _hasPendingHeight = false; } SyncCamoSliders(); @@ -471,6 +458,17 @@ public partial class PaintHud _hasDefaults = true; } + /// Snapshots the live camo + height knobs into the reset defaults (caller decides _hasDefaultCamo). + private void CaptureCamoDefaults() + { + _defaults.camoHalfLambert = painter.CamoHalfLambert; + _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; + _defaults.camoSpecularDampen = painter.CamoSpecularDampen; + _defaults.occlusionThreshold = painter.OcclusionThreshold; + _defaults.heightNormalIntensity = painter.HeightNormalIntensity; + _defaults.parallaxIntensity = painter.ParallaxIntensity; + } + /// /// Resets the HUD to its authored defaults, exactly as if the saved layout file had been deleted: the /// panels jump back to their prefab positions and every brush value returns to the painter's prefab / @@ -494,14 +492,7 @@ public partial class PaintHud // Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than // slamming it to the not-yet-filled 0 (which would flatten the material's lighting). if (!_hasDefaultCamo && painter.IsReady) - { - _defaults.camoHalfLambert = painter.CamoHalfLambert; - _defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness; - _defaults.camoSpecularDampen = painter.CamoSpecularDampen; - _defaults.occlusionThreshold = painter.OcclusionThreshold; - _defaults.heightNormalIntensity = painter.HeightNormalIntensity; - _defaults.parallaxIntensity = painter.ParallaxIntensity; - } + CaptureCamoDefaults(); ApplyValues(painter, _defaults); SyncFromPainter(); diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs index 6e988ef..e8ec493 100644 --- a/Assets/Scripts/PaintHud.cs +++ b/Assets/Scripts/PaintHud.cs @@ -155,11 +155,7 @@ public partial class PaintHud : MonoBehaviour { if (!newPainter || newPainter == painter || !_panel) return; - if (painter) - { - painter.Picked -= SyncFromPainter; - painter.BrushSizeChanged -= SyncBrushSize; - } + UnhookPainterEvents(); painter = newPainter; _camoSynced = false; // re-seed the camo sliders once this painter's material instance is ready @@ -184,13 +180,13 @@ public partial class PaintHud : MonoBehaviour UpdateButtonStates(); // enable Save now that a painter is bound } - private void OnDestroy() + private void OnDestroy() => UnhookPainterEvents(); + + private void UnhookPainterEvents() { - if (painter) - { - painter.Picked -= SyncFromPainter; - painter.BrushSizeChanged -= SyncBrushSize; - } + if (!painter) return; + painter.Picked -= SyncFromPainter; + painter.BrushSizeChanged -= SyncBrushSize; } private void OnModeChanged(GameMode mode) @@ -383,8 +379,7 @@ public partial class PaintHud : MonoBehaviour private static void SetSlider(Transform panel, string name, float value) { - var cell = FindDeep(panel, name); - var slider = cell ? cell.GetComponentInChildren() : null; + var slider = FindSlider(panel, name, out _); if (!slider) return; var curve = slider.GetComponent(); if (curve) curve.SetOutput(value); // inverts the value onto the handle, then fires the slider listener @@ -470,8 +465,7 @@ public partial class PaintHud : MonoBehaviour { if (!tex) return null; if (_maskSprites.TryGetValue(tex, out var cached) && cached) return cached; - var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f); - s.hideFlags = HideFlags.HideAndDontSave; + var s = ToSprite(tex); _maskSprites[tex] = s; return s; } @@ -525,11 +519,16 @@ public partial class PaintHud : MonoBehaviour toggle.onValueChanged.AddListener(v => group.alpha = v ? 1f : 0.5f); } + /// Finds a slider cell by name and the Slider inside it (null when either is missing). + private static Slider FindSlider(Transform panel, string name, out Transform cell) + { + cell = FindDeep(panel, name); + return cell ? cell.GetComponentInChildren() : null; + } + private static void WireSlider(Transform panel, string name, float value, Action onChanged) { - var cell = FindDeep(panel, name); - if (!cell) return; - var slider = cell.GetComponentInChildren(); + var slider = FindSlider(panel, name, out Transform cell); if (!slider) return; var curve = slider.GetComponent(); // optional non-linear response on the same object var valueRt = cell.Find("Value"); diff --git a/Assets/Scripts/PaintMode.cs b/Assets/Scripts/PaintMode.cs index b03fd26..a76000a 100644 --- a/Assets/Scripts/PaintMode.cs +++ b/Assets/Scripts/PaintMode.cs @@ -9,11 +9,7 @@ public class PaintMode : GameMode public override void Enter() { - if (Manager.Player) - { - Manager.Player.enabled = true; - Manager.Player.ControlEnabled = false; - } + SetPlayerControl(false); if (Manager.CameraInput) { @@ -21,8 +17,7 @@ public class PaintMode : GameMode Manager.CameraInput.PanEnabled = true; } - if (Manager.Painter) - Manager.Painter.PaintingEnabled = true; + SetPainting(true); } public override void Exit() diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index 76fae74..dc66f80 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -486,17 +486,12 @@ public class PlayerCharacterController : MonoBehaviour } /// - /// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum. The - /// CharacterController overrides direct transform writes, so it is toggled around the move (same - /// pattern as ). Owner-only; the authoritative - /// NetworkTransform replicates the new position to remotes. + /// Kill-plane rescue: snap the avatar back to its captured spawn point and clear all momentum. + /// Owner-only; the authoritative NetworkTransform replicates the new position to remotes. /// private void RescueToSpawn() { - bool reenable = _controller && _controller.enabled; - if (_controller) _controller.enabled = false; - transform.position = _spawnPosition; - if (_controller) _controller.enabled = reenable; + CharacterTeleport.Move(_controller, transform, _spawnPosition); _verticalVelocity = 0f; _horizontalVelocity = Vector3.zero; @@ -800,12 +795,12 @@ public class PlayerCharacterController : MonoBehaviour } else if (_climbLeftGround && vEff > 0f) { - TopOutDetach(); // topped out upward onto walkable ground: hop over the lip (holding the up key carries on) + TopOutDetach(); return; } else if (_climbLeftGround || vEff < 0f) { - ExitWallClimb(); // descended to the floor, or pulling down/away at the base: let go, no hop + ExitWallClimb(); return; } @@ -989,34 +984,16 @@ public class PlayerCharacterController : MonoBehaviour /// private bool ProbeWall(out Vector3 normal, out float gap) { - normal = _wallNormal; - gap = 0f; - Vector3 origin = WorldCapsuleCenter; Vector3 dir = -_wallNormal; float extent = CapsuleExtent(dir); float maxDist = extent + wallMaxOffset + ProbeReachSlack * CapsuleScale; - int count = Physics.RaycastNonAlloc( - origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); + if (NearestClimbHit(origin, dir, maxDist, extent, ceilingOnly: false, out normal, out gap)) + return true; - float best = float.MaxValue; - bool found = false; - for (int i = 0; i < count; i++) - { - if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider)) // skip self, other player avatars, and no-climb surfaces - continue; - if (_wallProbe[i].distance < best) - { - best = _wallProbe[i].distance; - normal = _wallProbe[i].normal; - found = true; - } - } - - if (found) - gap = Mathf.Max(0f, best - extent); - return found; + normal = _wallNormal; + return false; } /// @@ -1035,15 +1012,32 @@ public class PlayerCharacterController : MonoBehaviour /// private bool ProbeCeiling(out Vector3 normal, out float gap) { - normal = Vector3.down; - gap = 0f; - Vector3 origin = WorldCapsuleCenter; float half = WorldHalfHeight; float maxDist = half + wallMaxOffset + ProbeReachSlack * CapsuleScale; + if (NearestClimbHit(origin, Vector3.up, maxDist, half, ceilingOnly: true, out normal, out gap)) + return true; + + normal = Vector3.down; + return false; + } + + /// + /// Raycasts along and returns the nearest hit that a climb probe accepts + /// (player avatars and no-climb surfaces never count; additionally + /// rejects non-ceiling normals). is the clearance beyond the capsule's + /// support along the probe direction. False = no valid hit + /// (/ are then zero; callers supply their fallback). + /// + private bool NearestClimbHit(Vector3 origin, Vector3 dir, float maxDist, float extent, + bool ceilingOnly, out Vector3 normal, out float gap) + { + normal = Vector3.zero; + gap = 0f; + int count = Physics.RaycastNonAlloc( - origin, Vector3.up, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); + origin, dir, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore); float best = float.MaxValue; bool found = false; @@ -1051,7 +1045,7 @@ public class PlayerCharacterController : MonoBehaviour { if (IsPlayerCollider(_wallProbe[i].collider) || IsNoClimb(_wallProbe[i].collider)) continue; - if (_wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling + if (ceilingOnly && _wallProbe[i].normal.y >= CeilingNormalThreshold) // not a ceiling continue; if (_wallProbe[i].distance < best) { @@ -1062,7 +1056,7 @@ public class PlayerCharacterController : MonoBehaviour } if (found) - gap = Mathf.Max(0f, best - half); + gap = Mathf.Max(0f, best - extent); return found; } diff --git a/Assets/Scripts/PlayerControlMode.cs b/Assets/Scripts/PlayerControlMode.cs index 71ffaf3..f252485 100644 --- a/Assets/Scripts/PlayerControlMode.cs +++ b/Assets/Scripts/PlayerControlMode.cs @@ -8,16 +8,11 @@ public class PlayerControlMode : GameMode public override void Enter() { - if (Manager.Player) - { - Manager.Player.enabled = true; - Manager.Player.ControlEnabled = true; - } + SetPlayerControl(true); if (Manager.CameraInput) Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive; - if (Manager.Painter) - Manager.Painter.PaintingEnabled = false; + SetPainting(false); } } diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs index 932d03c..9af2d54 100644 --- a/Assets/Scripts/PlayerPaintSync.cs +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -233,20 +233,14 @@ public class PlayerPaintSync : NetworkBehaviour // its own material via HUD + persistence). if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady) { - Vector3 c = _camo.Value; - _painter.CamoHalfLambert = c.x; - _painter.CamoHalfLambertBrightness = c.y; - _painter.CamoSpecularDampen = c.z; + _painter.CamoKnobs = _camo.Value; _camoApplyPending = false; } // Remote replica: apply the owner's painted-height knobs once the material exists / on change. if (!IsOwner && _heightApplyPending && _painter && _painter.IsReady) { - Vector3 h = _height.Value; - _painter.OcclusionThreshold = h.x; - _painter.HeightNormalIntensity = h.y; - _painter.ParallaxIntensity = h.z; + _painter.HeightKnobs = _height.Value; _heightApplyPending = false; } @@ -274,11 +268,11 @@ public class PlayerPaintSync : NetworkBehaviour // 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); + Vector3 camo = _painter.CamoKnobs; if (_camo.Value != camo) _camo.Value = camo; - var height = new Vector3(_painter.OcclusionThreshold, _painter.HeightNormalIntensity, _painter.ParallaxIntensity); + Vector3 height = _painter.HeightKnobs; if (_height.Value != height) _height.Value = height; } @@ -365,20 +359,31 @@ public class PlayerPaintSync : NetworkBehaviour [ServerRpc] private void SubmitRestoreServerRpc(int seq, int id) => ApplyRestoreClientRpc(seq, id); - [ClientRpc] - private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps) + /// + /// Common gate for the replicated commands: the owner already applied them locally, so only remotes + /// (incl. the host's replica) proceed; during a snapshot transfer the event is buffered instead. + /// Returns true when the caller should apply the event live. + /// + private bool GateRemoteEvent(NetEvent ev) { - // The owner already painted these locally; only remotes (incl. the host's replica) replay them. if (IsOwner || !_painter) - return; + return false; - _seqHorizon = seq; // server replica tracks this as the snapshot cutoff source + _seqHorizon = ev.seq; // server replica tracks this as the snapshot cutoff source if (_awaitingSnapshot) { - _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps }); - return; + _buffered.Add(ev); + return false; } + return true; + } + + [ClientRpc] + private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps) + { + if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps })) + return; for (int i = 0; i < stamps.Length; i++) _painter.ApplyNetworkStamp(stamps[i]); @@ -387,17 +392,9 @@ public class PlayerPaintSync : NetworkBehaviour [ClientRpc] private void ApplyCheckpointClientRpc(int seq, int id) { - if (IsOwner || !_painter) + if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id })) return; - _seqHorizon = seq; - - if (_awaitingSnapshot) - { - _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id }); - return; - } - _painter.RemoteCommitCheckpoint(id); LogSync($"committed checkpoint {id} (seq {seq})"); } @@ -405,17 +402,9 @@ public class PlayerPaintSync : NetworkBehaviour [ClientRpc] private void ApplyRestoreClientRpc(int seq, int id) { - if (IsOwner || !_painter) + if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Restore, id = id })) return; - _seqHorizon = seq; - - if (_awaitingSnapshot) - { - _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Restore, id = id }); - return; - } - // Reproduce the undo/redo from this peer's own history; if it doesn't hold that checkpoint (an // undo past this peer's join point), fetch a fresh snapshot of the resulting state instead. if (_painter.RemoteRestoreTo(id)) @@ -520,14 +509,20 @@ public class PlayerPaintSync : NetworkBehaviour int xferId = ++_xferOut; BeginSnapshotClientRpc(xferId, data.Length, capturedSeq, baselineId, send); + yield return SendChunked(data, (off, chunk) => SnapshotChunkClientRpc(xferId, off, chunk, send)); + EndSnapshotClientRpc(xferId, send); + } + /// Feeds a blob chunk-wise into sendChunk(offset, chunk), throttled to snapshotChunksPerFrame. + private IEnumerator SendChunked(byte[] data, System.Action sendChunk) + { int sentThisFrame = 0; for (int off = 0; off < data.Length; off += snapshotChunkBytes) { int len = Mathf.Min(snapshotChunkBytes, data.Length - off); var chunk = new byte[len]; System.Array.Copy(data, off, chunk, 0, len); - SnapshotChunkClientRpc(xferId, off, chunk, send); + sendChunk(off, chunk); if (++sentThisFrame >= snapshotChunksPerFrame) { @@ -535,8 +530,6 @@ public class PlayerPaintSync : NetworkBehaviour yield return null; // spread the burst so the reliable window does not overflow } } - - EndSnapshotClientRpc(xferId, send); } [ClientRpc] @@ -564,8 +557,14 @@ public class PlayerPaintSync : NetworkBehaviour private void SnapshotChunkClientRpc(int xferId, int offset, byte[] chunk, ClientRpcParams p = default) { if (xferId != _activeXferId || _snapBuffer == null) return; // stale chunk from a superseded transfer - if (offset >= 0 && offset + chunk.Length <= _snapBuffer.Length) - System.Array.Copy(chunk, 0, _snapBuffer, offset, chunk.Length); + CopyChunkInto(_snapBuffer, offset, chunk); + } + + /// Copies a received chunk into the reassembly buffer, ignoring out-of-bounds offsets. + private static void CopyChunkInto(byte[] buffer, int offset, byte[] chunk) + { + if (offset >= 0 && offset + chunk.Length <= buffer.Length) + System.Array.Copy(chunk, 0, buffer, offset, chunk.Length); } [ClientRpc] @@ -611,22 +610,7 @@ public class PlayerPaintSync : NetworkBehaviour private IEnumerator UploadSnapshot(byte[] data, int cutoff, int baselineId, bool broadcast, ulong target) { BeginUploadServerRpc(data.Length, cutoff, baselineId, broadcast, target); - - int sentThisFrame = 0; - for (int off = 0; off < data.Length; off += snapshotChunkBytes) - { - int len = Mathf.Min(snapshotChunkBytes, data.Length - off); - var chunk = new byte[len]; - System.Array.Copy(data, off, chunk, 0, len); - UploadChunkServerRpc(off, chunk); - - if (++sentThisFrame >= snapshotChunksPerFrame) - { - sentThisFrame = 0; - yield return null; // spread the burst so the reliable window does not overflow - } - } - + yield return SendChunked(data, (off, chunk) => UploadChunkServerRpc(off, chunk)); EndUploadServerRpc(); } @@ -644,8 +628,7 @@ public class PlayerPaintSync : NetworkBehaviour private void UploadChunkServerRpc(int offset, byte[] chunk) { if (_uploadBuffer == null) return; - if (offset >= 0 && offset + chunk.Length <= _uploadBuffer.Length) - System.Array.Copy(chunk, 0, _uploadBuffer, offset, chunk.Length); + CopyChunkInto(_uploadBuffer, offset, chunk); } [ServerRpc] diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs index e500065..25ae156 100644 --- a/Assets/Scripts/PlayerPoseController.cs +++ b/Assets/Scripts/PlayerPoseController.cs @@ -179,7 +179,7 @@ public class PlayerPoseController : MonoBehaviour // Local control only - a remote's pose index is pushed in via SetNetworkState. if (!_networkControlled) { - if (CycleKeyPressed()) + if (InputUtil.GameplayKeyPressed(cycleKey)) NextPose(); // Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default @@ -213,17 +213,21 @@ public class PlayerPoseController : MonoBehaviour // Camera target: a per-pose framing offset (applied in any state), eased so cycling poses glides // the camera, routed to the ThirdPersonCamera's follow target only (never the body). float cameraPoseTarget = pose ? pose.CameraTargetYOffset : 0f; - _cameraPoseOffset = cameraOffsetSpeed > 0f - ? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, cameraOffsetSpeed * Time.deltaTime) - : cameraPoseTarget; + _cameraPoseOffset = EaseCameraOffset(_cameraPoseOffset, cameraPoseTarget); _player.SetCameraTargetLocalYOffset(_cameraPoseOffset); // 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) - : firstPersonTarget; + _firstPersonCameraOffset = EaseCameraOffset(_firstPersonCameraOffset, firstPersonTarget); + } + + /// Eases a camera y-offset toward its target at ; 0 snaps. + private float EaseCameraOffset(float current, float target) + { + return cameraOffsetSpeed > 0f + ? Mathf.MoveTowards(current, target, cameraOffsetSpeed * Time.deltaTime) + : target; } void OnDisable() @@ -339,17 +343,6 @@ public class PlayerPoseController : MonoBehaviour _player.SetCapsule(_capCenterY, _capRadius, _capHeight); } - private bool CycleKeyPressed() - { - if (TypingGuard.IsTyping) return false; -#if ENABLE_INPUT_SYSTEM - var keyboard = Keyboard.current; - return keyboard != null && keyboard[cycleKey].wasPressedThisFrame; -#else - return Input.GetKeyDown(cycleKey); -#endif - } - /// /// Plays and holds the given clip, overriding the Animator Controller. Switching to a different /// clip crossfades from the current pose over (the first pose, or a diff --git a/Assets/Scripts/SkinnedColliderBaker.cs b/Assets/Scripts/SkinnedColliderBaker.cs index 8d0cfe8..d61d68d 100644 --- a/Assets/Scripts/SkinnedColliderBaker.cs +++ b/Assets/Scripts/SkinnedColliderBaker.cs @@ -73,30 +73,12 @@ public class SkinnedColliderBaker : MonoBehaviour // renderer), and MeshPainter / ConcealmentMeter — which raise the baked mesh to world via // localToWorldMatrix — stay correct at any player scale. No-op (and untouched) at scale 1; the // uniform divide is rotation-independent, which the player's uniform scale guarantees. - Vector3 ls = transform.lossyScale; - if (ls.x != 1f || ls.y != 1f || ls.z != 1f) - RescaleToUnitScale(ls); + GeometryUtil.RescaleToUnitScale(_baked, transform.lossyScale, _vbuf); _collider.sharedMesh = null; // force a physics re-cook of the new geometry _collider.sharedMesh = _baked; } - private void RescaleToUnitScale(Vector3 lossyScale) - { - float ix = lossyScale.x != 0f ? 1f / lossyScale.x : 0f; - float iy = lossyScale.y != 0f ? 1f / lossyScale.y : 0f; - float iz = lossyScale.z != 0f ? 1f / lossyScale.z : 0f; - _baked.GetVertices(_vbuf); - for (int i = 0; i < _vbuf.Count; i++) - { - Vector3 v = _vbuf[i]; - v.x *= ix; v.y *= iy; v.z *= iz; - _vbuf[i] = v; - } - _baked.SetVertices(_vbuf); - _baked.RecalculateBounds(); - } - private void OnDestroy() { if (_baked) Destroy(_baked); diff --git a/Assets/Scripts/SmoothedLook.cs b/Assets/Scripts/SmoothedLook.cs new file mode 100644 index 0000000..042a984 --- /dev/null +++ b/Assets/Scripts/SmoothedLook.cs @@ -0,0 +1,62 @@ +using UnityEngine; + +/// +/// Mouse-look yaw/pitch accumulator with optional SmoothDampAngle smoothing, shared by the +/// first-person and freecam rigs. accumulates a scaled mouse delta into the +/// target angles and advances the smoothed display angles; seeds everything +/// from an existing rotation so going live produces no visible jump. +/// +public struct SmoothedLook +{ + float _yaw; // look target (raw input accumulates here) + float _pitch; + float _displayYaw; // smoothed value actually applied to the transform + float _displayPitch; + float _yawSmoothRef; + float _pitchSmoothRef; + + /// Smoothed yaw shown this frame (drives the body's facing in first person). + public float DisplayYaw => _displayYaw; + + /// Rotation composed from the smoothed display angles. + public Quaternion Rotation => Quaternion.Euler(_displayPitch, _displayYaw, 0f); + + /// Accumulates one frame of look input and advances the smoothed display angles. + public void Tick(Vector2 scaledDelta, float hSens, float vSens, bool invertY, float maxPitch, float smoothTime) + { + _yaw += scaledDelta.x * hSens; + float vs = vSens * (invertY ? 1f : -1f); + _pitch = Mathf.Clamp(_pitch + scaledDelta.y * vs, -maxPitch, maxPitch); + + if (smoothTime > 0f) + { + // SmoothDampAngle is frame-rate-independent and wraps yaw correctly. + _displayYaw = Mathf.SmoothDampAngle(_displayYaw, _yaw, ref _yawSmoothRef, smoothTime); + _displayPitch = Mathf.SmoothDampAngle(_displayPitch, _pitch, ref _pitchSmoothRef, smoothTime); + } + else + { + _displayYaw = _yaw; + _displayPitch = _pitch; + } + } + + /// Seeds the look angles from an existing rotation, then snaps the smoothed state to them. + public void SyncTo(Quaternion rotation, float maxPitch) + { + Vector3 e = rotation.eulerAngles; + _yaw = e.y; + float p = e.x > 180f ? e.x - 360f : e.x; + _pitch = Mathf.Clamp(p, -maxPitch, maxPitch); + Snap(); + } + + /// Snaps the smoothed state to the current targets so entering never lags/jumps to catch up. + public void Snap() + { + _displayYaw = _yaw; + _displayPitch = _pitch; + _yawSmoothRef = 0f; + _pitchSmoothRef = 0f; + } +} diff --git a/Assets/Scripts/SmoothedLook.cs.meta b/Assets/Scripts/SmoothedLook.cs.meta new file mode 100644 index 0000000..2297928 --- /dev/null +++ b/Assets/Scripts/SmoothedLook.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b943136bd10858b42b2790061dc1213e \ No newline at end of file