提交测试脚本
This commit is contained in:
@@ -152,14 +152,14 @@ namespace NBF
|
||||
|
||||
_RigidBody.AddForce(buoyancy, ForceMode.Acceleration);
|
||||
|
||||
// // 在水面上滑行的近似流体动力学
|
||||
// if (_AccelerateDownhill > 0f)
|
||||
// {
|
||||
// _RigidBody.AddForce(_AccelerateDownhill * -Physics.gravity.y * new Vector3(normal.x, 0f, normal.z),
|
||||
// ForceMode.Acceleration);
|
||||
// }
|
||||
//
|
||||
//
|
||||
// 在水面上滑行的近似流体动力学
|
||||
if (_AccelerateDownhill > 0f)
|
||||
{
|
||||
_RigidBody.AddForce(_AccelerateDownhill * -Physics.gravity.y * new Vector3(normal.x, 0f, normal.z),
|
||||
ForceMode.Acceleration);
|
||||
}
|
||||
|
||||
|
||||
// // 朝向
|
||||
// // 与水面垂直。默认使用一个垂直方向,但也可以使用单独的垂直方向。
|
||||
// // 根据船的长度与宽度的比例。这会根据船只的不同而产生不同的旋转效果。
|
||||
@@ -174,8 +174,30 @@ namespace NBF
|
||||
// _RigidBody.AddTorque(torqueWidth * _BuoyancyTorqueStrength, ForceMode.Acceleration);
|
||||
// _RigidBody.AddTorque(-_AngularDrag * _RigidBody.angularVelocity);
|
||||
// }
|
||||
//
|
||||
//
|
||||
|
||||
// 朝向(改进的扭矩版本)
|
||||
{
|
||||
var normalLatitudinal = normal;
|
||||
|
||||
if (_Debug._DrawQueries)
|
||||
Debug.DrawLine(transform.position, transform.position + 5f * normalLatitudinal, Color.green);
|
||||
|
||||
// 限制扭矩强度基于物体质量
|
||||
var massFactor = Mathf.Clamp(_RigidBody.mass / 0.1f, 0.1f, 1f); // 质量越小,扭矩越弱
|
||||
var adjustedTorqueStrength = _BuoyancyTorqueStrength * massFactor;
|
||||
|
||||
var torqueWidth = Vector3.Cross(transform.up, normalLatitudinal);
|
||||
|
||||
// 添加最大扭矩限制
|
||||
var maxTorque = 5f; // 根据需要调整
|
||||
var finalTorque = Vector3.ClampMagnitude(torqueWidth * adjustedTorqueStrength, maxTorque);
|
||||
|
||||
_RigidBody.AddTorque(finalTorque, ForceMode.Acceleration);
|
||||
_RigidBody.AddTorque(-_AngularDrag * _RigidBody.angularVelocity, ForceMode.Acceleration);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 相对于水进行拖拽操作
|
||||
if (_Drag != Vector3.zero)
|
||||
{
|
||||
|
||||
@@ -35,20 +35,12 @@ public class Buoyancy : MonoBehaviour
|
||||
[Tooltip("When enabled, the net force is applied with a random offset to create an angular velocity.")]
|
||||
public bool applyForceWithRandomOffset;
|
||||
|
||||
[Tooltip("When enabled, the height of the custom mesh is taken into account when fetching water surface height.")]
|
||||
public bool isWaterSurfaceACustomMesh;
|
||||
|
||||
[Tooltip(
|
||||
"When enabled, a bunch of gizmos will show showing in blue, the position of the sampling for normals, in magenta the computed normal, in green the direction of the deformation force, in red the direction of the current force, and in a yellow sphere, the approximation volume the buoyancy calculations.")]
|
||||
public bool drawDebug;
|
||||
|
||||
private Vector3 currentDirection;
|
||||
|
||||
// private Vector3 A;
|
||||
//
|
||||
// private Vector3 B;
|
||||
//
|
||||
// private Vector3 C;
|
||||
|
||||
private Vector3 waterPosition;
|
||||
|
||||
@@ -87,83 +79,123 @@ public class Buoyancy : MonoBehaviour
|
||||
{
|
||||
if (!_isEnabled)
|
||||
{
|
||||
h = (hNormalized = 0f);
|
||||
h = 0f;
|
||||
hNormalized = 0f;
|
||||
rigidbodyComponent.useGravity = true;
|
||||
return;
|
||||
}
|
||||
else if (_water != null)
|
||||
{
|
||||
rigidbodyComponent.useGravity = false;
|
||||
FetchWaterSurfaceData(base.transform.position, out waterPosition, out normal, out currentDirection);
|
||||
deformationDirection = Vector3.ProjectOnPlane(normal, Vector3.up);
|
||||
h = Mathf.Clamp(waterPosition.y - (base.transform.position.y - sphereRadiusApproximation), 0f,
|
||||
2f * sphereRadiusApproximation);
|
||||
hNormalized = h * 1f / (2f * sphereRadiusApproximation);
|
||||
float num = MathF.PI * h * h / 3f * (3f * sphereRadiusApproximation - h);
|
||||
rigidbodyComponent.angularDamping = Mathf.Lerp(overwaterRigidbodyAngularDrag,
|
||||
underwaterRigidbodyAngularDrag, hNormalized);
|
||||
Vector3 b = rigidbodyComponent.mass * Physics.gravity;
|
||||
Vector3 vector = Vector3.Lerp(Physics.gravity, b, hNormalized);
|
||||
float num2 = 997f;
|
||||
float num3 = 0.001293f;
|
||||
// float num4 = 1.81E-05f;
|
||||
float num4 = 0.0000181f;
|
||||
float num5 = 0.001f;
|
||||
float num6 = 0.47f;
|
||||
Vector3 vector2 = (0f - num2) * num * Physics.gravity;
|
||||
Vector3 a = MathF.PI * 6f * sphereRadiusApproximation * num4 * -rigidbodyComponent.linearVelocity;
|
||||
Vector3 b2 = MathF.PI * 6f * sphereRadiusApproximation * num5 * -rigidbodyComponent.linearVelocity;
|
||||
Vector3 vector3 = Vector3.Lerp(a, b2, hNormalized) * dragMultiplier;
|
||||
float num7 = Mathf.Lerp(
|
||||
b: Mathf.Sqrt(2f * rigidbodyComponent.mass * (0f - Physics.gravity.y) /
|
||||
(num2 * MathF.PI * Mathf.Pow(sphereRadiusApproximation, 2f) * num6)),
|
||||
a: Mathf.Sqrt(2f * rigidbodyComponent.mass * (0f - Physics.gravity.y) /
|
||||
(num3 * MathF.PI * Mathf.Pow(sphereRadiusApproximation, 2f) * num6)), t: hNormalized);
|
||||
Vector3 force = vector + vector2 + vector3;
|
||||
Vector3 vector4 = (applyForceWithRandomOffset
|
||||
? (new Vector3(UnityEngine.Random.Range(-1f, 1f), UnityEngine.Random.Range(-1f, 1f),
|
||||
UnityEngine.Random.Range(-1f, 1f)) * sphereRadiusApproximation / 5f)
|
||||
: Vector3.zero);
|
||||
rigidbodyComponent.AddForceAtPosition(force, base.transform.position + vector4, ForceMode.Acceleration);
|
||||
if (hNormalized > 0f && hNormalized < 1f)
|
||||
{
|
||||
Vector3 force2 =
|
||||
-(Vector3.Dot(rigidbodyComponent.linearVelocity, Physics.gravity.normalized) *
|
||||
Physics.gravity.normalized) * surfaceTensionDamping;
|
||||
rigidbodyComponent.AddForce(force2, ForceMode.Acceleration);
|
||||
rigidbodyComponent.AddForce(deformationDirection * waveForceMultiplier);
|
||||
rigidbodyComponent.AddForce(currentDirection * currentSpeedMultiplier);
|
||||
}
|
||||
|
||||
if (rigidbodyComponent.linearVelocity.magnitude > num7)
|
||||
{
|
||||
rigidbodyComponent.linearVelocity = rigidbodyComponent.linearVelocity.normalized * num7;
|
||||
}
|
||||
if (_water == null)
|
||||
return;
|
||||
|
||||
rigidbodyComponent.useGravity = false;
|
||||
|
||||
// Cache frequently used values
|
||||
Vector3 pos = transform.position;
|
||||
Vector3 velocity = rigidbodyComponent.linearVelocity;
|
||||
float radius = sphereRadiusApproximation;
|
||||
float diameter = 2f * radius;
|
||||
|
||||
// 1) Sample water surface at current position
|
||||
FetchWaterSurfaceData(pos, out waterPosition, out normal, out currentDirection);
|
||||
|
||||
// Horizontal “wave push” direction derived from surface normal
|
||||
deformationDirection = Vector3.ProjectOnPlane(normal, Vector3.up);
|
||||
|
||||
// 2) Submersion depth of the approximated sphere (0..2R)
|
||||
float bottomY = pos.y - radius;
|
||||
h = Mathf.Clamp(waterPosition.y - bottomY, 0f, diameter);
|
||||
hNormalized = (diameter > 0f) ? (h / diameter) : 0f;
|
||||
|
||||
// 3) Submerged volume (spherical cap) V(h) = πh²/3 * (3R - h)
|
||||
float submergedVolume = MathF.PI * h * h / 3f * (3f * radius - h);
|
||||
|
||||
// 4) Angular damping transitions from air to water
|
||||
rigidbodyComponent.angularDamping = Mathf.Lerp(
|
||||
overwaterRigidbodyAngularDrag,
|
||||
underwaterRigidbodyAngularDrag,
|
||||
hNormalized
|
||||
);
|
||||
|
||||
// 5) Forces (kept identical to your original math/units)
|
||||
Vector3 gravityAcceleration = Physics.gravity;
|
||||
|
||||
// NOTE: this is kept as-is (original mixes accel & force then uses Acceleration mode)
|
||||
Vector3 weightVector = rigidbodyComponent.mass * gravityAcceleration;
|
||||
Vector3 gravityTerm = Vector3.Lerp(gravityAcceleration, weightVector, hNormalized);
|
||||
|
||||
// Physical constants (as in original)
|
||||
const float rhoWater = 997f; // kg/m^3
|
||||
const float rhoAir = 0.001293f; // kg/m^3
|
||||
const float muAir = 0.0000181f; // Pa·s
|
||||
const float muWater = 0.001f; // Pa·s
|
||||
const float dragCoefficient = 0.47f;
|
||||
|
||||
// Buoyancy: -ρ * V * g
|
||||
Vector3 buoyancyTerm = (-rhoWater) * submergedVolume * gravityAcceleration;
|
||||
|
||||
// Linear viscous drag (Stokes-like): 6πRμ(-v) -> blend air/water by submersion
|
||||
Vector3 dragAir = MathF.PI * 6f * radius * muAir * (-velocity);
|
||||
Vector3 dragWater = MathF.PI * 6f * radius * muWater * (-velocity);
|
||||
Vector3 viscousDragTerm = Vector3.Lerp(dragAir, dragWater, hNormalized) * dragMultiplier;
|
||||
|
||||
// Net force (still applied as Acceleration)
|
||||
Vector3 netAcceleration = gravityTerm + buoyancyTerm + viscousDragTerm;
|
||||
|
||||
// Optional random offset to induce angular velocity
|
||||
Vector3 randomOffset = Vector3.zero;
|
||||
if (applyForceWithRandomOffset)
|
||||
{
|
||||
randomOffset = new Vector3(
|
||||
UnityEngine.Random.Range(-1f, 1f),
|
||||
UnityEngine.Random.Range(-1f, 1f),
|
||||
UnityEngine.Random.Range(-1f, 1f)
|
||||
) * (radius / 5f);
|
||||
}
|
||||
|
||||
rigidbodyComponent.AddForceAtPosition(
|
||||
netAcceleration,
|
||||
pos + randomOffset,
|
||||
ForceMode.Acceleration
|
||||
);
|
||||
|
||||
// 6) Extra forces only around the surface transition (0<hN<1)
|
||||
if (hNormalized > 0f && hNormalized < 1f)
|
||||
{
|
||||
Vector3 gravityDir = gravityAcceleration.normalized;
|
||||
|
||||
// Surface tension damping: cancels velocity along gravity direction
|
||||
Vector3 verticalVelocity = Vector3.Dot(velocity, gravityDir) * gravityDir;
|
||||
Vector3 surfaceTensionAccel = -verticalVelocity * surfaceTensionDamping;
|
||||
|
||||
rigidbodyComponent.AddForce(surfaceTensionAccel, ForceMode.Acceleration);
|
||||
rigidbodyComponent.AddForce(deformationDirection * waveForceMultiplier, ForceMode.Acceleration);
|
||||
rigidbodyComponent.AddForce(currentDirection * currentSpeedMultiplier, ForceMode.Acceleration);
|
||||
}
|
||||
|
||||
// 7) Terminal velocity clamp (same formula, just named)
|
||||
float area = MathF.PI * radius * radius;
|
||||
float g = -gravityAcceleration.y; // positive value
|
||||
|
||||
float terminalVelocityInAir = Mathf.Sqrt(2f * rigidbodyComponent.mass * g / (rhoAir * area * dragCoefficient));
|
||||
float terminalVelocityInWater =
|
||||
Mathf.Sqrt(2f * rigidbodyComponent.mass * g / (rhoWater * area * dragCoefficient));
|
||||
float terminalVelocity = Mathf.Lerp(terminalVelocityInAir, terminalVelocityInWater, hNormalized);
|
||||
|
||||
float speed = rigidbodyComponent.linearVelocity.magnitude;
|
||||
if (speed > terminalVelocity && speed > 1e-6f)
|
||||
{
|
||||
rigidbodyComponent.linearVelocity = rigidbodyComponent.linearVelocity / speed * terminalVelocity;
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 FetchWaterSurfaceData(Vector3 point, out Vector3 positionWS, out Vector3 normalWS,
|
||||
out Vector3 currentDirectionWS)
|
||||
{
|
||||
WaterSearchParameters wsp = default(WaterSearchParameters);
|
||||
WaterSearchResult wsr = default(WaterSearchResult);
|
||||
wsp.startPositionWS = wsr.candidateLocationWS;
|
||||
wsp.targetPositionWS = point;
|
||||
wsp.error = 0.01f;
|
||||
wsp.maxIterations = 8;
|
||||
wsp.includeDeformation = includeDeformation;
|
||||
wsp.outputNormal = true;
|
||||
positionWS = wsr.candidateLocationWS;
|
||||
currentDirectionWS = Vector3.zero;
|
||||
positionWS = Vector3.zero;
|
||||
normalWS = Vector3.up;
|
||||
currentDirectionWS = Vector3.right;
|
||||
if (targetSurface.ProjectPointOnWaterSurface(wsp, out wsr))
|
||||
{
|
||||
positionWS = wsr.projectedPositionWS;
|
||||
currentDirectionWS = wsr.currentDirectionWS;
|
||||
normalWS = wsr.normalWS;
|
||||
}
|
||||
|
||||
Vector3 vector = (isWaterSurfaceACustomMesh ? (Vector3.up * _water.transform.position.y) : Vector3.zero);
|
||||
return positionWS + vector;
|
||||
return point;
|
||||
}
|
||||
|
||||
public Vector3 GetCurrentWaterPosition()
|
||||
@@ -181,10 +213,6 @@ public class Buoyancy : MonoBehaviour
|
||||
{
|
||||
if (drawDebug)
|
||||
{
|
||||
// Gizmos.color = Color.blue;
|
||||
// Gizmos.DrawSphere(A, sphereRadiusApproximation / 10f);
|
||||
// Gizmos.DrawSphere(B, sphereRadiusApproximation / 10f);
|
||||
// Gizmos.DrawSphere(C, sphereRadiusApproximation / 10f);
|
||||
Gizmos.color = Color.magenta;
|
||||
Gizmos.DrawLine(base.transform.position, base.transform.position + normal);
|
||||
Gizmos.color = Color.green;
|
||||
|
||||
388
Assets/Scripts/Test/Buoyancy2.cs
Normal file
388
Assets/Scripts/Test/Buoyancy2.cs
Normal file
@@ -0,0 +1,388 @@
|
||||
using UnityEngine;
|
||||
|
||||
[RequireComponent(typeof(Rigidbody))]
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public class Buoyancy2 : MonoBehaviour
|
||||
{
|
||||
[Header("物理参数")] [SerializeField] private float waterDensity = 1000f; // 水的密度 (kg/m³)
|
||||
[SerializeField] private float gravity = 9.81f; // 重力加速度 (m/s²)
|
||||
[SerializeField] private float buoyancyMultiplier = 1f; // 浮力倍数
|
||||
|
||||
[Header("阻尼设置")] [SerializeField] private float linearDragInWater = 1f; // 水中线性阻尼
|
||||
[SerializeField] private float angularDragInWater = 0.5f; // 水中角阻尼
|
||||
[SerializeField] private float airLinearDrag = 0.1f; // 空气中线性阻尼
|
||||
[SerializeField] private float airAngularDrag = 0.05f; // 空气中角阻尼
|
||||
|
||||
[Header("水面设置")] [SerializeField] private float waterSurfaceLevel = 0f; // 水面高度
|
||||
[SerializeField] private bool autoDetectWaterLevel = true; // 自动检测水面高度
|
||||
|
||||
[Header("采样精度")] [SerializeField] private int sampleResolution = 5; // 采样分辨率 (每维采样点数)
|
||||
[SerializeField] private bool useAdaptiveSampling = true; // 使用自适应采样
|
||||
[SerializeField] private int maxSamples = 125; // 最大采样点数
|
||||
|
||||
[Header("稳定性控制")] [SerializeField] private float stabilityFactor = 0.3f; // 稳定性因子
|
||||
[SerializeField] private float rightingTorqueStrength = 2f; // 扶正扭矩强度
|
||||
[SerializeField] private bool enableMetacentricStability = true; // 启用稳心稳定性
|
||||
|
||||
[Header("调试显示")] [SerializeField] private bool showDebugInfo = false;
|
||||
[SerializeField] private bool drawGizmos = true;
|
||||
[SerializeField] private Color immersedColor = Color.blue;
|
||||
[SerializeField] private Color surfaceColor = Color.cyan;
|
||||
|
||||
// 私有变量
|
||||
private Rigidbody rb;
|
||||
private Collider objCollider;
|
||||
private Vector3[] samplePoints;
|
||||
private Vector3 centerOfBuoyancy; // 浮心位置
|
||||
private Vector3 centerOfGravity; // 重心位置
|
||||
private float totalVolume; // 总体积 (m³)
|
||||
private float immersedVolume; // 浸入体积 (m³)
|
||||
private float immersedRatio; // 浸入比例
|
||||
private Vector3 metacenter; // 稳心位置
|
||||
private bool isPartiallySubmerged;
|
||||
|
||||
// 缓存值
|
||||
private Vector3 lastPosition;
|
||||
private Quaternion lastRotation;
|
||||
private float lastWaterLevel;
|
||||
|
||||
void Start()
|
||||
{
|
||||
InitializeComponents();
|
||||
CalculatePhysicalProperties();
|
||||
InitializeSamplingGrid();
|
||||
ValidateSetup();
|
||||
}
|
||||
|
||||
void FixedUpdate()
|
||||
{
|
||||
UpdateWaterLevel();
|
||||
CalculateBuoyancy();
|
||||
ApplyForces();
|
||||
UpdateDrag();
|
||||
ApplyStabilityCorrection();
|
||||
}
|
||||
|
||||
void InitializeComponents()
|
||||
{
|
||||
rb = GetComponent<Rigidbody>();
|
||||
objCollider = GetComponent<Collider>();
|
||||
|
||||
if (rb == null)
|
||||
{
|
||||
Debug.LogError($"{gameObject.name}: 需要 Rigidbody 组件!", this);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (objCollider == null)
|
||||
{
|
||||
Debug.LogError($"{gameObject.name}: 需要 Collider 组件!", this);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存初始重心位置
|
||||
centerOfGravity = rb.centerOfMass;
|
||||
}
|
||||
|
||||
void CalculatePhysicalProperties()
|
||||
{
|
||||
// 计算物体的真实体积
|
||||
totalVolume = CalculateRealVolume();
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Debug.Log(
|
||||
$"{gameObject.name} - 体积: {totalVolume:F4} m³, 质量: {rb.mass:F2} kg, 密度: {rb.mass / totalVolume:F2} kg/m³");
|
||||
}
|
||||
}
|
||||
|
||||
float CalculateRealVolume()
|
||||
{
|
||||
// 根据碰撞器类型精确计算体积
|
||||
if (objCollider is BoxCollider box)
|
||||
{
|
||||
Vector3 size = Vector3.Scale(box.size, transform.lossyScale);
|
||||
return size.x * size.y * size.z;
|
||||
}
|
||||
else if (objCollider is SphereCollider sphere)
|
||||
{
|
||||
float radius = sphere.radius *
|
||||
Mathf.Max(transform.lossyScale.x, transform.lossyScale.y, transform.lossyScale.z);
|
||||
return (4f / 3f) * Mathf.PI * Mathf.Pow(radius, 3);
|
||||
}
|
||||
else if (objCollider is CapsuleCollider capsule)
|
||||
{
|
||||
float radius = capsule.radius * Mathf.Max(transform.lossyScale.x, transform.lossyScale.z);
|
||||
float height = capsule.height * transform.lossyScale.y;
|
||||
return Mathf.PI * radius * radius * height;
|
||||
}
|
||||
else if (objCollider is MeshCollider mesh)
|
||||
{
|
||||
// 对于网格碰撞器,使用包围盒近似计算
|
||||
Bounds bounds = mesh.bounds;
|
||||
Vector3 size = Vector3.Scale(bounds.size, transform.lossyScale);
|
||||
return size.x * size.y * size.z * 0.6f; // 乘以填充系数
|
||||
}
|
||||
else
|
||||
{
|
||||
// 其他类型使用包围盒
|
||||
Bounds bounds = objCollider.bounds;
|
||||
return bounds.size.x * bounds.size.y * bounds.size.z;
|
||||
}
|
||||
}
|
||||
|
||||
void InitializeSamplingGrid()
|
||||
{
|
||||
int resolution = sampleResolution;
|
||||
|
||||
// 根据物体大小自适应调整采样精度
|
||||
if (useAdaptiveSampling)
|
||||
{
|
||||
float objectSize = objCollider.bounds.size.magnitude;
|
||||
resolution = Mathf.Clamp(Mathf.RoundToInt(objectSize * 10), 3, 8);
|
||||
}
|
||||
|
||||
// 限制最大采样点数
|
||||
while (resolution * resolution * resolution > maxSamples && resolution > 2)
|
||||
{
|
||||
resolution--;
|
||||
}
|
||||
|
||||
int totalSamples = resolution * resolution * resolution;
|
||||
samplePoints = new Vector3[totalSamples];
|
||||
|
||||
int index = 0;
|
||||
for (int x = 0; x < resolution; x++)
|
||||
{
|
||||
for (int y = 0; y < resolution; y++)
|
||||
{
|
||||
for (int z = 0; z < resolution; z++)
|
||||
{
|
||||
// 生成标准化坐标 [-0.5, 0.5]
|
||||
float nx = ((float)x / (resolution - 1)) - 0.5f;
|
||||
float ny = ((float)y / (resolution - 1)) - 0.5f;
|
||||
float nz = ((float)z / (resolution - 1)) - 0.5f;
|
||||
|
||||
samplePoints[index] = new Vector3(nx, ny, nz);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDebugInfo)
|
||||
{
|
||||
Debug.Log($"{gameObject.name} - 采样点数: {totalSamples}, 分辨率: {resolution}");
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateWaterLevel()
|
||||
{
|
||||
if (autoDetectWaterLevel)
|
||||
{
|
||||
// 可以在这里添加自动检测水面高度的逻辑
|
||||
// 例如射线检测或其他方法
|
||||
}
|
||||
}
|
||||
|
||||
void CalculateBuoyancy()
|
||||
{
|
||||
immersedVolume = 0f;
|
||||
centerOfBuoyancy = Vector3.zero;
|
||||
int immersedCount = 0;
|
||||
|
||||
Bounds bounds = objCollider.bounds;
|
||||
Vector3 boundsCenter = bounds.center;
|
||||
Vector3 boundsExtents = bounds.extents;
|
||||
|
||||
foreach (Vector3 normalizedPoint in samplePoints)
|
||||
{
|
||||
// 将标准化坐标转换为世界坐标
|
||||
Vector3 worldPoint = boundsCenter + Vector3.Scale(normalizedPoint, boundsExtents * 2f);
|
||||
|
||||
if (worldPoint.y < waterSurfaceLevel)
|
||||
{
|
||||
immersedVolume += 1f;
|
||||
centerOfBuoyancy += worldPoint;
|
||||
immersedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算浸入比例
|
||||
immersedRatio = (float)immersedCount / samplePoints.Length;
|
||||
|
||||
// 计算实际浸入体积
|
||||
immersedVolume = totalVolume * immersedRatio;
|
||||
|
||||
// 计算浮心位置
|
||||
if (immersedCount > 0)
|
||||
{
|
||||
centerOfBuoyancy /= immersedCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
centerOfBuoyancy = rb.worldCenterOfMass;
|
||||
}
|
||||
|
||||
isPartiallySubmerged = immersedRatio > 0f && immersedRatio < 1f;
|
||||
}
|
||||
|
||||
void ApplyForces()
|
||||
{
|
||||
if (immersedRatio <= 0f) return;
|
||||
|
||||
// 计算浮力: F = ρ × V × g
|
||||
float buoyancyForceMagnitude = waterDensity * immersedVolume * gravity * buoyancyMultiplier;
|
||||
|
||||
// 浮力方向始终向上
|
||||
Vector3 buoyancyForce = Vector3.up * buoyancyForceMagnitude;
|
||||
|
||||
// 在浮心位置施加浮力
|
||||
rb.AddForceAtPosition(buoyancyForce, centerOfBuoyancy, ForceMode.Force);
|
||||
|
||||
// 添加由于浮力分布不均产生的扭矩
|
||||
if (isPartiallySubmerged)
|
||||
{
|
||||
Vector3 buoyancyTorque = Vector3.Cross(centerOfBuoyancy - rb.worldCenterOfMass, buoyancyForce);
|
||||
rb.AddTorque(buoyancyTorque * stabilityFactor, ForceMode.Force);
|
||||
}
|
||||
|
||||
if (showDebugInfo && immersedRatio > 0.1f)
|
||||
{
|
||||
Debug.DrawRay(centerOfBuoyancy, buoyancyForce * 0.01f, Color.blue, Time.fixedDeltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateDrag()
|
||||
{
|
||||
// 根据浸入程度插值阻尼系数
|
||||
float currentLinearDrag = Mathf.Lerp(airLinearDrag, linearDragInWater, immersedRatio);
|
||||
float currentAngularDrag = Mathf.Lerp(airAngularDrag, angularDragInWater, immersedRatio);
|
||||
|
||||
rb.linearDamping = currentLinearDrag;
|
||||
rb.angularDamping = currentAngularDrag;
|
||||
}
|
||||
|
||||
void ApplyStabilityCorrection()
|
||||
{
|
||||
if (!enableMetacentricStability || immersedRatio <= 0f) return;
|
||||
|
||||
// 计算稳心位置(简化模型)
|
||||
CalculateMetacenter();
|
||||
|
||||
// 应用扶正扭矩
|
||||
if (isPartiallySubmerged)
|
||||
{
|
||||
Vector3 rightingArm = metacenter - rb.worldCenterOfMass;
|
||||
Vector3 restoringTorque = Vector3.Cross(rightingArm, Vector3.down) * rightingTorqueStrength * immersedRatio;
|
||||
rb.AddTorque(restoringTorque, ForceMode.Force);
|
||||
}
|
||||
}
|
||||
|
||||
void CalculateMetacenter()
|
||||
{
|
||||
// 简化的稳心计算:假设稳心在浮心上方一定距离
|
||||
float metacentricHeight = Mathf.Max(objCollider.bounds.size.y * 0.3f, 0.1f);
|
||||
metacenter = centerOfBuoyancy + Vector3.up * metacentricHeight;
|
||||
}
|
||||
|
||||
void ValidateSetup()
|
||||
{
|
||||
// 检查物体密度是否合理
|
||||
float objectDensity = rb.mass / totalVolume;
|
||||
if (objectDensity < waterDensity * 0.1f)
|
||||
{
|
||||
Debug.LogWarning($"{gameObject.name}: 物体密度过低 ({objectDensity:F2} kg/m³),可能导致异常行为");
|
||||
}
|
||||
else if (objectDensity > waterDensity * 3f)
|
||||
{
|
||||
Debug.LogWarning($"{gameObject.name}: 物体密度过高 ({objectDensity:F2} kg/m³),可能快速沉没");
|
||||
}
|
||||
}
|
||||
|
||||
// 公共接口方法
|
||||
public float GetImmersedRatio() => immersedRatio;
|
||||
public float GetImmersedVolume() => immersedVolume;
|
||||
public Vector3 GetCenterOfBuoyancy() => centerOfBuoyancy;
|
||||
public bool IsSubmerged() => immersedRatio >= 0.99f;
|
||||
public bool IsFloating() => immersedRatio > 0f && immersedRatio < 0.99f;
|
||||
|
||||
public void SetWaterLevel(float level)
|
||||
{
|
||||
waterSurfaceLevel = level;
|
||||
autoDetectWaterLevel = false;
|
||||
}
|
||||
|
||||
public void SetBuoyancyMultiplier(float multiplier)
|
||||
{
|
||||
buoyancyMultiplier = Mathf.Clamp(multiplier, 0.1f, 5f);
|
||||
}
|
||||
|
||||
void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!drawGizmos || !Application.isPlaying) return;
|
||||
|
||||
// 绘制浮心
|
||||
Gizmos.color = Color.blue;
|
||||
Gizmos.DrawSphere(centerOfBuoyancy, 0.05f);
|
||||
|
||||
// 绘制重心
|
||||
if (rb != null)
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawSphere(rb.worldCenterOfMass, 0.05f);
|
||||
}
|
||||
|
||||
// 绘制稳心
|
||||
if (enableMetacentricStability)
|
||||
{
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawSphere(metacenter, 0.03f);
|
||||
}
|
||||
|
||||
// 绘制采样点
|
||||
if (samplePoints != null && objCollider != null)
|
||||
{
|
||||
Bounds bounds = objCollider.bounds;
|
||||
Vector3 boundsCenter = bounds.center;
|
||||
Vector3 boundsExtents = bounds.extents;
|
||||
|
||||
foreach (Vector3 normalizedPoint in samplePoints)
|
||||
{
|
||||
Vector3 worldPoint = boundsCenter + Vector3.Scale(normalizedPoint, boundsExtents * 2f);
|
||||
|
||||
if (worldPoint.y < waterSurfaceLevel)
|
||||
{
|
||||
Gizmos.color = immersedColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
Gizmos.color = surfaceColor;
|
||||
}
|
||||
|
||||
Gizmos.DrawSphere(worldPoint, 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制水面线
|
||||
if (Camera.current != null)
|
||||
{
|
||||
Vector3 cameraPos = Camera.current.transform.position;
|
||||
Vector3 waterCenter = new Vector3(cameraPos.x, waterSurfaceLevel, cameraPos.z);
|
||||
Vector3 waterSize = new Vector3(10f, 0f, 10f);
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireCube(waterCenter, waterSize);
|
||||
}
|
||||
}
|
||||
|
||||
void OnValidate()
|
||||
{
|
||||
// 参数验证
|
||||
sampleResolution = Mathf.Clamp(sampleResolution, 2, 10);
|
||||
buoyancyMultiplier = Mathf.Clamp(buoyancyMultiplier, 0.1f, 5f);
|
||||
stabilityFactor = Mathf.Clamp(stabilityFactor, 0f, 1f);
|
||||
rightingTorqueStrength = Mathf.Clamp(rightingTorqueStrength, 0f, 10f);
|
||||
}
|
||||
}
|
||||
3
Assets/Scripts/Test/Buoyancy2.cs.meta
Normal file
3
Assets/Scripts/Test/Buoyancy2.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8fb7a174acde428b84360b349d8ca2e3
|
||||
timeCreated: 1772376865
|
||||
304
Assets/Scripts/Test/BuoyancyBody.cs
Normal file
304
Assets/Scripts/Test/BuoyancyBody.cs
Normal file
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public interface IWaterProvider
|
||||
{
|
||||
/// <summary>返回该世界坐标下的水面高度</summary>
|
||||
float GetWaterHeight(Vector3 worldPos);
|
||||
|
||||
/// <summary>可选:水面法线(没有就返回 Vector3.up)</summary>
|
||||
Vector3 GetWaterNormal(Vector3 worldPos);
|
||||
|
||||
/// <summary>可选:水流速度(没有就返回 Vector3.zero)</summary>
|
||||
Vector3 GetWaterVelocity(Vector3 worldPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 多点采样浮力:考虑形状(采样点分布)、重心(Rigidbody.centerOfMass)、扭矩(在点上施力)。
|
||||
/// 适合小物体(0.01m级),避免“任何形状都一样直直往下/往上顶飞”
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(Rigidbody))]
|
||||
public class BuoyancyBody : MonoBehaviour
|
||||
{
|
||||
[Header("Water")]
|
||||
[Tooltip("如果不填则使用简单水面:y = WaterLevel")]
|
||||
public MonoBehaviour waterProviderBehaviour;
|
||||
private IWaterProvider waterProvider;
|
||||
|
||||
[Tooltip("简单水面模式:水面高度(y)")]
|
||||
public float waterLevel = 0f;
|
||||
|
||||
[Header("Buoyancy (Physical)")]
|
||||
[Tooltip("水密度 kg/m^3。淡水约1000,海水约1025")]
|
||||
public float waterDensity = 1000f;
|
||||
|
||||
[Tooltip("物体密度 kg/m^3。决定浮沉:< waterDensity 更容易浮,> waterDensity 更容易沉")]
|
||||
public float objectDensity = 300f;
|
||||
|
||||
[Tooltip("如果 > 0 则强制使用该体积(m^3),否则按 mass/objectDensity 推体积")]
|
||||
public float overrideVolume = 0f;
|
||||
|
||||
[Tooltip("浮力强度缩放(调手感用),1为物理值")]
|
||||
public float buoyancyScale = 1f;
|
||||
|
||||
[Header("Stabilization / Damping")]
|
||||
[Tooltip("水中线性阻尼(越大越不弹、越“粘水”)")]
|
||||
public float linearDamping = 2.5f;
|
||||
|
||||
[Tooltip("水中角阻尼(越大越稳定立漂)")]
|
||||
public float angularDamping = 2.0f;
|
||||
|
||||
[Tooltip("入水/出水过渡平滑厚度(m)。越大越不弹,但边界更“软”】【0.01~0.05常用】")]
|
||||
public float surfaceSmoothing = 0.02f;
|
||||
|
||||
[Tooltip("限制单个采样点最大浮力加速度(m/s^2),防止轻小物体从高处落下冲天")]
|
||||
public float maxBuoyantAccelPerPoint = 50f;
|
||||
|
||||
[Header("Center of Mass")]
|
||||
[Tooltip("本地空间重心偏移(m)。可用来模拟胶囊体配重,让浮漂能站起来")]
|
||||
public Vector3 centerOfMassOffset = Vector3.zero;
|
||||
|
||||
[Header("Sampling (Shape matters!)")]
|
||||
[Tooltip("每个Collider生成的采样点数量(越大越精确,越耗性能)。浮漂建议 12~40")]
|
||||
[Range(4, 200)] public int pointsPerCollider = 24;
|
||||
|
||||
[Tooltip("采样点是否只保留在Collider内部(对非凸MeshCollider不可靠;浮漂建议用Capsule/Box/Sphere)")]
|
||||
public bool keepPointsInsideCollider = true;
|
||||
|
||||
[Tooltip("运行时显示采样点(编辑器Gizmos)")]
|
||||
public bool drawGizmos = true;
|
||||
|
||||
[Tooltip("只对这些Collider算浮力(为空则自动抓取子物体所有Collider)")]
|
||||
public Collider[] colliders;
|
||||
|
||||
private Rigidbody rb;
|
||||
private readonly List<SamplePoint> samplePoints = new();
|
||||
|
||||
private struct SamplePoint
|
||||
{
|
||||
public Collider col;
|
||||
public Vector3 localPos; // 相对 collider.transform 的本地
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
rb = GetComponent<Rigidbody>();
|
||||
ApplyCOM();
|
||||
|
||||
waterProvider = waterProviderBehaviour as IWaterProvider;
|
||||
BuildSamplePoints();
|
||||
}
|
||||
|
||||
void OnValidate()
|
||||
{
|
||||
if (!rb) rb = GetComponent<Rigidbody>();
|
||||
ApplyCOM();
|
||||
|
||||
// 在编辑器里改参数时重建采样点
|
||||
if (Application.isPlaying == false)
|
||||
{
|
||||
// 避免在Prefab编辑等情况下报错
|
||||
}
|
||||
}
|
||||
|
||||
void Reset()
|
||||
{
|
||||
rb = GetComponent<Rigidbody>();
|
||||
rb.useGravity = true;
|
||||
rb.interpolation = RigidbodyInterpolation.Interpolate;
|
||||
rb.collisionDetectionMode = CollisionDetectionMode.Continuous;
|
||||
|
||||
if (colliders == null || colliders.Length == 0)
|
||||
colliders = GetComponentsInChildren<Collider>();
|
||||
}
|
||||
|
||||
private void ApplyCOM()
|
||||
{
|
||||
if (!rb) return;
|
||||
rb.centerOfMass = centerOfMassOffset;
|
||||
}
|
||||
|
||||
/// <summary>手动调用:当你运行时增减Collider或缩放后,建议调用一次</summary>
|
||||
public void Rebuild()
|
||||
{
|
||||
waterProvider = waterProviderBehaviour as IWaterProvider;
|
||||
ApplyCOM();
|
||||
BuildSamplePoints();
|
||||
}
|
||||
|
||||
private void BuildSamplePoints()
|
||||
{
|
||||
samplePoints.Clear();
|
||||
|
||||
Collider[] cols = colliders;
|
||||
if (cols == null || cols.Length == 0)
|
||||
cols = GetComponentsInChildren<Collider>();
|
||||
|
||||
foreach (var col in cols)
|
||||
{
|
||||
if (!col || !col.enabled) continue;
|
||||
|
||||
// 用 bounds 做快速采样,再可选筛内部点
|
||||
var b = col.bounds;
|
||||
|
||||
int n = Mathf.Max(4, pointsPerCollider);
|
||||
// 使用“分层网格+抖动”的方式,保证不同形状/尺寸采样分布不同
|
||||
int dim = Mathf.CeilToInt(Mathf.Pow(n, 1f / 3f));
|
||||
dim = Mathf.Max(2, dim);
|
||||
|
||||
Vector3 size = b.size;
|
||||
Vector3 step = new Vector3(
|
||||
size.x / (dim - 1),
|
||||
size.y / (dim - 1),
|
||||
size.z / (dim - 1)
|
||||
);
|
||||
|
||||
// 为了让小物体也稳定:如果某轴极小,step可能很小,没关系
|
||||
int added = 0;
|
||||
for (int ix = 0; ix < dim && added < n; ix++)
|
||||
for (int iy = 0; iy < dim && added < n; iy++)
|
||||
for (int iz = 0; iz < dim && added < n; iz++)
|
||||
{
|
||||
// 0..1
|
||||
float fx = (dim == 1) ? 0.5f : (float)ix / (dim - 1);
|
||||
float fy = (dim == 1) ? 0.5f : (float)iy / (dim - 1);
|
||||
float fz = (dim == 1) ? 0.5f : (float)iz / (dim - 1);
|
||||
|
||||
// 抖动(避免规则网格导致“锁姿态”)
|
||||
Vector3 jitter = new Vector3(
|
||||
(UnityEngine.Random.value - 0.5f) * step.x * 0.25f,
|
||||
(UnityEngine.Random.value - 0.5f) * step.y * 0.25f,
|
||||
(UnityEngine.Random.value - 0.5f) * step.z * 0.25f
|
||||
);
|
||||
|
||||
Vector3 world = new Vector3(
|
||||
b.min.x + size.x * fx,
|
||||
b.min.y + size.y * fy,
|
||||
b.min.z + size.z * fz
|
||||
) + jitter;
|
||||
|
||||
if (keepPointsInsideCollider)
|
||||
{
|
||||
// 对凸Collider通常可靠:如果点在内部,ClosestPoint会返回点本身
|
||||
Vector3 cp = col.ClosestPoint(world);
|
||||
if ((cp - world).sqrMagnitude > 1e-8f)
|
||||
continue;
|
||||
}
|
||||
|
||||
// 转到 collider.transform 本地存储
|
||||
Vector3 local = col.transform.InverseTransformPoint(world);
|
||||
samplePoints.Add(new SamplePoint { col = col, localPos = local });
|
||||
added++;
|
||||
}
|
||||
|
||||
// 如果内部点太少,至少补一点:用 collider.transform.position 周围
|
||||
if (added < 4)
|
||||
{
|
||||
for (int i = added; i < 4; i++)
|
||||
{
|
||||
Vector3 world = col.bounds.center + UnityEngine.Random.insideUnitSphere * (col.bounds.extents.magnitude * 0.25f);
|
||||
Vector3 local = col.transform.InverseTransformPoint(world);
|
||||
samplePoints.Add(new SamplePoint { col = col, localPos = local });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FixedUpdate()
|
||||
{
|
||||
if (samplePoints.Count == 0) BuildSamplePoints();
|
||||
|
||||
// 体积估计:V = m / rho_object
|
||||
float volume = overrideVolume > 0f ? overrideVolume : (rb.mass / Mathf.Max(1e-6f, objectDensity));
|
||||
volume = Mathf.Max(1e-9f, volume);
|
||||
|
||||
int count = samplePoints.Count;
|
||||
float volPerPoint = volume / count;
|
||||
|
||||
Vector3 gravity = Physics.gravity; // 通常 (0,-9.81,0)
|
||||
float gMag = gravity.magnitude;
|
||||
|
||||
// 如果重力为0就不算了
|
||||
if (gMag < 1e-6f) return;
|
||||
|
||||
// 逐点施加:浮力 + 阻尼
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var sp = samplePoints[i];
|
||||
if (!sp.col || !sp.col.enabled) continue;
|
||||
|
||||
Vector3 worldPos = sp.col.transform.TransformPoint(sp.localPos);
|
||||
|
||||
float waterH = waterProvider?.GetWaterHeight(worldPos) ?? waterLevel;
|
||||
float depth = waterH - worldPos.y; // >0 表示点在水下
|
||||
|
||||
if (depth <= 0f) continue;
|
||||
|
||||
// 入水过渡:在水面附近用平滑厚度做0..1
|
||||
float submergence01 = (surfaceSmoothing <= 1e-6f)
|
||||
? 1f
|
||||
: Mathf.Clamp01(depth / surfaceSmoothing);
|
||||
|
||||
// 该点产生的“排水质量”:m_displaced = rho_water * V_point * submergence
|
||||
float displacedMass = waterDensity * volPerPoint * submergence01;
|
||||
|
||||
// 浮力 = -g * m_displaced
|
||||
Vector3 buoyancy = -gravity * displacedMass * buoyancyScale;
|
||||
|
||||
// 限制单点最大向上加速度,防止小物体落水被“顶飞”
|
||||
// a = F/m -> Fmax = amax * m
|
||||
float Fmax = maxBuoyantAccelPerPoint * rb.mass;
|
||||
if (buoyancy.sqrMagnitude > Fmax * Fmax)
|
||||
buoyancy = buoyancy.normalized * Fmax;
|
||||
|
||||
// 水中阻尼(相对水流速度)
|
||||
Vector3 waterVel = waterProvider?.GetWaterVelocity(worldPos) ?? Vector3.zero;
|
||||
Vector3 pointVel = rb.GetPointVelocity(worldPos);
|
||||
Vector3 relVel = pointVel - waterVel;
|
||||
|
||||
// 阻尼随入水程度增强:越深越“粘”
|
||||
Vector3 dampingForce = -relVel * (linearDamping * displacedMass);
|
||||
|
||||
// 把力施加在采样点上 -> 自然产生扭矩(形状/重心不同效果不同)
|
||||
rb.AddForceAtPosition(buoyancy + dampingForce, worldPos, ForceMode.Force);
|
||||
|
||||
// 额外角阻尼(整体),只在“确实有一部分在水里”时施加更合理,
|
||||
// 这里用 submergence01 做比例:越入水越强
|
||||
rb.AddTorque(-rb.angularVelocity * (angularDamping * displacedMass), ForceMode.Force);
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!drawGizmos) return;
|
||||
|
||||
Gizmos.matrix = Matrix4x4.identity;
|
||||
|
||||
// 水面
|
||||
Gizmos.color = new Color(0f, 0.6f, 1f, 0.25f);
|
||||
Vector3 p = transform.position;
|
||||
float y = (waterProvider != null) ? waterProvider.GetWaterHeight(p) : waterLevel;
|
||||
Gizmos.DrawCube(new Vector3(p.x, y, p.z), new Vector3(0.5f, 0.001f, 0.5f));
|
||||
|
||||
// 采样点
|
||||
if (samplePoints == null) return;
|
||||
Gizmos.color = Color.yellow;
|
||||
foreach (var sp in samplePoints)
|
||||
{
|
||||
if (!sp.col) continue;
|
||||
Vector3 w = sp.col.transform.TransformPoint(sp.localPos);
|
||||
Gizmos.DrawSphere(w, 0.005f);
|
||||
}
|
||||
|
||||
// COM
|
||||
if (TryGetComponent<Rigidbody>(out var r))
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawSphere(transform.TransformPoint(r.centerOfMass), 0.01f);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
3
Assets/Scripts/Test/BuoyancyBody.cs.meta
Normal file
3
Assets/Scripts/Test/BuoyancyBody.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1687f1bac8ee43f8a5705c076bda18ef
|
||||
timeCreated: 1772380695
|
||||
21
Assets/Scripts/Test/BuoyancyWaterProvider.cs
Normal file
21
Assets/Scripts/Test/BuoyancyWaterProvider.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class BuoyancyWaterProvider : MonoBehaviour, IWaterProvider
|
||||
{
|
||||
public float waterLevel = 0f;
|
||||
|
||||
public float GetWaterHeight(Vector3 worldPos)
|
||||
{
|
||||
return waterLevel;
|
||||
}
|
||||
|
||||
public Vector3 GetWaterNormal(Vector3 worldPos)
|
||||
{
|
||||
return Vector3.up;
|
||||
}
|
||||
|
||||
public Vector3 GetWaterVelocity(Vector3 worldPos)
|
||||
{
|
||||
return Vector3.zero; // 关键!不要乱给
|
||||
}
|
||||
}
|
||||
3
Assets/Scripts/Test/BuoyancyWaterProvider.cs.meta
Normal file
3
Assets/Scripts/Test/BuoyancyWaterProvider.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 06d107cece7c4cbb9825557923be567f
|
||||
timeCreated: 1772380723
|
||||
Reference in New Issue
Block a user