feat(cable): 优化插线手感与线缆表现

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-09 14:04:48 +08:00
co-authored by Cursor
parent c905eb4625
commit 8a7b2d0ed2
5 changed files with 2817 additions and 5720 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+173 -10
View File
@@ -44,6 +44,20 @@ public class PhysicCable : MonoBehaviour
[SerializeField] private float plugTurnRate = 0.18f;
[SerializeField] private float plugMaxTurnStep = 0.11f; // 每步最大转角(弧度)
[Header("插拔手感")]
[SerializeField] private bool enableSocketAssist = true;
[SerializeField] private float defaultAssistRadius = 1.45f; // 没有外部半径时的吸附范围
[SerializeField, Range(0f, 1f)] private float socketAssistStrength = 0.62f;
[SerializeField] private float plugSnapRate = 0.55f;
[SerializeField] private float insertImpactDuration = 0.22f;
[SerializeField] private float insertShoveDistance = 0.18f;
[SerializeField] private float insertShakeAmplitude = 0.055f;
[SerializeField] private float insertShakeFrequency = 34f;
[SerializeField] private float insertTailKick = 0.12f;
[SerializeField] private float unplugBreakDistance = 0.55f;
[SerializeField] private float unplugStickExponent = 2.4f;
[SerializeField] private float unplugReleaseKick = 0.16f;
[Header("可选")]
[SerializeField] private Transform reelCenter; // 出线方向参考中心,空则用 startTransform.parent
[SerializeField] private Transform floorLimit; // 地面高度参考,空则不启用地面碰撞
@@ -54,16 +68,16 @@ public class PhysicCable : MonoBehaviour
// 线缆与插头(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);
// 7/7 PixelCableLine step 色阶: Edge → Band Dark → Band Light
[SerializeField] private Color shadowColor = new(0f, 0.08f, 0.16f, 0.22f);
[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 Color darkColor = new(0f, 0.08f, 0.16f, 1f); // Edge Outline
[SerializeField] private float darkWidth = 0.33f; // 原型:w+4
[SerializeField] private Color bodyColor = new(0.24f, 0.30f, 0.44f, 1f); // 主体(低饱和板岩蓝)
[SerializeField] private Color bodyColor = new(0f, 0.19f, 0.34f, 1f); // Band Dark
[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 Color midColor = new(0f, 0.41f, 0.57f, 1f); // Band Light
[SerializeField] private float midWidth = 0.1f; // 原型:0.55w
[SerializeField] private float midLift = 0.036f; // 原型:上移 0.14w
[Header("端部收窄(默认关闭:整根线宽与插头/孔匹配)")]
@@ -96,6 +110,14 @@ public class PhysicCable : MonoBehaviour
private Transform _dock; // 当前插入的插孔
private Vector3 _plugSnapEnd; // 插接吸附目标:渐进收敛到孔心后末端硬钉,保证完全对齐堵住孔
private float _plugAngle = -Mathf.PI / 2f; // 插头朝向(弧度,初始朝下)
private Transform _dragAssistDock;
private float _dragAssistRadius;
private float _dragAssistBlend;
private bool _unplugResisting;
private Vector3 _unplugAnchor;
private float _insertImpactTimer;
private Vector3 _insertImpactDir;
private Vector3 _insertImpactNormal;
private LineRenderer _line; // 遗留组件,仅保持禁用(旧场景/CableSystem 仍会引用)
private CableMeshRenderer _cableMesh;
@@ -118,6 +140,9 @@ public class PhysicCable : MonoBehaviour
{
State = CableState.Hidden;
_dock = null;
ClearDragAssistTarget();
_unplugResisting = false;
_insertImpactTimer = 0f;
SetCableRenderersEnabled(false);
}
@@ -127,6 +152,8 @@ public class PhysicCable : MonoBehaviour
EnsureInit();
if (resetShape) ResetHangingShape();
_dock = null;
ClearDragAssistTarget();
_unplugResisting = false;
State = CableState.Free;
SetCableRenderersEnabled(true);
}
@@ -138,13 +165,15 @@ public class PhysicCable : MonoBehaviour
_dock = null;
_pointer = FlattenZ(pointerWorld);
_dragTarget = _pts[_n - 1].pos;
_unplugResisting = false;
_insertImpactTimer = 0f;
State = CableState.Dragging;
SetCableRenderersEnabled(true);
}
public void SetPointer(Vector3 pointerWorld)
{
_pointer = FlattenZ(pointerWorld);
_pointer = ApplyUnplugResistance(FlattenZ(pointerWorld));
}
public void EndDrag()
@@ -152,14 +181,49 @@ public class PhysicCable : MonoBehaviour
if (State == CableState.Dragging)
{
State = CableState.Free;
ClearDragAssistTarget();
_unplugResisting = false;
}
}
public void SetDragAssistTarget(Transform dock, float radius)
{
if (!enableSocketAssist || dock == null)
{
ClearDragAssistTarget();
return;
}
_dragAssistDock = dock;
_dragAssistRadius = Mathf.Max(0.01f, radius > 0f ? radius : defaultAssistRadius);
}
public void ClearDragAssistTarget()
{
_dragAssistDock = null;
_dragAssistBlend = 0f;
}
public void StartUnplugResistance(Transform socket)
{
if (socket == null || _pts == null) return;
_unplugAnchor = FlattenZ(socket.position);
_pointer = _unplugAnchor;
_dragTarget = _unplugAnchor;
_pts[_n - 1].pos = _unplugAnchor;
_pts[_n - 1].prev = _unplugAnchor;
_unplugResisting = unplugBreakDistance > 0.01f;
ClearDragAssistTarget();
}
/// <summary>末端钉到插孔;snapStraight 用于读档,把绳直接摆成出线口到插孔的直线。</summary>
public void PlugInto(Transform dock, bool snapStraight = false)
{
EnsureInit();
_dock = dock;
ClearDragAssistTarget();
_unplugResisting = false;
State = CableState.Plugged;
SetCableRenderersEnabled(true);
if (snapStraight && dock != null)
@@ -168,6 +232,14 @@ public class PhysicCable : MonoBehaviour
}
// 吸附目标从当前末端出发,向孔心渐进收敛(保留吸入动画,最终严格钉在孔心)
_plugSnapEnd = _pts[_n - 1].pos;
if (snapStraight)
{
_insertImpactTimer = 0f;
}
else
{
BeginInsertImpact(dock);
}
}
// ------------------------------ 插头姿态 ------------------------------
@@ -273,9 +345,10 @@ public class PhysicCable : MonoBehaviour
}
// --- 拖拽目标:重 lerp + 按当前绳长钳制(拽满时目标跟着绳长走,消除抖动)
Vector3 dragPointer = dragging ? GetAssistedPointer(_pointer) : _pointer;
if (dragging)
{
_dragTarget += (_pointer - _dragTarget) * 0.3f;
_dragTarget += (dragPointer - _dragTarget) * 0.3f;
Vector3 offset = _dragTarget - anchor;
float dist = offset.magnitude;
float maxRadius = Mathf.Min(lMax, _length) * 0.99f;
@@ -286,7 +359,7 @@ public class PhysicCable : MonoBehaviour
}
// --- 送线 / 回收(长度由原始指针驱动,而非被钳制后的目标)
Vector3 endTarget = dragging ? _pointer : (plugged ? dock : _pts[_n - 1].pos);
Vector3 endTarget = dragging ? dragPointer : (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);
@@ -315,7 +388,8 @@ public class PhysicCable : MonoBehaviour
else if (plugged)
{
// 吸附目标渐进收敛到孔心后,末端硬钉——软拉会被重力/张力持续拽偏,堵不严孔
_plugSnapEnd += (dock - _plugSnapEnd) * 0.5f;
Vector3 dockTarget = GetImpactDockTarget(dock, dt);
_plugSnapEnd += (dockTarget - _plugSnapEnd) * plugSnapRate;
if ((_plugSnapEnd - dock).sqrMagnitude < 1e-4f) _plugSnapEnd = dock;
_pts[_n - 1].pos = _plugSnapEnd;
_pts[_n - 1].prev = _plugSnapEnd;
@@ -388,6 +462,95 @@ public class PhysicCable : MonoBehaviour
UpdatePlugAngle(anchor, dragging, plugged);
}
private Vector3 ApplyUnplugResistance(Vector3 rawPointer)
{
if (!_unplugResisting) return rawPointer;
Vector3 pull = rawPointer - _unplugAnchor;
pull.z = 0;
float dist = pull.magnitude;
if (dist >= unplugBreakDistance)
{
_unplugResisting = false;
if (dist > 1e-4f)
{
KickTail(pull / dist * unplugReleaseKick);
}
return rawPointer;
}
float t = Mathf.Clamp01(dist / Mathf.Max(0.001f, unplugBreakDistance));
float eased = Mathf.Pow(t, Mathf.Max(1f, unplugStickExponent));
return Vector3.Lerp(_unplugAnchor, rawPointer, eased);
}
private Vector3 GetAssistedPointer(Vector3 pointer)
{
if (!enableSocketAssist || _dragAssistDock == null || State != CableState.Dragging)
{
_dragAssistBlend = Mathf.MoveTowards(_dragAssistBlend, 0f, 0.18f);
return pointer;
}
Vector3 dock = FlattenZ(_dragAssistDock.position);
float dist = Vector3.Distance(pointer, dock);
float targetBlend = Mathf.Clamp01(1f - dist / Mathf.Max(0.01f, _dragAssistRadius));
targetBlend = targetBlend * targetBlend * (3f - 2f * targetBlend);
targetBlend *= socketAssistStrength;
_dragAssistBlend = Mathf.MoveTowards(_dragAssistBlend, targetBlend, 0.14f);
return Vector3.Lerp(pointer, dock, _dragAssistBlend);
}
private void BeginInsertImpact(Transform dock)
{
if (dock == null || IsInsertImpactDisabled()) return;
Vector3 dockPos = FlattenZ(dock.position);
Vector3 dir = dockPos - _pts[_n - 1].pos;
if (dir.sqrMagnitude < 1e-4f)
{
dir = _pts[_n - 1].pos - _pts[Mathf.Max(0, _n - 3)].pos;
}
dir.z = 0;
if (dir.sqrMagnitude < 1e-4f) dir = Vector3.right;
_insertImpactDir = dir.normalized;
_insertImpactNormal = new Vector3(-_insertImpactDir.y, _insertImpactDir.x, 0f);
_insertImpactTimer = Mathf.Max(0f, insertImpactDuration);
KickTail(_insertImpactDir * insertTailKick);
}
private bool IsInsertImpactDisabled()
{
return insertImpactDuration <= 0f
&& insertTailKick <= 0f
&& insertShoveDistance <= 0f
&& insertShakeAmplitude <= 0f;
}
private Vector3 GetImpactDockTarget(Vector3 dock, float dt)
{
if (_insertImpactTimer <= 0f) return dock;
_insertImpactTimer = Mathf.Max(0f, _insertImpactTimer - dt);
float life = Mathf.Clamp01(_insertImpactTimer / Mathf.Max(0.001f, insertImpactDuration));
float elapsed = insertImpactDuration - _insertImpactTimer;
float shove = insertShoveDistance * life * life;
float shake = Mathf.Sin(elapsed * insertShakeFrequency) * insertShakeAmplitude * life;
return dock + _insertImpactDir * shove + _insertImpactNormal * shake;
}
private void KickTail(Vector3 impulse)
{
if (_pts == null) return;
int start = Mathf.Max(1, _n - 5);
for (int i = start; i < _n; i++)
{
float t = (i - start + 1f) / (_n - start + 1f);
_pts[i].prev -= impulse * t;
}
}
private void ApplyOutletStub(Vector3 anchor, Vector3 dir)
{
// P1:横向收紧 0.6,且沿出线方向至少伸出 stubMinOffset
+134 -8
View File
@@ -2,6 +2,7 @@
using AibisDream.Framework;
using AibisDream.Kit;
using AibisDream.Utility;
using DG.Tweening;
using UnityEngine;
using UnityEngine.EventSystems;
@@ -45,6 +46,23 @@ namespace AibisDream.FixSystem
public event Action OnPlugPointerDown;
private bool _isDragging;
private bool _isInserting;
private Tween _insertTween;
private Tween _unplugTween;
private Vector3 _feedbackOffset;
[Header("插入手感")]
[SerializeField] private float insertWindupDistance = 0.08f;
[SerializeField] private float insertWindupDuration = 0.045f;
[SerializeField] private float insertSnapDuration = 0.11f;
[SerializeField] private float insertSnapOvershoot = 1.45f;
[SerializeField] private float insertPunchScale = 0.045f;
[Header("拔出手感")]
[SerializeField] private float unplugPopDistance = 0.13f;
[SerializeField] private float unplugPopDuration = 0.07f;
[SerializeField] private float unplugReturnDuration = 0.12f;
[SerializeField] private float unplugPunchScale = 0.065f;
private void Awake()
{
@@ -80,14 +98,42 @@ namespace AibisDream.FixSystem
/// <param name="targetSocket">目标socket</param>
private void InsertSocket(ISocket targetSocket)
{
// 修改插头位置
transform.position = targetSocket.GetSocketPos().position;
if (_isInserting) return;
_isInserting = true;
// 处理插槽插入
targetSocket.PlugIn();
_sprite.enabled = false;
cablePanel.ClearDragHighlight();
ClearFeedbackOffset();
OnInsertSocket?.Invoke(targetSocket);
var targetPos = targetSocket.GetSocketPos().position;
_insertTween?.Kill();
transform.DOKill(false);
transform.localScale = Vector3.one;
Vector3 insertDir = targetPos - transform.position;
insertDir.z = 0;
if (insertDir.sqrMagnitude < 1e-4f) insertDir = transform.right;
insertDir.Normalize();
var sequence = DOTween.Sequence();
if (insertWindupDistance > 0f && insertWindupDuration > 0f)
{
sequence.Append(transform.DOMove(transform.position - insertDir * insertWindupDistance, insertWindupDuration)
.SetEase(Ease.OutSine));
}
sequence.Append(transform.DOMove(targetPos, insertSnapDuration)
.SetEase(Ease.OutBack, insertSnapOvershoot))
.Insert(0f, transform.DOPunchScale(Vector3.one * insertPunchScale,
insertWindupDuration + insertSnapDuration, 7, 0.45f))
.OnUpdate(() => cablePanel.UpdateDragPointer(transform.position))
.OnComplete(() =>
{
targetSocket.PlugIn();
_sprite.enabled = false;
_isInserting = false;
OnInsertSocket?.Invoke(targetSocket);
});
_insertTween = sequence;
}
/// <summary>
@@ -95,15 +141,23 @@ namespace AibisDream.FixSystem
/// </summary>
public void PullUpSocket()
{
_insertTween?.Kill();
_isInserting = false;
_sprite.enabled = true;
ApplyNormalSorting();
transform.DOKill(false);
transform.localScale = Vector3.one;
PlayUnplugFeedback();
OnPullUpSocket?.Invoke();
}
/// <summary>读档/静默:仅恢复插头精灵可见,不触发 CablePanel 事件。</summary>
public void PullUpSocketSilent()
{
ClearFeedbackOffset();
_sprite.enabled = true;
ApplyNormalSorting();
}
@@ -118,6 +172,7 @@ namespace AibisDream.FixSystem
/// <summary>读档:无动画回到初始位。</summary>
public void RestoreAtStartPositionImmediate()
{
ClearFeedbackOffset();
transform.rotation = _defaultRotation;
transform.position = _startPos;
}
@@ -126,6 +181,7 @@ namespace AibisDream.FixSystem
public void RestoreInsertedAt(Transform socketPos)
{
if (socketPos == null) return;
ClearFeedbackOffset();
transform.position = socketPos.position;
_sprite.enabled = false;
}
@@ -135,14 +191,84 @@ namespace AibisDream.FixSystem
_sprite.enabled = visible;
}
public void ApplyFeedbackOffset()
{
if (_feedbackOffset.sqrMagnitude <= 1e-6f) return;
transform.position += _feedbackOffset;
}
private void PlayUnplugFeedback()
{
_unplugTween?.Kill();
_feedbackOffset = Vector3.zero;
Vector3 releaseDir = GetUnplugReleaseDirection();
Vector3 popOffset = releaseDir * Mathf.Max(0f, unplugPopDistance);
var sequence = DOTween.Sequence();
if (popOffset.sqrMagnitude > 1e-6f && unplugPopDuration > 0f)
{
sequence.Append(DOTween.To(() => _feedbackOffset, value => _feedbackOffset = value,
popOffset, unplugPopDuration)
.SetEase(Ease.OutCubic));
}
if (unplugReturnDuration > 0f)
{
sequence.Append(DOTween.To(() => _feedbackOffset, value => _feedbackOffset = value,
Vector3.zero, unplugReturnDuration)
.SetEase(Ease.OutBack, 1.25f));
}
else
{
sequence.AppendCallback(() => _feedbackOffset = Vector3.zero);
}
if (unplugPunchScale > 0f)
{
sequence.Insert(0f, transform.DOPunchScale(Vector3.one * unplugPunchScale,
unplugPopDuration + unplugReturnDuration, 7, 0.42f));
}
sequence.OnKill(() => _feedbackOffset = Vector3.zero)
.OnComplete(() => _feedbackOffset = Vector3.zero);
_unplugTween = sequence;
}
private Vector3 GetUnplugReleaseDirection()
{
Vector3 releaseDir = Vector3.zero;
if (cablePanel != null && cablePanel.CableRootPos != null)
{
releaseDir = cablePanel.CableRootPos.position - transform.position;
}
releaseDir.z = 0;
if (releaseDir.sqrMagnitude < 1e-4f)
{
releaseDir = -transform.right;
releaseDir.z = 0;
}
return releaseDir.sqrMagnitude > 1e-4f ? releaseDir.normalized : Vector3.left;
}
private void ClearFeedbackOffset()
{
_unplugTween?.Kill();
_feedbackOffset = Vector3.zero;
transform.localScale = Vector3.one;
}
private void OnDrag(BaseEventData eventData)
{
if (!_isDragging) return;
if (eventData is PointerEventData pointerData)
{
// 指针只驱动线缆送线与末端钉住目标,插头本体由绳末端带动(CablePanel.LateUpdate
cablePanel.UpdateDragPointer(CameraKit.GetMouseWorldPos(pointerData.position));
var pointerWorldPos = CameraKit.GetMouseWorldPos(pointerData.position);
cablePanel.UpdateDragPointer(pointerWorldPos);
cablePanel.UpdateDragProximity(pointerWorldPos);
}
}
@@ -21,6 +21,9 @@ namespace AibisDream.FixSystem
[Header("灯光系统")]
[SerializeField] private CablePanelLight panelLight;
[Header("拖拽手感")]
[SerializeField] private float dragAssistRangeMultiplier = 1.65f;
public Plug PlugRef => plugRef;
public Cable CableRef => cableRef;
public PhysicCable PhysicCableRef => physicCableRef;
@@ -195,6 +198,7 @@ namespace AibisDream.FixSystem
if (state == PhysicCable.CableState.Free || state == PhysicCable.CableState.Dragging)
{
physicCableRef.ApplyPlugPose(plugRef.transform, plugRef.PlugRoot);
plugRef.ApplyFeedbackOffset();
}
}
@@ -245,11 +249,17 @@ namespace AibisDream.FixSystem
private void OnPlugPoinerDown()
{
if (IsPlugInSocket()) PlugRef.PullUpSocket();
Transform unplugSocket = curSocket?.GetSocketPos();
bool wasPlugged = IsPlugInSocket();
if (wasPlugged) PlugRef.PullUpSocket();
// 末端钉到拖拽目标,按需送线;指针位置随 OnDrag 持续更新。
// 排序不随拖拽切换:线缆与插头常驻 FixTop 高位(原型画序固定不变)
physicCableRef.BeginDrag(PlugRef.transform.position);
if (wasPlugged)
{
physicCableRef.StartUnplugResistance(unplugSocket);
}
// 打开插孔
BodyModuleSystem.OpenModuleSlots();
@@ -261,9 +271,37 @@ namespace AibisDream.FixSystem
physicCableRef.SetPointer(pointerWorldPos);
}
public void UpdateDragProximity(Vector3 pointerWorldPos)
{
if (physicCableRef == null || BodyModuleSystem?.BodyModules == null)
{
return;
}
float snapRange = BodyModuleSystem.plugSnappingRange;
float assistRange = Mathf.Max(snapRange, snapRange * dragAssistRangeMultiplier);
if (TryFindClosestModuleWithin(pointerWorldPos, assistRange, out var targetModule))
{
targetModule.OpenPlugSlot();
BodyModuleSystem.CloseModuleSlots(targetModule);
physicCableRef.SetDragAssistTarget(targetModule.GetSocketPos(), assistRange);
}
else
{
ClearDragHighlight();
}
}
public void ClearDragHighlight()
{
physicCableRef?.ClearDragAssistTarget();
BodyModuleSystem?.CloseModuleSlots(null);
}
/// <summary>松手且未插中插孔:解除钉住,线缆自动回收,插头挂回绳末端。</summary>
public void ReleasePlug()
{
ClearDragHighlight();
physicCableRef.EndDrag();
}
@@ -288,6 +326,30 @@ namespace AibisDream.FixSystem
return res;
}
private bool TryFindClosestModuleWithin(Vector3 sourcePos, float range, out BodyModule targetModule)
{
float closestDistance = Mathf.Infinity;
BodyModule closestModule = null;
foreach (var bodyModule in BodyModuleSystem.BodyModules)
{
if (bodyModule == null || !bodyModule.gameObject.activeInHierarchy || !bodyModule.IsAvailable())
{
continue;
}
float distance = Vector3.Distance(sourcePos, bodyModule.GetSocketPos().position);
if (distance < closestDistance)
{
closestDistance = distance;
closestModule = bodyModule;
}
}
targetModule = closestDistance <= range ? closestModule : null;
return targetModule != null;
}
public void PullUpCable()
{
if (isCableRetracted) return;