using UnityEngine; using UnityEngine.Serialization; /// /// 统一物理线缆(移植自 Assets/Prototype/2D线缆物理交互 原型): /// 固定粒子数 + 可变总长度的 Verlet 绳,一条线覆盖旧 Cable(拖拽)/ PhysicCable(闲置)两套状态。 /// 拖拽时按需送线、松手自动回收到最短长度、插入插孔时末端钉住。 /// [RequireComponent(typeof(LineRenderer))] public class PhysicCable : MonoBehaviour { public enum CableState { Hidden, // 收起,不渲染不模拟 Free, // 闲置下垂,自动回收到最短长度 Dragging, // 末端钉在拖拽目标上,按需送线 Plugged // 末端钉在插孔上 } [FormerlySerializedAs("StartTranform")] public Transform startTransform; // 出线口 [Header("绳体")] [SerializeField] private int pointCount = 40; [SerializeField] private float minLength = 2f; // 闲置时的自然下垂长度 [SerializeField, Range(0.05f, 1f)] private float idleLengthScale = 0.08f; [SerializeField] private float maxStretch = 8f; // 最大拉出长度(相对 minLength 的倍数) [SerializeField] private float gravity = 100f; // 世界单位/s²,向下 [SerializeField, Range(0.8f, 1f)] private float damping = 0.965f; [SerializeField] private int constraintIterations = 24; [SerializeField, Range(0f, 1f)] private float bendSmooth = 0.3f; // 弯曲刚度(中点平滑) [SerializeField] private float endGravityBoost = 1.8f; // 末端几个点的重力加成,让插头端垂坠 [Header("收放")] [SerializeField] private float feedRate = 0.45f; // 送线速度 [SerializeField] private float holdRate = 0.06f; // 拖拽/插接中回收速度 [SerializeField] private float retractRate = 0.09f; // 松手后回收速度 [SerializeField] private float lengthMargin = 0.45f; // 需求长度余量 [Header("出线口保护段")] [SerializeField] private float stubMinOffset = 0.12f; // 出线口处沿出线方向至少伸出的距离 [Header("插头朝向")] [SerializeField] private float plugRefArcLength = 0.6f; // 取角度参考点时向回走的弧长 [SerializeField] private float plugTurnRate = 0.18f; [SerializeField] private float plugMaxTurnStep = 0.11f; // 每步最大转角(弧度) [Header("可选")] [SerializeField] private Transform reelCenter; // 出线方向参考中心,空则用 startTransform.parent [SerializeField] private Transform floorLimit; // 地面高度参考,空则不启用地面碰撞 [Header("外观(单 Mesh 分带,对应原型 pixel 预设)")] [SerializeField] private Material cableMaterial; // 空则运行时回退 Sprite-Unlit-Default / Sprites-Default // Tools 层整层高于 FixTop(屏幕 UI Canvas 在 FixTop/100),插头历来在 Tools 上未被遮挡; // 线缆与插头(Tools/48)成对相邻,剪线等演出覆盖物(Tools/50+)仍可整体盖住两者 [SerializeField] private string sortingLayerName = "Tools"; [SerializeField] private int sortingOrder = 47; // 三档色阶(描边/主体/顶光),压暗去饱和贴合场景像素环境;原型的第四档高光去掉 [SerializeField] private Color shadowColor = new(0.05f, 0.05f, 0.07f, 0.2f); [SerializeField] private float shadowWidth = 0.295f; [SerializeField] private float shadowDrop = 0.12f; // 原型:向下平移 7px [SerializeField] private Color darkColor = new(0.09f, 0.11f, 0.18f, 1f); // 深色描边 [SerializeField] private float darkWidth = 0.33f; // 原型:w+4 [SerializeField] private Color bodyColor = new(0.24f, 0.30f, 0.44f, 1f); // 主体(低饱和板岩蓝) [SerializeField] private float bodyWidth = 0.26f; // 原型:w=15px ≈ 0.26wu [SerializeField] private Color midColor = new(0.38f, 0.45f, 0.60f, 1f); // 顶光 [SerializeField] private float midWidth = 0.143f; // 原型:0.55w [SerializeField] private float midLift = 0.036f; // 原型:上移 0.14w [Header("端部收窄(默认关闭:整根线宽与插头/孔匹配)")] [SerializeField, Range(0f, 0.3f)] private float tipTaperFraction = 0f; // 末端收窄段占全长比例,0 关闭 [SerializeField, Range(0.3f, 1f)] private float tipTaperScale = 0.72f; // 末端最细处宽度比例 [Header("自遮挡(尾段盖头段)")] [SerializeField] private float overlapMargin = 0.1f; // 原型:m.w + 6px 的余量部分 public CableState State { get; private set; } = CableState.Hidden; public Vector3 EndPosition => _pts != null ? _pts[_n - 1].pos : (startTransform != null ? startTransform.position : transform.position); public float CurrentLength => _length; private struct Particle { public Vector3 pos; public Vector3 prev; } private Particle[] _pts; private int _n; private float _length; private Vector3 _pointer; // 拖拽指针(世界坐标,驱动送线长度) private Vector3 _dragTarget; // 末端钉住目标(重 lerp + 长度钳制,消除拽满时的抖动) private Transform _dock; // 当前插入的插孔 private Vector3 _plugSnapEnd; // 插接吸附目标:渐进收敛到孔心后末端硬钉,保证完全对齐堵住孔 private float _plugAngle = -Mathf.PI / 2f; // 插头朝向(弧度,初始朝下) private LineRenderer _line; // 遗留组件,仅保持禁用(旧场景/CableSystem 仍会引用) private CableMeshRenderer _cableMesh; private Vector3[] _renderA; private Vector3[] _renderB; private const int SmoothIterations = 2; // Chaikin 切角细分次数 private bool _initialized; private void Awake() { _line = GetComponent(); if (_line != null) _line.enabled = false; EnsureMeshRenderer(); } // ------------------------------ 状态 API ------------------------------ public void Hide() { State = CableState.Hidden; _dock = null; SetCableRenderersEnabled(false); } /// 闲置下垂状态;resetShape 时把绳重置为从出线口自然下垂。 public void ShowFree(bool resetShape = false) { EnsureInit(); if (resetShape) ResetHangingShape(); _dock = null; State = CableState.Free; SetCableRenderersEnabled(true); } public void BeginDrag(Vector3 pointerWorld) { EnsureInit(); if (State == CableState.Hidden) ResetHangingShape(); _dock = null; _pointer = FlattenZ(pointerWorld); _dragTarget = _pts[_n - 1].pos; State = CableState.Dragging; SetCableRenderersEnabled(true); } public void SetPointer(Vector3 pointerWorld) { _pointer = FlattenZ(pointerWorld); } public void EndDrag() { if (State == CableState.Dragging) { State = CableState.Free; } } /// 末端钉到插孔;snapStraight 用于读档,把绳直接摆成出线口到插孔的直线。 public void PlugInto(Transform dock, bool snapStraight = false) { EnsureInit(); _dock = dock; State = CableState.Plugged; SetCableRenderersEnabled(true); if (snapStraight && dock != null) { ResetStraightTo(FlattenZ(dock.position)); } // 吸附目标从当前末端出发,向孔心渐进收敛(保留吸入动画,最终严格钉在孔心) _plugSnapEnd = _pts[_n - 1].pos; } // ------------------------------ 插头姿态 ------------------------------ /// 把插头摆到绳末端并按绳向旋转(角度经过平滑与限速)。 public void ApplyPlugPose(Transform plug, Transform plugRoot) { if (_pts == null || plug == null) return; plug.rotation = Quaternion.Euler(0, 0, _plugAngle * Mathf.Rad2Deg - 90f); Vector3 end = _pts[_n - 1].pos; if (plugRoot != null) { Vector3 delta = end - plugRoot.position; delta.z = 0; plug.position += delta; } else { plug.position = new Vector3(end.x, end.y, plug.position.z); } } // ------------------------------ 模拟 ------------------------------ private void FixedUpdate() { if (State == CableState.Hidden || _pts == null || startTransform == null) return; Simulate(Time.fixedDeltaTime); } private void LateUpdate() { // 渲染放 LateUpdate:与插头跟随(CablePanel.LateUpdate)读同一份点位,消除拖拽时线头分离 if (State == CableState.Hidden || _pts == null) return; Render(); } private void EnsureInit() { if (_initialized) return; _initialized = true; EnsureMeshRenderer(); _n = Mathf.Max(8, pointCount); _pts = new Particle[_n]; int renderCount = _n << SmoothIterations; _renderA = new Vector3[renderCount]; _renderB = new Vector3[renderCount]; ResetHangingShape(); } private void ResetHangingShape() { Vector3 anchor = startTransform.position; float idleLength = GetIdleLength(); for (int i = 0; i < _n; i++) { float t = i / (float)(_n - 1); Vector3 p = anchor + Vector3.down * (t * idleLength) + Vector3.right * (t * 0.02f); _pts[i].pos = p; _pts[i].prev = p; } _length = idleLength; _plugAngle = -Mathf.PI / 2f; } private void ResetStraightTo(Vector3 end) { Vector3 anchor = startTransform.position; float idleLength = GetIdleLength(); for (int i = 0; i < _n; i++) { float t = i / (float)(_n - 1); Vector3 p = Vector3.Lerp(anchor, end, t); _pts[i].pos = p; _pts[i].prev = p; } _length = Mathf.Max(idleLength, Vector3.Distance(anchor, end) * 1.04f + lengthMargin); } private void Simulate(float dt) { Vector3 anchor = startTransform.position; Vector3 outletDir = GetOutletDirection(anchor); float idleLength = GetIdleLength(); bool dragging = State == CableState.Dragging; bool plugged = State == CableState.Plugged && _dock != null; bool pinnedEnd = dragging || plugged; Vector3 dock = plugged ? FlattenZ(_dock.position) : Vector3.zero; float lMax = minLength * maxStretch; if (plugged) { // 保证够得着插孔 lMax = Mathf.Max(lMax, Vector3.Distance(dock, anchor) * 1.12f); } // --- 拖拽目标:重 lerp + 按当前绳长钳制(拽满时目标跟着绳长走,消除抖动) if (dragging) { _dragTarget += (_pointer - _dragTarget) * 0.3f; Vector3 offset = _dragTarget - anchor; float dist = offset.magnitude; float maxRadius = Mathf.Min(lMax, _length) * 0.99f; if (dist > maxRadius) { _dragTarget = anchor + offset / dist * maxRadius; } } // --- 送线 / 回收(长度由原始指针驱动,而非被钳制后的目标) Vector3 endTarget = dragging ? _pointer : (plugged ? dock : _pts[_n - 1].pos); float needed = Vector3.Distance(endTarget, anchor) * 1.04f + lengthMargin; float targetLength = pinnedEnd ? Mathf.Clamp(needed, idleLength, lMax) : idleLength; float rate = targetLength > _length ? feedRate : (pinnedEnd ? holdRate : retractRate); _length += (targetLength - _length) * rate; float seg = _length / (_n - 1); float g = gravity * dt * dt; // --- Verlet 积分 for (int i = 1; i < _n; i++) { ref Particle p = ref _pts[i]; Vector3 v = (p.pos - p.prev) * damping; p.prev = p.pos; float boost = i > _n - 4 ? endGravityBoost : 1f; p.pos += v + Vector3.down * (g * boost); } _pts[0].pos = anchor; _pts[0].prev = anchor; if (dragging) { _pts[_n - 1].pos = _dragTarget; } else if (plugged) { // 吸附目标渐进收敛到孔心后,末端硬钉——软拉会被重力/张力持续拽偏,堵不严孔 _plugSnapEnd += (dock - _plugSnapEnd) * 0.5f; if ((_plugSnapEnd - dock).sqrMagnitude < 1e-4f) _plugSnapEnd = dock; _pts[_n - 1].pos = _plugSnapEnd; _pts[_n - 1].prev = _plugSnapEnd; } // --- 约束迭代 for (int k = 0; k < constraintIterations; k++) { for (int i = 0; i < _n - 1; i++) { ref Particle a = ref _pts[i]; ref Particle b = ref _pts[i + 1]; Vector3 d = b.pos - a.pos; float dist = d.magnitude; if (dist < 1e-6f) dist = 1e-6f; float diff = (dist - seg) / dist; bool pinA = i == 0; bool pinB = i == _n - 2 && pinnedEnd; float wa = pinA ? 0f : (pinB ? 1f : 0.5f); float wb = pinB ? 0f : (pinA ? 1f : 0.5f); a.pos += d * (diff * wa); b.pos -= d * (diff * wb); } // 出线口保护段:靠近出线口的两个点顺着出线方向、横向收紧, // 无论绳的其余部分被拽到哪,出线处都不出现折角或自穿插 ApplyOutletStub(anchor, outletDir); // 插接时把末段摆直指向孔心(移植原型的 plugged e2 约束,方向按来线方向泛化): // 倒数第二点拉向"孔心沿来线方向后退一节"的位置,末段直插进孔,不斜搭在孔上 if (plugged) { Vector3 approach = dock - _pts[_n - 3].pos; approach.z = 0; float approachDist = approach.magnitude; if (approachDist > 1e-4f) { Vector3 straightTarget = dock - approach / approachDist * seg; _pts[_n - 2].pos += (straightTarget - _pts[_n - 2].pos) * 0.4f; } } // 弯曲刚度:中点平滑(每 3 次迭代做一次) if (bendSmooth > 0f && k % 3 == 0) { for (int i = 1; i < _n - 1; i++) { if (i == _n - 2 && pinnedEnd) continue; Vector3 mid = (_pts[i - 1].pos + _pts[i + 1].pos) * 0.5f; _pts[i].pos += (mid - _pts[i].pos) * (bendSmooth * 0.5f); } } } // --- 地面(被钉住的拖拽末端豁免,避免钉点与地面互相拉扯) if (floorLimit != null) { float floorY = floorLimit.position.y; for (int i = 1; i < _n; i++) { if (i == _n - 1 && dragging) continue; if (_pts[i].pos.y < floorY) { _pts[i].pos.y = floorY; _pts[i].prev.x += (_pts[i].pos.x - _pts[i].prev.x) * 0.5f; // 摩擦 } } } UpdatePlugAngle(anchor, dragging, plugged); } private void ApplyOutletStub(Vector3 anchor, Vector3 dir) { // P1:横向收紧 0.6,且沿出线方向至少伸出 stubMinOffset Vector3 rel = _pts[1].pos - anchor; float along = Vector3.Dot(rel, dir); Vector3 lateral = (rel - dir * along) * 0.4f; if (along < stubMinOffset) along = stubMinOffset; _pts[1].pos = anchor + dir * along + lateral; // P2:横向收紧 0.2 rel = _pts[2].pos - anchor; along = Vector3.Dot(rel, dir); lateral = (rel - dir * along) * 0.8f; _pts[2].pos = anchor + dir * along + lateral; } private void UpdatePlugAngle(Vector3 anchor, bool dragging, bool plugged) { Vector3 end = _pts[_n - 1].pos; float targetAngle; if (plugged) { // 插着时插头精灵隐藏,保持角度即可 targetAngle = _plugAngle; } else { // 拽紧时绳近似出线口到插头的直线,直接沿这条线取向; // 局部段方向在高张力下噪声大 Vector3 toEnd = end - anchor; float taut = toEnd.magnitude / Mathf.Max(0.001f, _length); if (dragging && taut > 0.92f) { targetAngle = Mathf.Atan2(toEnd.y, toEnd.x); } else { // 角度取自向回走固定弧长的参考点;绳被压缩(末端点堆积)时方向不可靠, // 保持上一帧角度而不是乱翻 int k = _n - 1; float arc = 0f; while (k > 0 && arc < plugRefArcLength) { arc += Vector3.Distance(_pts[k].pos, _pts[k - 1].pos); k--; } Vector3 refP = _pts[k].pos; targetAngle = Vector3.Distance(end, refP) > plugRefArcLength * 0.3f ? Mathf.Atan2(end.y - refP.y, end.x - refP.x) : _plugAngle; } } float da = Mathf.DeltaAngle(_plugAngle * Mathf.Rad2Deg, targetAngle * Mathf.Rad2Deg) * Mathf.Deg2Rad; float step = Mathf.Clamp(da * plugTurnRate, -plugMaxTurnStep, plugMaxTurnStep); _plugAngle += step; } private Vector3 GetOutletDirection(Vector3 anchor) { Transform center = reelCenter != null ? reelCenter : startTransform.parent; if (center != null) { Vector3 radial = anchor - center.position; radial.z = 0; if (radial.sqrMagnitude > 1e-6f) { return radial.normalized; } } return Vector3.down; } private Vector3 FlattenZ(Vector3 worldPos) { worldPos.z = startTransform != null ? startTransform.position.z : transform.position.z; return worldPos; } // ------------------------------ 渲染 ------------------------------ private void Render() { for (int i = 0; i < _n; i++) { _renderA[i] = _pts[i].pos; } int count = _n; Vector3[] src = _renderA; Vector3[] dst = _renderB; for (int it = 0; it < SmoothIterations; it++) { count = Chaikin(src, count, dst); (src, dst) = (dst, src); } // 插接时线缆末端由平头切面改为圆头端帽(线自身的圆头,同旧 LineRenderer),读作"插进孔里" _cableMesh.UpdateMesh(src, count, FindTailStart(count), State == CableState.Plugged); } /// /// 自遮挡检测(移植自原型):找到下垂最低点 j,若 j 之后的尾段与 j 之前的头段 /// 空间重叠,返回尾段在平滑点数组里的起始下标(从 j 前两个物理点起),否则 -1。 /// private int FindTailStart(int smoothedCount) { int j = 0; for (int i = 1; i < _n; i++) { if (_pts[i].pos.y < _pts[j].pos.y) j = i; // 原型 y 向下,这里取世界最低点 } if (j >= _n - 2) return -1; float rr = bodyWidth + overlapMargin; float rrSqr = rr * rr; bool overlap = false; for (int s = j + 2; s < _n && !overlap; s += 2) { for (int t = 0; t < j - 2 && !overlap; t += 2) { if (s - t >= 8 && (_pts[s].pos - _pts[t].pos).sqrMagnitude < rrSqr) overlap = true; } } if (!overlap) return -1; int j0 = Mathf.Max(0, j - 2); return Mathf.Clamp(j0 << SmoothIterations, 0, smoothedCount - 2); } private float GetIdleLength() { return Mathf.Max(0.1f, minLength * idleLengthScale); } private void EnsureMeshRenderer() { if (_cableMesh != null) return; // MeshRenderer 不能与本物体上遗留的 LineRenderer 共存,放到子物体; // 顶点直接用世界坐标,子物体世界变换必须归零 Transform child = transform.Find("Cable Mesh"); if (child == null) { child = new GameObject("Cable Mesh").transform; child.SetParent(transform, false); } child.position = Vector3.zero; child.rotation = Quaternion.identity; // 继承 GameObject layer:后处理/渲染特性按层过滤时,线缆与周围面板行为一致 child.gameObject.layer = gameObject.layer; _cableMesh = child.GetComponent(); if (_cableMesh == null) _cableMesh = child.gameObject.AddComponent(); _cableMesh.Configure(ResolveMaterial(), sortingLayerName, sortingOrder); _cableMesh.SetBands(BuildBands()); _cableMesh.SetTipTaper(tipTaperFraction, tipTaperScale); _cableMesh.SetVisible(State != CableState.Hidden); } private Material ResolveMaterial() { if (cableMaterial != null) return cableMaterial; // 直接共用出线口所在线盘 sprite 的材质:与周围环境的受光行为保证完全一致 if (startTransform != null) { var reference = startTransform.GetComponentInParent(); if (reference != null && reference.sharedMaterial != null) { return reference.sharedMaterial; } } Shader shader = Shader.Find("Universal Render Pipeline/2D/Sprite-Lit-Default"); if (shader == null) shader = Shader.Find("Sprites/Default"); return new Material(shader); } private CableMeshRenderer.Band[] BuildBands() { // 提交顺序即画序(后画的盖前画的);三档色阶,比原型少一档高光,贴合场景像素密度 return new[] { new CableMeshRenderer.Band(shadowColor, shadowWidth, -shadowDrop, skipInTail: true), new CableMeshRenderer.Band(darkColor, darkWidth, 0f), new CableMeshRenderer.Band(bodyColor, bodyWidth, 0f), new CableMeshRenderer.Band(midColor, midWidth, midLift), }; } private void SetCableRenderersEnabled(bool enabled) { EnsureMeshRenderer(); _cableMesh.SetVisible(enabled); } /// /// 一次 Chaikin 切角细分:保留首尾点,每段取 1/4、3/4 两个切分点,输出点数为 2n。 /// private static int Chaikin(Vector3[] src, int count, Vector3[] dst) { int idx = 0; dst[idx++] = src[0]; for (int i = 0; i < count - 1; i++) { dst[idx++] = Vector3.Lerp(src[i], src[i + 1], 0.25f); dst[idx++] = Vector3.Lerp(src[i], src[i + 1], 0.75f); } dst[idx++] = src[count - 1]; return idx; } }