1020 lines
35 KiB
C#
1020 lines
35 KiB
C#
using System.Collections;
|
||
using System.Linq;
|
||
using AibisDream;
|
||
using AibisDream.Framework;
|
||
using AibisDream.FixSystem;
|
||
using DG.Tweening;
|
||
using UnityEngine;
|
||
using UnityEngine.Events;
|
||
using UnityEngine.EventSystems;
|
||
using UnityEngine.Serialization;
|
||
using Yarn.Unity;
|
||
|
||
namespace AibisDream.MiniGame.HuoShan
|
||
{
|
||
/// <summary>
|
||
/// 处理器旋钮控制器 — 完整移植 Web_EmotionWave glow-tube 分支的交互体验
|
||
/// 交互通过 EventTriggerEx 统一管理,支持 EventSystemEx.isLocked 和 IInteraction 状态
|
||
/// 核心特性:累计旋转、输入阻力、噪声颤抖、回弹前蓄力预兆、底座发热变色
|
||
/// 保留原有公共接口(事件、Yarn 命令、功率查询)
|
||
/// 在旋钮 GameObject 上添加 EventTriggerEx + Collider2D 即可接入;未添加则回退到 Input+Raycast
|
||
/// </summary>
|
||
public class KnobController : MonoBehaviour, IInteraction
|
||
{
|
||
#region Serialized Fields
|
||
|
||
[Header("旋钮 Transform")]
|
||
[Tooltip("旋钮旋转的 Transform")]
|
||
[SerializeField] private Transform knobTransform;
|
||
|
||
[Tooltip("旋钮底座 SpriteRenderer(发热变色)")]
|
||
[SerializeField] private SpriteRenderer knobPlate;
|
||
|
||
[Tooltip("功率指示器 Transform(旋钮上的标记线)")]
|
||
[SerializeField] private Transform powerIndicator;
|
||
|
||
[Tooltip("用于检测点击的 Collider2D")]
|
||
[SerializeField] private Collider2D interactionCollider;
|
||
|
||
[Header("旋转参数")]
|
||
[Tooltip("最大累计圈数")]
|
||
[SerializeField] private int maxTurns = 10;
|
||
|
||
[Tooltip("旋转方向:勾选则顺时针拧=降功率(与 Web 原型一致)")]
|
||
[SerializeField] private bool clockwiseDecreasesPower = true;
|
||
|
||
[Tooltip("停手后到回弹的酝酿时间 (秒)")]
|
||
[SerializeField] private float idleDelay = 0.8f;
|
||
|
||
[Tooltip("回弹基础时长 (秒)")]
|
||
[SerializeField] private float snapbackDuration = 0.8f;
|
||
|
||
[Header("辉光管")]
|
||
[Tooltip("辉光管粒子效果(不填则从 SaleView 下查找)")]
|
||
[SerializeField] private GlowTubeEffect glowTubeEffect;
|
||
|
||
[Header("信息面板")]
|
||
[SerializeField] private GameObject infoPanel;
|
||
[SerializeField] private UnityEngine.UI.Text infoText;
|
||
[SerializeField] private string failMessage = "表达模块拒绝了未被处理的波形";
|
||
[SerializeField] private float panelShowDuration = 0.3f;
|
||
[SerializeField] private float panelHideDuration = 0.2f;
|
||
|
||
[Header("Yarn")]
|
||
[Tooltip("单次失败时跳转的 Yarn 节点(Yarn 根据 $knobFailCount 决定:重试 reset_knob 或 lock_knob 后跳 failNode)")]
|
||
[SerializeField] private string singleFailNode = "处理器单次调节失败";
|
||
|
||
[Header("失败判定")]
|
||
[Tooltip("旋转到最大角度的百分比后判定为失败(0-1)")]
|
||
[SerializeField] private float failThreshold = 0.3f;
|
||
|
||
[Header("旋钮三态外观")]
|
||
[Tooltip("旋钮本体 SpriteRenderer(切 收起/展开/激活);不填则用 knobPlate / knobTransform")]
|
||
[SerializeField] private SpriteRenderer knobVisual;
|
||
|
||
[Tooltip("收起(盖住)")]
|
||
[SerializeField] private Sprite collapsedSprite;
|
||
|
||
[Tooltip("展开(打开后待机)")]
|
||
[SerializeField] private Sprite expandedSprite;
|
||
|
||
[Tooltip("激活(提示可交互 / 高亮)")]
|
||
[SerializeField] private Sprite activeSprite;
|
||
|
||
[Tooltip("开合切图过渡时长(Yarn open/close_knob_lid 等待用;切图本身即时)")]
|
||
[SerializeField] private float lidAnimDuration = 0.4f;
|
||
|
||
[Tooltip("旧版盖子子物体(步进模式/旋钮/盖子)。有三态图后会自动隐藏,避免遮挡")]
|
||
[FormerlySerializedAs("knobLid")]
|
||
[SerializeField] private Transform legacyKnobLid;
|
||
|
||
[Header("交互提示")]
|
||
[Tooltip("高亮目标:仅当未配置 activeSprite 时回退改材质 _ShineFade;有激活图时高亮=切到激活态")]
|
||
[SerializeField] private SpriteRenderer interactionHighlightSprite;
|
||
|
||
[Tooltip("开始拖拽旋钮时是否退出激活态(回到展开)")]
|
||
[SerializeField] private bool clearHighlightWhenDragStarts = true;
|
||
|
||
[Header("环形进度条")]
|
||
[Tooltip("灯带进度条 SpriteRenderer;不填则尝试自动查找")]
|
||
[SerializeField] private SpriteRenderer ringProgressRenderer;
|
||
|
||
[Tooltip("自动查找的灯带节点名")]
|
||
[SerializeField] private string ringProgressChildName = "灯带进度条";
|
||
|
||
[Tooltip("环形填充起始角度,0=右,90=上")]
|
||
[SerializeField] private float ringProgressStartAngle = 90f;
|
||
|
||
[Tooltip("勾选则按顺时针方向填充")]
|
||
[SerializeField] private bool ringProgressClockwise = true;
|
||
|
||
[Tooltip("灯带进度条着色")]
|
||
[SerializeField] private Color ringProgressTint = new Color(1f, 0.72f, 0.25f);
|
||
|
||
[Tooltip("着色强度:0=保留贴图本色,1=完全按上方颜色重新上色(贴图只保留明暗)")]
|
||
[Range(0f, 1f)]
|
||
[SerializeField] private float ringProgressRecolor = 1f;
|
||
|
||
[Header("事件")]
|
||
public UnityEvent onPowerAdjustmentStart;
|
||
public UnityEvent<float> onPowerChanged;
|
||
public UnityEvent<float> onPowerAdjustmentFail;
|
||
public UnityEvent<float> onPowerAdjustmentSuccess;
|
||
[Tooltip("回弹完成时触发")]
|
||
public UnityEvent onSnapbackComplete;
|
||
|
||
#endregion
|
||
|
||
#region Private State
|
||
|
||
private float _maxAngle;
|
||
private float _knobAngle;
|
||
private float _knobValue;
|
||
private float _intensity = 1f;
|
||
private float _displayIntensity = 1f;
|
||
|
||
private bool _isDragging;
|
||
private bool _isSnappingBack;
|
||
private bool _snapbackLocked;
|
||
|
||
// Tremor
|
||
private float _tremorAmount;
|
||
private float _tremorTarget;
|
||
private float _seedA;
|
||
private float _seedB;
|
||
|
||
// Idle / warning
|
||
private float _idleStartTime;
|
||
private bool _isIdle;
|
||
private Coroutine _idleCoroutine;
|
||
|
||
// Overshoot
|
||
private float _overshootIntensity;
|
||
private float _overshootDecay;
|
||
|
||
// Resistance pulse
|
||
private float _resistancePulse;
|
||
private float _nextPulseTime;
|
||
|
||
private float _animTime;
|
||
private float _prevMouseAngle;
|
||
private Camera _mainCamera;
|
||
|
||
private bool _hasFailed;
|
||
private bool _hasSucceeded;
|
||
private float _currentPower = 100f;
|
||
private int _failCount;
|
||
private bool _isLocked;
|
||
|
||
private EventTriggerEx _eventTrigger;
|
||
private bool _useEventTrigger;
|
||
|
||
private Material _ringProgressMaterial;
|
||
|
||
private const string RingProgressShaderName = "AibisDream/SpriteRadialProgress";
|
||
private const string RingProgressShaderResourcePath = "Shader/SpriteRadialProgress";
|
||
private const string CollapsedSpriteName = "旋钮收起";
|
||
private const string ExpandedSpriteName = "旋钮展开";
|
||
private const string ActiveSpriteName = "旋钮激活状态";
|
||
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
|
||
private static readonly int ProgressId = Shader.PropertyToID("_Progress");
|
||
private static readonly int StartAngleId = Shader.PropertyToID("_StartAngle");
|
||
private static readonly int FillClockwiseId = Shader.PropertyToID("_FillClockwise");
|
||
private static readonly int ShineFadeId = Shader.PropertyToID("_ShineFade");
|
||
private static readonly int ColorId = Shader.PropertyToID("_Color");
|
||
private static readonly int RecolorStrengthId = Shader.PropertyToID("_RecolorStrength");
|
||
|
||
private SpriteRenderer _shineHighlightSprite;
|
||
private bool _isLidOpen;
|
||
private bool _highlightVisible;
|
||
private KnobAppearance _appearance = KnobAppearance.Collapsed;
|
||
|
||
private enum KnobAppearance
|
||
{
|
||
Collapsed,
|
||
Expanded,
|
||
Active
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Lifecycle
|
||
|
||
private void Awake()
|
||
{
|
||
_mainCamera = Camera.main;
|
||
_maxAngle = 360f * maxTurns;
|
||
_seedA = Random.Range(0f, 1000f);
|
||
_seedB = Random.Range(0f, 1000f);
|
||
|
||
if (knobTransform == null) knobTransform = transform;
|
||
if (interactionCollider == null && knobTransform != null)
|
||
interactionCollider = knobTransform.GetComponent<Collider2D>();
|
||
|
||
if (infoPanel != null) infoPanel.SetActive(false);
|
||
|
||
// 事件目标:Collider 所在的对象(可能与 KnobController 不同,如旋钮 visuals 在子物体)
|
||
var eventTargetGo = interactionCollider != null ? interactionCollider.gameObject : (knobTransform != null ? knobTransform.gameObject : gameObject);
|
||
|
||
_eventTrigger = eventTargetGo.GetComponent<EventTriggerEx>();
|
||
if (_eventTrigger == null && eventTargetGo.GetComponent<Collider2D>() != null)
|
||
{
|
||
if (eventTargetGo != gameObject)
|
||
{
|
||
var bridge = eventTargetGo.GetComponent<KnobInteractionBridge>();
|
||
if (bridge == null) eventTargetGo.AddComponent<KnobInteractionBridge>();
|
||
}
|
||
_eventTrigger = eventTargetGo.AddComponent<EventTriggerEx>();
|
||
}
|
||
if (_eventTrigger != null)
|
||
{
|
||
_useEventTrigger = true;
|
||
EnsureEventTriggerEntries();
|
||
_eventTrigger.Register(EventTriggerType.PointerDown, OnEventPointerDown);
|
||
_eventTrigger.Register(EventTriggerType.PointerUp, OnEventPointerUp);
|
||
_eventTrigger.Register(EventTriggerType.Drag, OnEventDrag);
|
||
}
|
||
|
||
EnsureKnobVisual();
|
||
EnsureKnobStateSprites();
|
||
DisableLegacyKnobLid();
|
||
ApplyKnobAppearance(KnobAppearance.Collapsed, force: true);
|
||
|
||
EnsureRingProgress();
|
||
|
||
EnsureInteractionHighlightSprite();
|
||
SetInteractionHighlightVisible(false);
|
||
}
|
||
|
||
private void EnsureKnobVisual()
|
||
{
|
||
if (knobVisual != null) return;
|
||
if (knobPlate != null)
|
||
{
|
||
knobVisual = knobPlate;
|
||
return;
|
||
}
|
||
if (knobTransform != null)
|
||
knobVisual = knobTransform.GetComponent<SpriteRenderer>();
|
||
}
|
||
|
||
private void EnsureKnobStateSprites()
|
||
{
|
||
EnsureKnobVisual();
|
||
if (knobVisual == null || knobVisual.sprite == null) return;
|
||
|
||
var sprites = Resources.FindObjectsOfTypeAll<Sprite>();
|
||
Sprite FindByName(string spriteName)
|
||
{
|
||
for (int i = 0; i < sprites.Length; i++)
|
||
{
|
||
var s = sprites[i];
|
||
if (s != null && s.name == spriteName && s.texture == knobVisual.sprite.texture)
|
||
return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (collapsedSprite == null) collapsedSprite = FindByName(CollapsedSpriteName);
|
||
if (expandedSprite == null) expandedSprite = FindByName(ExpandedSpriteName);
|
||
if (activeSprite == null) activeSprite = FindByName(ActiveSpriteName);
|
||
}
|
||
|
||
private void DisableLegacyKnobLid()
|
||
{
|
||
if (legacyKnobLid == null && knobTransform != null)
|
||
{
|
||
var lid = knobTransform.Find("盖子");
|
||
if (lid != null) legacyKnobLid = lid;
|
||
}
|
||
|
||
if (legacyKnobLid == null)
|
||
{
|
||
var t = transform;
|
||
while (t != null)
|
||
{
|
||
if (t.name == "SaleView" || t.name == "SellView" || t.name == "SalelView")
|
||
{
|
||
legacyKnobLid = t.Find("步进模式/旋钮/盖子");
|
||
break;
|
||
}
|
||
t = t.parent;
|
||
}
|
||
}
|
||
|
||
if (legacyKnobLid != null)
|
||
legacyKnobLid.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void EnsureInteractionHighlightSprite()
|
||
{
|
||
if (interactionHighlightSprite != null)
|
||
{
|
||
_shineHighlightSprite = interactionHighlightSprite;
|
||
return;
|
||
}
|
||
EnsureKnobVisual();
|
||
if (knobVisual != null)
|
||
{
|
||
_shineHighlightSprite = knobVisual;
|
||
return;
|
||
}
|
||
if (knobPlate != null)
|
||
{
|
||
_shineHighlightSprite = knobPlate;
|
||
return;
|
||
}
|
||
if (knobTransform != null)
|
||
_shineHighlightSprite = knobTransform.GetComponent<SpriteRenderer>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 控制旋钮交互提示。有激活图时切到「激活」态;否则回退改材质 _ShineFade。
|
||
/// </summary>
|
||
public void SetInteractionHighlightVisible(bool visible)
|
||
{
|
||
_highlightVisible = visible;
|
||
|
||
if (activeSprite != null || expandedSprite != null || collapsedSprite != null)
|
||
{
|
||
if (visible && _isLidOpen)
|
||
ApplyKnobAppearance(KnobAppearance.Active);
|
||
else if (_isLidOpen)
|
||
ApplyKnobAppearance(KnobAppearance.Expanded);
|
||
else
|
||
ApplyKnobAppearance(KnobAppearance.Collapsed);
|
||
return;
|
||
}
|
||
|
||
EnsureInteractionHighlightSprite();
|
||
if (_shineHighlightSprite == null) return;
|
||
var mat = _shineHighlightSprite.material;
|
||
if (mat == null || !mat.HasProperty(ShineFadeId)) return;
|
||
mat.SetFloat(ShineFadeId, visible ? 1f : 0f);
|
||
}
|
||
|
||
private void ApplyKnobAppearance(KnobAppearance appearance, bool force = false)
|
||
{
|
||
if (!force && _appearance == appearance) return;
|
||
_appearance = appearance;
|
||
|
||
EnsureKnobVisual();
|
||
if (knobVisual == null) return;
|
||
|
||
Sprite target = appearance switch
|
||
{
|
||
KnobAppearance.Collapsed => collapsedSprite != null ? collapsedSprite : knobVisual.sprite,
|
||
KnobAppearance.Expanded => expandedSprite != null ? expandedSprite : knobVisual.sprite,
|
||
KnobAppearance.Active => activeSprite != null ? activeSprite
|
||
: (expandedSprite != null ? expandedSprite : knobVisual.sprite),
|
||
_ => knobVisual.sprite
|
||
};
|
||
|
||
if (target != null)
|
||
knobVisual.sprite = target;
|
||
}
|
||
|
||
private void EnsureRingProgress()
|
||
{
|
||
if (ringProgressRenderer == null && knobTransform != null && !string.IsNullOrEmpty(ringProgressChildName))
|
||
{
|
||
var ring = knobTransform.Find(ringProgressChildName);
|
||
if (ring == null && knobTransform.parent != null)
|
||
ring = knobTransform.parent.Find(ringProgressChildName);
|
||
if (ring != null)
|
||
ringProgressRenderer = ring.GetComponent<SpriteRenderer>();
|
||
}
|
||
|
||
if (ringProgressRenderer == null)
|
||
return;
|
||
|
||
Shader shader = Resources.Load<Shader>(RingProgressShaderResourcePath);
|
||
if (shader == null)
|
||
shader = Shader.Find(RingProgressShaderName);
|
||
|
||
if (shader == null)
|
||
{
|
||
#if UNITY_EDITOR
|
||
Debug.LogWarning($"[KnobController] 未找到环形进度条 Shader:{RingProgressShaderName}", this);
|
||
#endif
|
||
return;
|
||
}
|
||
|
||
_ringProgressMaterial = new Material(shader)
|
||
{
|
||
name = $"{ringProgressRenderer.gameObject.name}_RingProgress_Mat"
|
||
};
|
||
|
||
if (ringProgressRenderer.sprite != null)
|
||
_ringProgressMaterial.SetTexture(MainTexId, ringProgressRenderer.sprite.texture);
|
||
|
||
ringProgressRenderer.material = _ringProgressMaterial;
|
||
ApplyRingProgressMaterialProperties();
|
||
UpdateRingProgress(_knobValue);
|
||
}
|
||
|
||
private void ApplyRingProgressMaterialProperties()
|
||
{
|
||
if (_ringProgressMaterial == null)
|
||
return;
|
||
|
||
_ringProgressMaterial.SetFloat(StartAngleId, ringProgressStartAngle);
|
||
_ringProgressMaterial.SetFloat(FillClockwiseId, ringProgressClockwise ? 1f : 0f);
|
||
_ringProgressMaterial.SetColor(ColorId, ringProgressTint);
|
||
_ringProgressMaterial.SetFloat(RecolorStrengthId, ringProgressRecolor);
|
||
}
|
||
|
||
private void UpdateRingProgress(float progress)
|
||
{
|
||
if (_ringProgressMaterial == null)
|
||
return;
|
||
|
||
if (ringProgressRenderer != null && ringProgressRenderer.sprite != null)
|
||
_ringProgressMaterial.SetTexture(MainTexId, ringProgressRenderer.sprite.texture);
|
||
|
||
_ringProgressMaterial.SetFloat(ProgressId, Mathf.Clamp01(progress));
|
||
}
|
||
|
||
private void EnsureEventTriggerEntries()
|
||
{
|
||
if (_eventTrigger == null) return;
|
||
var triggers = _eventTrigger.triggers;
|
||
var needed = new[]
|
||
{
|
||
EventTriggerType.PointerDown,
|
||
EventTriggerType.PointerUp,
|
||
EventTriggerType.Drag
|
||
};
|
||
foreach (var t in needed)
|
||
{
|
||
if (triggers.Any(e => e.eventID == t)) continue;
|
||
triggers.Add(new EventTrigger.Entry { eventID = t });
|
||
}
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
UpdateFromKnobAngle();
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
float dt = Time.deltaTime;
|
||
_animTime += dt;
|
||
|
||
// 强制使用 EventTriggerEx,不使用 Input+Raycast 回退
|
||
// if (!_useEventTrigger)
|
||
// HandleMouseInputFallback();
|
||
|
||
UpdateResistancePulse(dt);
|
||
UpdateKnobVisuals(dt);
|
||
UpdateGlowTube();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Input(EventTriggerEx 驱动 / 回退)
|
||
|
||
private void OnEventPointerDown(BaseEventData eventData)
|
||
{
|
||
if (eventData is PointerEventData { button: PointerEventData.InputButton.Left })
|
||
StartDrag();
|
||
}
|
||
|
||
private void OnEventPointerUp(BaseEventData eventData)
|
||
{
|
||
if (eventData is PointerEventData { button: PointerEventData.InputButton.Left })
|
||
EndDrag();
|
||
}
|
||
|
||
private void OnEventDrag(BaseEventData eventData)
|
||
{
|
||
if (_isDragging && eventData is PointerEventData pointerData)
|
||
ContinueDrag(pointerData.position);
|
||
}
|
||
|
||
private void HandleMouseInputFallback()
|
||
{
|
||
if (Input.GetMouseButtonDown(0))
|
||
{
|
||
if (_snapbackLocked) return;
|
||
Ray ray = _mainCamera.ScreenPointToRay(Input.mousePosition);
|
||
RaycastHit2D hit = Physics2D.GetRayIntersection(ray, float.MaxValue);
|
||
if (hit.collider != null && IsValidHit(hit.collider))
|
||
{
|
||
StartDrag();
|
||
}
|
||
}
|
||
|
||
if (_isDragging)
|
||
{
|
||
if (Input.GetMouseButton(0))
|
||
ContinueDrag(Input.mousePosition);
|
||
else
|
||
EndDrag();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region IInteraction(EventTriggerEx 统一交互)
|
||
|
||
public bool IsActive => !_isLocked;
|
||
|
||
/// <summary>
|
||
/// 不可用时(锁定、回弹中、已失败/成功)EventTriggerEx 将拦截交互
|
||
/// </summary>
|
||
public bool IsAvailable => !_isLocked && !_snapbackLocked && !_hasFailed && !_hasSucceeded;
|
||
|
||
public GameObject GetGameObject() => gameObject;
|
||
|
||
#endregion
|
||
|
||
private bool IsValidHit(Collider2D col)
|
||
{
|
||
if (col == interactionCollider) return true;
|
||
if (col.gameObject == gameObject) return true;
|
||
if (knobTransform != null && col.transform.IsChildOf(knobTransform)) return true;
|
||
return false;
|
||
}
|
||
|
||
private float GetMouseAngleDeg(Vector3? screenPos = null)
|
||
{
|
||
Vector3 screenCenter = _mainCamera.WorldToScreenPoint(knobTransform.position);
|
||
Vector3 mp = screenPos ?? Input.mousePosition;
|
||
return Mathf.Atan2(mp.y - screenCenter.y, mp.x - screenCenter.x) * Mathf.Rad2Deg;
|
||
}
|
||
|
||
/// <summary>输入阻力:转得越多,鼠标驱动力越弱</summary>
|
||
private float GetInputResistance()
|
||
{
|
||
return 1f - _knobValue * 0.88f;
|
||
}
|
||
|
||
#region Drag
|
||
|
||
private void StartDrag()
|
||
{
|
||
if (_hasFailed || _hasSucceeded || _isLocked) return;
|
||
|
||
if (clearHighlightWhenDragStarts)
|
||
SetInteractionHighlightVisible(false);
|
||
|
||
_isDragging = true;
|
||
_prevMouseAngle = GetMouseAngleDeg(Input.mousePosition);
|
||
ClearIdleState();
|
||
onPowerAdjustmentStart?.Invoke();
|
||
}
|
||
|
||
private void ContinueDrag(Vector3 screenPosition)
|
||
{
|
||
if (_snapbackLocked || _hasFailed || _hasSucceeded || _isLocked) return;
|
||
|
||
float cur = GetMouseAngleDeg(screenPosition);
|
||
float delta = cur - _prevMouseAngle;
|
||
if (delta > 180f) delta -= 360f;
|
||
if (delta < -180f) delta += 360f;
|
||
_prevMouseAngle = cur;
|
||
|
||
if (clockwiseDecreasesPower) delta = -delta;
|
||
delta *= GetInputResistance();
|
||
|
||
_knobAngle = Mathf.Clamp(_knobAngle + delta, 0f, _maxAngle);
|
||
UpdateFromKnobAngle();
|
||
|
||
ClearIdleState();
|
||
StartIdleCountdown();
|
||
}
|
||
|
||
private void EndDrag()
|
||
{
|
||
if (!_isDragging) return;
|
||
_isDragging = false;
|
||
|
||
if (_knobAngle > 10f)
|
||
StartIdleCountdown();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Core State
|
||
|
||
private void UpdateFromKnobAngle()
|
||
{
|
||
_knobAngle = Mathf.Clamp(_knobAngle, 0f, _maxAngle);
|
||
_knobValue = _knobAngle / _maxAngle;
|
||
UpdateRingProgress(_knobValue);
|
||
|
||
// Intensity: cubic falloff
|
||
_intensity = Mathf.Pow(1f - _knobValue, 3f);
|
||
_intensity = Mathf.Max(0.03f, _intensity);
|
||
|
||
_tremorTarget = _knobValue * _knobValue * 3.5f;
|
||
|
||
_currentPower = _intensity * 100f;
|
||
onPowerChanged?.Invoke(_currentPower);
|
||
|
||
// Fail check
|
||
if (_knobValue >= failThreshold && !_hasFailed && !_hasSucceeded)
|
||
{
|
||
TriggerFailure();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Idle / Warning
|
||
|
||
private void ClearIdleState()
|
||
{
|
||
if (_idleCoroutine != null)
|
||
{
|
||
StopCoroutine(_idleCoroutine);
|
||
_idleCoroutine = null;
|
||
}
|
||
_isIdle = false;
|
||
_idleStartTime = 0f;
|
||
}
|
||
|
||
private void StartIdleCountdown()
|
||
{
|
||
ClearIdleState();
|
||
_isIdle = true;
|
||
_idleStartTime = Time.time;
|
||
_idleCoroutine = StartCoroutine(IdleSnapbackRoutine());
|
||
}
|
||
|
||
private IEnumerator IdleSnapbackRoutine()
|
||
{
|
||
yield return new WaitForSeconds(idleDelay);
|
||
TriggerSnapback();
|
||
}
|
||
|
||
private float GetWarningLevel()
|
||
{
|
||
if (!_isIdle || _knobAngle < 10f) return 0f;
|
||
float elapsed = Time.time - _idleStartTime;
|
||
return Mathf.Pow(Mathf.Min(1f, elapsed / idleDelay), 2.8f);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Snapback
|
||
|
||
private void TriggerSnapback()
|
||
{
|
||
if (_knobAngle < 5f) return;
|
||
_isSnappingBack = true;
|
||
_snapbackLocked = true;
|
||
_isIdle = false;
|
||
|
||
StartCoroutine(SnapbackRoutine());
|
||
}
|
||
|
||
private IEnumerator SnapbackRoutine()
|
||
{
|
||
float snapFrom = _knobAngle;
|
||
float turns = snapFrom / 360f;
|
||
float dur = Mathf.Clamp(snapbackDuration * (turns / 3f), 0.5f, 1.2f);
|
||
float t0 = Time.time;
|
||
|
||
while (true)
|
||
{
|
||
float elapsed = Time.time - t0;
|
||
float p = Mathf.Clamp01(elapsed / dur);
|
||
|
||
// Charge-then-burst easing
|
||
float eased;
|
||
if (p < 0.08f)
|
||
{
|
||
eased = p * 0.02f;
|
||
}
|
||
else
|
||
{
|
||
float t = (p - 0.08f) / 0.92f;
|
||
eased = 0.0016f + 0.9984f * (1f - Mathf.Pow(1f - t, 5f));
|
||
}
|
||
|
||
_knobAngle = snapFrom * (1f - eased);
|
||
UpdateFromKnobAngle();
|
||
_tremorTarget = 10f + turns * 3f;
|
||
|
||
if (p >= 1f) break;
|
||
yield return null;
|
||
}
|
||
|
||
_knobAngle = 0f;
|
||
UpdateFromKnobAngle();
|
||
_overshootIntensity = Mathf.Min(0.55f, 0.2f + turns * 0.05f);
|
||
_overshootDecay = 0f;
|
||
_isSnappingBack = false;
|
||
_tremorTarget = 0f;
|
||
|
||
if (glowTubeEffect != null)
|
||
glowTubeEffect.TriggerPulse(_overshootIntensity);
|
||
|
||
yield return new WaitForSeconds(0.4f);
|
||
_snapbackLocked = false;
|
||
|
||
// 若是失败触发的回弹,回弹完成后再触发失败事件和 UI
|
||
if (_hasFailed)
|
||
{
|
||
ShowInfoPanel(failMessage);
|
||
onPowerAdjustmentFail?.Invoke(_currentPower);
|
||
if (!string.IsNullOrEmpty(singleFailNode))
|
||
DialogController.Instance?.StartDialogNode(singleFailNode);
|
||
Debug.Log($"[KnobController] Snapback complete, failure events triggered");
|
||
}
|
||
|
||
onSnapbackComplete?.Invoke();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Visuals
|
||
|
||
private void UpdateKnobVisuals(float dt)
|
||
{
|
||
// Smooth tremor
|
||
_tremorAmount += (_tremorTarget - _tremorAmount) * Mathf.Min(1f, dt * 14f);
|
||
|
||
float warning = GetWarningLevel();
|
||
float warningTremor = warning * warning * 15f;
|
||
float totalTremor = _tremorAmount + warningTremor;
|
||
|
||
// Rotation + tremor offset
|
||
float tremor = 0f;
|
||
if (totalTremor > 0.05f)
|
||
{
|
||
tremor = (Mathf.PerlinNoise(_animTime * 50f + _seedA, 0f) * 2f - 1f) * 0.6f
|
||
+ (Mathf.PerlinNoise(_animTime * 120f + _seedB, 7f) * 2f - 1f) * 0.4f;
|
||
tremor *= totalTremor;
|
||
}
|
||
|
||
knobTransform.localRotation = Quaternion.Euler(0f, 0f, -(_knobAngle + tremor));
|
||
}
|
||
|
||
private void UpdateResistancePulse(float dt)
|
||
{
|
||
if (_intensity < 0.5f && _knobAngle > 100f && !_isSnappingBack)
|
||
{
|
||
float suppression = 1f - _intensity;
|
||
if (_animTime > _nextPulseTime)
|
||
{
|
||
_resistancePulse = Mathf.Max(_resistancePulse,
|
||
suppression * (0.25f + Random.value * 0.5f));
|
||
_nextPulseTime = _animTime + 0.2f + Random.value * (2.5f - suppression * 2f);
|
||
}
|
||
}
|
||
|
||
float warnPulse = GetWarningLevel();
|
||
if (warnPulse > 0.2f)
|
||
{
|
||
float wp = warnPulse * 0.5f * (0.5f + 0.5f * Mathf.Sin(_animTime * 18f * warnPulse));
|
||
if (wp > 0f) _resistancePulse = Mathf.Max(_resistancePulse, wp);
|
||
}
|
||
|
||
_resistancePulse *= Mathf.Exp(-dt * 9f);
|
||
if (_resistancePulse < 0.008f) _resistancePulse = 0f;
|
||
}
|
||
|
||
private void UpdateGlowTube()
|
||
{
|
||
_overshootDecay += Time.deltaTime * 3f;
|
||
float overshoot = _overshootIntensity * Mathf.Exp(-_overshootDecay * 2.5f)
|
||
* (1f + Mathf.Sin(_overshootDecay * 40f) * 0.5f * Mathf.Exp(-_overshootDecay * 1.5f));
|
||
|
||
// 辉光与环形进度一致:用线性剩余量驱动粒子,避免 Pow(...,3) 导致中段过暗、与灯带进度脱节
|
||
float glowBase = Mathf.Max(0.03f, 1f - _knobValue);
|
||
_displayIntensity = glowBase + _resistancePulse + Mathf.Max(0f, overshoot);
|
||
_displayIntensity = Mathf.Min(1.5f, _displayIntensity);
|
||
|
||
if (glowTubeEffect != null)
|
||
{
|
||
glowTubeEffect.SetIntensity(_displayIntensity);
|
||
if (_resistancePulse > 0.1f)
|
||
glowTubeEffect.TriggerPulse(_resistancePulse * 0.5f);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Failure / Info Panel
|
||
|
||
private void TriggerFailure()
|
||
{
|
||
_failCount++;
|
||
YarnVariableStorage.Instance?.SetValue("$knobFailCount", (float)_failCount);
|
||
_hasFailed = true;
|
||
Debug.Log($"[KnobController] Failure #{_failCount} at {_currentPower:F1}%, waiting for snapback...");
|
||
|
||
// 不立即触发 UI/事件,等旋钮回弹完成后再触发
|
||
TriggerSnapback();
|
||
}
|
||
|
||
private void ShowInfoPanel(string message)
|
||
{
|
||
if (infoPanel == null || infoText == null) return;
|
||
infoText.text = message;
|
||
infoPanel.SetActive(true);
|
||
|
||
var rt = infoPanel.GetComponent<RectTransform>();
|
||
if (rt != null)
|
||
{
|
||
Vector3 orig = rt.anchoredPosition;
|
||
rt.anchoredPosition = orig + Vector3.up * 100f;
|
||
rt.DOAnchorPos(orig, panelShowDuration).SetEase(Ease.OutCubic);
|
||
}
|
||
}
|
||
|
||
private void HideInfoPanel()
|
||
{
|
||
if (infoPanel == null) return;
|
||
var rt = infoPanel.GetComponent<RectTransform>();
|
||
if (rt != null)
|
||
{
|
||
var end = rt.anchoredPosition + Vector2.up * 100f;
|
||
rt.DOAnchorPos(end, panelHideDuration)
|
||
.SetEase(Ease.InCubic)
|
||
.OnComplete(() => infoPanel.SetActive(false));
|
||
}
|
||
else
|
||
{
|
||
infoPanel.SetActive(false);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Yarn Commands
|
||
|
||
[YarnCommand("adjust_power")]
|
||
public static IEnumerator AdjustPower(float targetPower)
|
||
{
|
||
var ctrl = FixSystemCenter.SystemDic.Get<KnobController>();
|
||
if (ctrl == null) ctrl = FindObjectOfType<KnobController>();
|
||
if (ctrl != null)
|
||
{
|
||
float targetAngle = (1f - Mathf.Clamp01(targetPower / 100f)) * ctrl._maxAngle;
|
||
ctrl._knobAngle = targetAngle;
|
||
ctrl.UpdateFromKnobAngle();
|
||
yield return new WaitForSeconds(1f);
|
||
}
|
||
}
|
||
|
||
[YarnCommand("reset_knob")]
|
||
public static void ResetKnob()
|
||
{
|
||
var ctrl = FixSystemCenter.SystemDic.Get<KnobController>();
|
||
if (ctrl == null) ctrl = FindObjectOfType<KnobController>();
|
||
ctrl?.Reset(clearFailCount: false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 锁定旋钮(交互禁止)。由 Yarn 在达到允许重试次数后调用。
|
||
/// 用法: <<lock_knob>>
|
||
/// </summary>
|
||
[YarnCommand("lock_knob")]
|
||
public static void LockKnob()
|
||
{
|
||
var ctrl = FixSystemCenter.SystemDic.Get<KnobController>();
|
||
if (ctrl == null) ctrl = FindObjectOfType<KnobController>();
|
||
ctrl?.Lock();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解锁旋钮(交互解禁)。
|
||
/// 用法: <<unlock_knob>>
|
||
/// </summary>
|
||
[YarnCommand("unlock_knob")]
|
||
public static void UnlockKnob()
|
||
{
|
||
var ctrl = FixSystemCenter.SystemDic.Get<KnobController>();
|
||
if (ctrl == null) ctrl = FindObjectOfType<KnobController>();
|
||
ctrl?.Unlock();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Public API
|
||
|
||
/// <summary>
|
||
/// 打开旋钮:切到「展开」图(替代旧盖子平移)。
|
||
/// </summary>
|
||
public void OpenLid(float duration = 0f)
|
||
{
|
||
_isLidOpen = true;
|
||
if (_highlightVisible)
|
||
ApplyKnobAppearance(KnobAppearance.Active, force: true);
|
||
else
|
||
ApplyKnobAppearance(KnobAppearance.Expanded, force: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 收起旋钮:切到「收起」图。
|
||
/// </summary>
|
||
public void CloseLid(float duration = 0f)
|
||
{
|
||
_isLidOpen = false;
|
||
_highlightVisible = false;
|
||
ApplyKnobAppearance(KnobAppearance.Collapsed, force: true);
|
||
}
|
||
|
||
public float GetCurrentPower() => _currentPower;
|
||
public bool HasFailed() => _hasFailed;
|
||
public float KnobValue => _knobValue;
|
||
public float DisplayIntensity => _displayIntensity;
|
||
|
||
/// <summary>
|
||
/// 重置旋钮状态。clearFailCount=true 时同时清零失败次数(如进入 processor 模式时)。
|
||
/// </summary>
|
||
public void Reset(bool clearFailCount = true)
|
||
{
|
||
ClearIdleState();
|
||
_hasFailed = false;
|
||
_hasSucceeded = false;
|
||
_isDragging = false;
|
||
_isSnappingBack = false;
|
||
_snapbackLocked = false;
|
||
_knobAngle = 0f;
|
||
_tremorAmount = 0f;
|
||
_tremorTarget = 0f;
|
||
_resistancePulse = 0f;
|
||
_overshootIntensity = 0f;
|
||
_isLocked = false;
|
||
if (clearFailCount)
|
||
{
|
||
_failCount = 0;
|
||
CloseLid();
|
||
}
|
||
else if (_isLidOpen)
|
||
{
|
||
SetInteractionHighlightVisible(_highlightVisible);
|
||
}
|
||
|
||
UpdateFromKnobAngle();
|
||
|
||
HideInfoPanel();
|
||
|
||
Debug.Log("[KnobController] Reset");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 交互禁止:锁定旋钮,禁止用户操作。由 Yarn 在达到允许重试次数后调用。
|
||
/// </summary>
|
||
public void Lock()
|
||
{
|
||
_isLocked = true;
|
||
Debug.Log("[KnobController] Locked");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 交互解禁:解锁旋钮。通常由 Reset 内部调用,也可单独调用。
|
||
/// </summary>
|
||
public void Unlock()
|
||
{
|
||
_isLocked = false;
|
||
}
|
||
|
||
public void SetMinAcceptablePower(float minPower)
|
||
{
|
||
failThreshold = 1f - Mathf.Clamp01(minPower / 100f);
|
||
}
|
||
|
||
public void ForceSuccess()
|
||
{
|
||
_hasSucceeded = true;
|
||
onPowerAdjustmentSuccess?.Invoke(_currentPower);
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
if (_ringProgressMaterial == null) return;
|
||
|
||
if (Application.isPlaying)
|
||
Destroy(_ringProgressMaterial);
|
||
else
|
||
DestroyImmediate(_ringProgressMaterial);
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
|
||
/// <summary>
|
||
/// 旋钮交互桥接:挂在 Collider 所在子物体上,将 IInteraction 转交给父级 KnobController
|
||
/// </summary>
|
||
public class KnobInteractionBridge : MonoBehaviour, IInteraction
|
||
{
|
||
private KnobController _controller;
|
||
|
||
private void Awake()
|
||
{
|
||
_controller = GetComponentInParent<KnobController>();
|
||
}
|
||
|
||
public bool IsActive => _controller?.IsActive ?? true;
|
||
public bool IsAvailable => _controller?.IsAvailable ?? true;
|
||
public GameObject GetGameObject() => gameObject;
|
||
}
|
||
}
|