Replace ceiling Y offset with per-pose CharacterController capsule
Each PlayerPose now carries its own collider center Y, radius and height, eased and routed to the CharacterController via a SetCapsule single-writer. This lets crawl poses (Ball/Star) shrink the capsule to fit under furniture. The ceiling Y offset is removed; poses without authored sizes fall back to the base capsule, so existing poses keep their collision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
13de058819
commit
52f9ace9e0
6 files changed
+144
-59
No files matched your search
@@ -18,5 +18,7 @@ MonoBehaviour:
|
||||
walkClipSpeed: 1
|
||||
runClip: {fileID: 0}
|
||||
runClipSpeed: 1
|
||||
ceilingYOffset: 0.84
|
||||
controllerCenterY: 0.25
|
||||
controllerRadius: 0.15
|
||||
controllerHeight: 0.5
|
||||
cameraTargetYOffset: -0.5
|
||||
@@ -18,5 +18,7 @@ MonoBehaviour:
|
||||
walkClipSpeed: 1
|
||||
runClip: {fileID: 0}
|
||||
runClipSpeed: 1
|
||||
ceilingYOffset: 1
|
||||
controllerCenterY: 0.12
|
||||
controllerRadius: 0.1
|
||||
controllerHeight: 0.3
|
||||
cameraTargetYOffset: -0.55
|
||||
@@ -4633,7 +4633,7 @@ MonoBehaviour:
|
||||
wallOffsetReturnSpeed: 2
|
||||
wallOffsetFollow: 12
|
||||
wallExitGrace: 0.15
|
||||
ceilingGrabDistance: 0.3
|
||||
ceilingGrabDistance: 0.02
|
||||
wallTopOutBoost: 0.3
|
||||
cameraOrbit: {fileID: 0}
|
||||
meshOffsetTarget: {fileID: 0}
|
||||
@@ -4660,7 +4660,8 @@ MonoBehaviour:
|
||||
defaultPoseIndex: 0
|
||||
cycleKey: 17
|
||||
poseBlendDuration: 0.2
|
||||
ceilingOffsetSpeed: 4
|
||||
cameraOffsetSpeed: 4
|
||||
capsuleBlendSpeed: 8
|
||||
applyFootIK: 0
|
||||
--- !u!114 &1141039644
|
||||
MonoBehaviour:
|
||||
|
||||
@@ -127,7 +127,7 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
public float wallExitGrace = 0.15f;
|
||||
|
||||
[Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")]
|
||||
public float ceilingGrabDistance = 0.3f;
|
||||
public float ceilingGrabDistance = 0.02f;
|
||||
|
||||
[Tooltip("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")]
|
||||
public float wallTopOutBoost = 0.3f;
|
||||
@@ -185,6 +185,16 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
/// </summary>
|
||||
public Transform MeshOffsetTarget => meshOffsetTarget;
|
||||
|
||||
/// <summary>Authored CharacterController capsule center Y, captured in Awake. The per-pose capsule
|
||||
/// (driven by <see cref="PlayerPoseController"/>) eases back to this when no pose overrides it.</summary>
|
||||
public float BaseCapsuleCenterY { get; private set; }
|
||||
|
||||
/// <summary>Authored CharacterController capsule radius, captured in Awake (see <see cref="BaseCapsuleCenterY"/>).</summary>
|
||||
public float BaseCapsuleRadius { get; private set; }
|
||||
|
||||
/// <summary>Authored CharacterController capsule height, captured in Awake (see <see cref="BaseCapsuleCenterY"/>).</summary>
|
||||
public float BaseCapsuleHeight { get; private set; }
|
||||
|
||||
// Below this horizontal speed (m/s, squared) the character counts as idle for the run animation.
|
||||
private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2
|
||||
|
||||
@@ -194,7 +204,6 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
private bool _sprinting; // sprint key state this frame (exposed via IsSprinting)
|
||||
private bool _isIdle; // snapshot: player essentially still this frame (exposed via IsIdle)
|
||||
private bool _movingOnGround; // snapshot: grounded & moving above the run threshold (exposed via IsMovingOnGround)
|
||||
private float _poseYOffset; // local-up model offset from the active pose (ceiling only); folded into SetMeshLean
|
||||
private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust (+ up / - down); folded into SetMeshLean
|
||||
|
||||
// Ground contact normal from the latest Move (for slope sliding).
|
||||
@@ -234,6 +243,9 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
void Awake()
|
||||
{
|
||||
_controller = GetComponent<CharacterController>();
|
||||
BaseCapsuleCenterY = _controller.center.y;
|
||||
BaseCapsuleRadius = _controller.radius;
|
||||
BaseCapsuleHeight = _controller.height;
|
||||
if (!cameraTransform && Camera.main)
|
||||
cameraTransform = Camera.main.transform;
|
||||
if (!cameraOrbit)
|
||||
@@ -720,7 +732,7 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
Vector3 worldOffset = dir * offset;
|
||||
meshOffsetTarget.localPosition = _meshBaseLocalPos
|
||||
+ transform.InverseTransformVector(worldOffset)
|
||||
+ Vector3.up * (_poseYOffset + _groundOffset); // ceiling pose y-offset + ground mouse raise (both 0 unless in use)
|
||||
+ Vector3.up * _groundOffset; // ground mouse raise (0 unless in use)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -773,21 +785,19 @@ public partial class PlayerCharacterController : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a local-up offset applied to the visual model only, composed into the wall-lean's single
|
||||
/// write of <see cref="meshOffsetTarget"/>. Used by <see cref="PlayerPoseController"/> to hang a
|
||||
/// ceiling pose at the right height; 0 = none. Re-applies immediately so a change shows this frame.
|
||||
/// Resizes the CharacterController capsule (center Y, radius, height) to the active pose's dimensions,
|
||||
/// driven by <see cref="PlayerPoseController"/> so a low pose can fit where the default standing capsule
|
||||
/// is too tall. Only the collision capsule changes — the visual model and camera target are untouched.
|
||||
/// Cached so an unchanged value skips the write (a resize re-bakes the capsule internally).
|
||||
/// </summary>
|
||||
public void SetPoseLocalYOffset(float y)
|
||||
public void SetCapsule(float centerY, float radius, float height)
|
||||
{
|
||||
if (_poseYOffset == y) return;
|
||||
_poseYOffset = y;
|
||||
if (!meshOffsetTarget) return;
|
||||
// While climbing the lean is the live _wallOffset along _wallNormal; otherwise re-apply along
|
||||
// whatever residual lean is easing home (both 0 when there's none, restoring the authored pose).
|
||||
if (_isWallClimbing)
|
||||
SetMeshLean(_wallNormal, _wallOffset);
|
||||
else
|
||||
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
|
||||
Vector3 c = _controller.center;
|
||||
if (c.y == centerY && _controller.radius == radius && _controller.height == height) return;
|
||||
c.y = centerY;
|
||||
_controller.center = c;
|
||||
_controller.radius = radius;
|
||||
_controller.height = height;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,8 +3,9 @@ using UnityEngine;
|
||||
/// <summary>
|
||||
/// A selectable player pose: an idle <see cref="AnimationClip"/> held while the player stands still,
|
||||
/// plus optional walk and run clips that the pose system plays itself (the Animator stays empty) while
|
||||
/// moving on the ground, and a y-offset applied to the visual model only while stuck to the ceiling
|
||||
/// (so the pose hangs at the right height). The first ScriptableObject in this project.
|
||||
/// moving on the ground, its own CharacterController capsule dimensions (so a low pose like a ball can
|
||||
/// crawl under furniture the default standing capsule is too tall for), and a camera-target y-offset that
|
||||
/// frames the pose without moving the body. The first ScriptableObject in this project.
|
||||
///
|
||||
/// Named PlayerPose (not Pose) to avoid shadowing the built-in UnityEngine.Pose struct, mirroring
|
||||
/// why the controller is PlayerCharacterController and not just "PlayerController".
|
||||
@@ -37,14 +38,24 @@ public class PlayerPose : ScriptableObject
|
||||
[Range(0.1f, 3f)]
|
||||
[SerializeField] private float runClipSpeed = 1f;
|
||||
|
||||
[Tooltip("Local-up offset added to the visual model ONLY while stuck to the ceiling, so the pose " +
|
||||
"hangs at the right height. Ignored on the ground and on walls. Meters.")]
|
||||
[SerializeField] private float ceilingYOffset;
|
||||
[Header("Collision")]
|
||||
[Tooltip("CharacterController capsule center Y while this pose is active (meters, local space). Lower " +
|
||||
"this together with Height so a short pose still rests its feet on the floor.")]
|
||||
[SerializeField] private float controllerCenterY = 0.66f;
|
||||
|
||||
[Tooltip("CharacterController capsule radius while this pose is active (meters). Unity clamps it to " +
|
||||
"half the height.")]
|
||||
[Min(0.01f)]
|
||||
[SerializeField] private float controllerRadius = 0.1f;
|
||||
|
||||
[Tooltip("CharacterController capsule height while this pose is active (meters). Lower it (with Center " +
|
||||
"Y) so a ball/star pose can crawl under a table or couch the default capsule is too tall for.")]
|
||||
[Min(0.01f)]
|
||||
[SerializeField] private float controllerHeight = 1.3f;
|
||||
|
||||
[Tooltip("Local-up offset applied to the camera target (the ThirdPersonCamera's follow/look target) " +
|
||||
"while this pose is active, in ANY state. Frames the pose by raising/lowering what the camera " +
|
||||
"tracks, without moving the body. The ceiling offset is added on top of this while on the " +
|
||||
"ceiling. Meters.")]
|
||||
"tracks, without moving the body. Meters.")]
|
||||
[SerializeField] private float cameraTargetYOffset;
|
||||
|
||||
/// <summary>Idle clip held while the player stands still in this pose (may be null).</summary>
|
||||
@@ -65,8 +76,14 @@ public class PlayerPose : ScriptableObject
|
||||
/// <summary>Playback speed multiplier for the run clip (1 = authored speed).</summary>
|
||||
public float RunClipSpeed => runClipSpeed;
|
||||
|
||||
/// <summary>Local-up offset applied to the model only while on the ceiling.</summary>
|
||||
public float CeilingYOffset => ceilingYOffset;
|
||||
/// <summary>CharacterController capsule center Y applied while this pose is active.</summary>
|
||||
public float ControllerCenterY => controllerCenterY;
|
||||
|
||||
/// <summary>CharacterController capsule radius applied while this pose is active.</summary>
|
||||
public float ControllerRadius => controllerRadius;
|
||||
|
||||
/// <summary>CharacterController capsule height applied while this pose is active.</summary>
|
||||
public float ControllerHeight => controllerHeight;
|
||||
|
||||
/// <summary>Local-up offset applied to the camera target while this pose is active (any state).</summary>
|
||||
public float CameraTargetYOffset => cameraTargetYOffset;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Animations;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Serialization;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
@@ -22,15 +23,16 @@ using UnityEngine.InputSystem;
|
||||
/// <see cref="AnimationMixerPlayable"/> on an <see cref="AnimationPlayableOutput"/>) that drives the
|
||||
/// Animator directly. Switching clip — between poses, or between idle/walk/run within a pose —
|
||||
/// crossfades the outgoing and incoming clips over <see cref="poseBlendDuration"/> instead of snapping.
|
||||
/// The per-pose ceiling y-offset is eased toward its target and routed through the controller (the
|
||||
/// single writer of the model-offset transform) so it composes with the wall-lean instead of fighting it.
|
||||
/// A separate per-pose camera y-offset (plus that same ceiling offset) is likewise eased and routed to the
|
||||
/// controller's camera target, framing each pose for the ThirdPersonCamera without moving the body.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(PlayerCharacterController))]
|
||||
[DisallowMultipleComponent]
|
||||
// Run after PlayerCharacterController (default order 0) so the ceiling / sprint state polled below is
|
||||
// this frame's, and the ceiling offset resolves without a one-frame seam.
|
||||
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
|
||||
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
|
||||
[DefaultExecutionOrder(100)]
|
||||
public partial class PlayerPoseController : MonoBehaviour
|
||||
{
|
||||
@@ -56,9 +58,16 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
[Min(0f)]
|
||||
[SerializeField] private float poseBlendDuration = 0.2f;
|
||||
|
||||
[Tooltip("Approach speed (local units/sec) for easing the ceiling y-offset toward its target. 0 = instant.")]
|
||||
[Tooltip("Approach speed (local units/sec) for easing the per-pose camera-target y-offset toward its " +
|
||||
"target while cycling poses. 0 = instant.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float ceilingOffsetSpeed = 4f;
|
||||
[FormerlySerializedAs("ceilingOffsetSpeed")]
|
||||
[SerializeField] private float cameraOffsetSpeed = 4f;
|
||||
|
||||
[Tooltip("Blend speed (0..1 per second) for resizing the CharacterController capsule toward the active " +
|
||||
"pose's dimensions when the pose changes. 0 = instant snap; higher = snappier resize.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float capsuleBlendSpeed = 8f;
|
||||
|
||||
[Tooltip("Apply Mecanim foot IK to the played humanoid clips. Off (default) plays each pose exactly as " +
|
||||
"authored. On bends the knees to plant the feet on the IK goal, which makes a straight-knee idle " +
|
||||
@@ -85,8 +94,16 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose
|
||||
private bool _blending;
|
||||
|
||||
private float _ceilingOffset; // eased local-up offset currently fed to the controller (mesh + camera)
|
||||
private float _cameraPoseOffset; // eased per-pose camera-target offset; the ceiling offset is added on top when routed to the controller
|
||||
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
|
||||
|
||||
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
|
||||
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
|
||||
// Snapped on the first frame so the player spawns at its pose size with no resize blend.
|
||||
private bool _capsuleStarted;
|
||||
private float _capFromCenterY, _capFromRadius, _capFromHeight;
|
||||
private float _capToCenterY, _capToRadius, _capToHeight;
|
||||
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
|
||||
@@ -94,7 +111,7 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
// 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 / ceiling offset.
|
||||
// so it is the root of this subtree, and resetting it would wipe the wall-lean / ground raise.
|
||||
private Transform[] _restBones;
|
||||
private Vector3[] _restPos;
|
||||
private Quaternion[] _restRot;
|
||||
@@ -144,38 +161,37 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
}
|
||||
PlayPose(clip, speed);
|
||||
|
||||
// Ceiling y-offset: only while stuck to the ceiling. Ease toward the target instead of
|
||||
// snapping, so entering and leaving the ceiling (and switching ceiling poses) glides. Routed
|
||||
// through the controller so it shares the wall-lean's single write of the model transform.
|
||||
float ceilingTarget = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f;
|
||||
_ceilingOffset = ceilingOffsetSpeed > 0f
|
||||
? Mathf.MoveTowards(_ceilingOffset, ceilingTarget, ceilingOffsetSpeed * Time.deltaTime)
|
||||
: ceilingTarget;
|
||||
_player.SetPoseLocalYOffset(_ceilingOffset);
|
||||
// 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.
|
||||
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
|
||||
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
|
||||
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
|
||||
float capHeight = hasCapsule ? pose.ControllerHeight : _player.BaseCapsuleHeight;
|
||||
ApplyCapsule(capCenterY, capRadius, capHeight);
|
||||
|
||||
// Camera target: a per-pose framing offset (applied in any state) plus the same ceiling offset,
|
||||
// routed to the ThirdPersonCamera's follow target only (never the body). The pose part eases like
|
||||
// the ceiling offset so cycling poses glides the camera; the ceiling part reuses the value above so
|
||||
// camera and model stay in lockstep on the ceiling.
|
||||
// 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 = ceilingOffsetSpeed > 0f
|
||||
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, ceilingOffsetSpeed * Time.deltaTime)
|
||||
_cameraPoseOffset = cameraOffsetSpeed > 0f
|
||||
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, cameraOffsetSpeed * Time.deltaTime)
|
||||
: cameraPoseTarget;
|
||||
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset + _ceilingOffset);
|
||||
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
// Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the
|
||||
// next idle frame. Also clear any ceiling offset we were holding.
|
||||
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
|
||||
ReleasePose();
|
||||
_ceilingOffset = 0f;
|
||||
_cameraPoseOffset = 0f;
|
||||
if (_player)
|
||||
{
|
||||
_player.SetPoseLocalYOffset(0f);
|
||||
_player.SetCameraTargetLocalYOffset(0f);
|
||||
_player.SetCapsule(_player.BaseCapsuleCenterY, _player.BaseCapsuleRadius, _player.BaseCapsuleHeight);
|
||||
}
|
||||
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
|
||||
}
|
||||
|
||||
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
|
||||
@@ -200,6 +216,43 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
|
||||
/// the controller. A single normalized blend drives all three values together, so the capsule stays a
|
||||
/// valid shape (and its floor contact moves linearly) throughout the resize. The first call snaps, so
|
||||
/// the player spawns at its pose size without a resize blend; <see cref="capsuleBlendSpeed"/> 0 also snaps.
|
||||
/// </summary>
|
||||
private void ApplyCapsule(float centerY, float radius, float height)
|
||||
{
|
||||
if (!_capsuleStarted)
|
||||
{
|
||||
_capsuleStarted = true;
|
||||
_capFromCenterY = _capToCenterY = _capCenterY = centerY;
|
||||
_capFromRadius = _capToRadius = _capRadius = radius;
|
||||
_capFromHeight = _capToHeight = _capHeight = height;
|
||||
_capBlend = 1f;
|
||||
}
|
||||
else if (centerY != _capToCenterY || radius != _capToRadius || height != _capToHeight)
|
||||
{
|
||||
// New target (pose changed): ease from wherever the capsule currently is.
|
||||
_capFromCenterY = _capCenterY;
|
||||
_capFromRadius = _capRadius;
|
||||
_capFromHeight = _capHeight;
|
||||
_capToCenterY = centerY;
|
||||
_capToRadius = radius;
|
||||
_capToHeight = height;
|
||||
_capBlend = 0f;
|
||||
}
|
||||
|
||||
_capBlend = capsuleBlendSpeed > 0f
|
||||
? Mathf.MoveTowards(_capBlend, 1f, capsuleBlendSpeed * Time.deltaTime)
|
||||
: 1f;
|
||||
_capCenterY = Mathf.Lerp(_capFromCenterY, _capToCenterY, _capBlend);
|
||||
_capRadius = Mathf.Lerp(_capFromRadius, _capToRadius, _capBlend);
|
||||
_capHeight = Mathf.Lerp(_capFromHeight, _capToHeight, _capBlend);
|
||||
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
|
||||
}
|
||||
|
||||
private bool CycleKeyPressed()
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
@@ -354,7 +407,7 @@ public partial class PlayerPoseController : MonoBehaviour
|
||||
}
|
||||
|
||||
// Drop the model-offset transform from the reset set so the per-frame ResetToRestPose never
|
||||
// touches it: the wall-lean / ceiling offset (PlayerCharacterController.SetMeshLean) is its sole
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user