using System; using UnityEngine; using GameFramework; namespace IndianOceanAssets.Engine2_5D { /// /// 回声系统 v2 —— 描边波纹版。 /// 按 E 键从玩家位置释放扩散波纹,波纹扫过的精灵(非光源物体) /// 会显示外描边(默认白色,可自定义),维持一段时间后整体消散。 /// /// 与 v1 的区别: /// - 不再在黑暗遮罩上画光波(那样会漏出地板) /// - 改为通过全局 shader 参数控制精灵描边 shader (SpriteEchoOutline) /// - 时间模型:扩散 → 维持(sustainTime) → 消散(fadeTime) /// /// 挂载位置:玩家物体上(或跟随玩家的空物体) /// 依赖:场景中需要有用 SpriteEchoOutline shader 的描边精灵 /// (由 EchoOutlineManager 自动生成) /// public class EchoSystem : MonoBehaviour { /// /// 按 E 释放回声(摇铃)时触发,参数为释放时的玩家世界位置。 /// 敌人聆听系统等可订阅此事件。 /// public static event Action OnEchoReleased; [Header("按键")] [SerializeField] private KeyCode echoKey = KeyCode.E; [Header("扩散参数")] [Tooltip("波纹扩散速度(世界单位/秒)")] [SerializeField] private float expandSpeed = 15f; [Tooltip("最大扩散半径(到达后进入维持阶段)")] [SerializeField] private float maxRadius = 30f; [Tooltip("波纹前沿柔和宽度(精灵被扫到时的过渡宽度,越大越柔)")] [SerializeField] private float ringWidth = 1.5f; [Header("时间模型")] [Tooltip("扩散到最大半径后,描边维持的时间(秒)")] [SerializeField] private float sustainTime = 4f; [Tooltip("维持结束后,描边消散的时间(秒)")] [SerializeField] private float fadeTime = 1.5f; [Header("描边外观")] [Tooltip("描边颜色(默认白色,可自定义)")] [SerializeField] private Color outlineColor = Color.white; [Header("红圈视觉")] [Tooltip("红圈颜色")] [SerializeField] private Color ringColor = new Color(1f, 0.15f, 0.1f, 1f); [Tooltip("红圈宽度(世界单位)")] [SerializeField] private float ringVisualWidth = 2.5f; [Tooltip("红圈离地高度(避免Z-fighting)")] [SerializeField] private float ringYOffset = 0.1f; [Tooltip("红圈渐隐总时间(秒)。从释放瞬间开始线性渐隐,到 0 后隐藏。默认 4 秒")] [SerializeField] private float ringFadeTime = 4f; [Tooltip("红圈扩散速度(世界单位/秒),独立于波纹扩散速度")] [SerializeField] private float ringExpandSpeed = 15f; [Header("Shader 引用")] [Tooltip("直接拖入 Assets/Light/shaders/EchoRing.shader。\n" + "用序列化字段引用而不是 Shader.Find,确保打包时构建管线能静态识别," + "不会被 shader stripping 剔除(否则运行时会 fallback 成粉色错误着色器)。\n" + "留空时仍会尝试 Shader.Find 兜底,但需要把该 shader 加入 " + "GraphicsSettings 的 Always Included Shaders 列表。")] [SerializeField] private Shader ringShader; [Header("冷却")] [Tooltip("回声冷却时间(秒)")] [SerializeField] private float cooldown = 3f; // 红圈视觉 private GameObject _ringVisual; private Material _ringMaterial; // 全局 shader 属性 ID(通过 Shader.SetGlobalX 设置,所有描边精灵共享) private static readonly int EchoCenterID = Shader.PropertyToID("_EchoCenter"); private static readonly int EchoRadiusID = Shader.PropertyToID("_EchoRadius"); private static readonly int EchoWidthID = Shader.PropertyToID("_EchoWidth"); private static readonly int EchoOutlineIntensityID = Shader.PropertyToID("_EchoOutlineIntensity"); private static readonly int EchoOutlineColorID = Shader.PropertyToID("_EchoOutlineColor"); private static readonly int EchoRingAlphaID = Shader.PropertyToID("_EchoRingAlpha"); private static readonly int EchoRingRadiusID = Shader.PropertyToID("_EchoRingRadius"); private enum State { Idle, Expanding, Sustaining, Fading } private State _state = State.Idle; private float _radius; private float _intensity; private float _stateTimer; private Vector2 _center; private float _lastEchoTime = -999f; private float _ringAlpha = 0f; private float _ringRadius = 0f; private void Start() { // 初始化:描边关闭 Shader.SetGlobalFloat(EchoOutlineIntensityID, 0f); Shader.SetGlobalColor(EchoOutlineColorID, outlineColor); Shader.SetGlobalFloat(EchoWidthID, ringWidth); Shader.SetGlobalFloat(EchoRingAlphaID, 0f); CreateRingVisual(); } /// /// 创建红圈视觉:一个平铺在地面上的 Quad,使用 EchoRing shader。 /// shader 通过全局 _EchoCenter / _EchoRadius 自动计算圆环位置, /// 无需每帧更新 Mesh —— 只需在释放时移动 Quad 到回声中心即可。 /// private void CreateRingVisual() { _ringVisual = GameObject.CreatePrimitive(PrimitiveType.Quad); _ringVisual.name = "EchoRing_Visual"; // 移除不需要的碰撞体 var col = _ringVisual.GetComponent(); if (col != null) Destroy(col); // 平铺在地面:绕 X 轴旋转 90° _ringVisual.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // 缩放:覆盖 maxRadius * 2 的范围 float size = maxRadius * 2f; _ringVisual.transform.localScale = new Vector3(size, size, 1f); // 不挂载到玩家下——回声中心固定,不跟随玩家移动 // 创建材质(运行时) // 优先使用序列化字段引用的 shader(构建管线可静态识别,不会被剔除); // 字段为空时再尝试 Shader.Find 兜底(必须同时把 shader 加入 GraphicsSettings // 的 Always Included Shaders,否则打包后 Find 会返回 null → 粉色错误着色器)。 var shader = ringShader != null ? ringShader : Shader.Find("IndianOcean/EchoRing"); if (shader != null) { _ringMaterial = new Material(shader); _ringMaterial.SetColor("_RingColor", ringColor); _ringMaterial.SetFloat("_RingWidth", ringVisualWidth); // 确保渲染在黑暗遮罩之上(黑暗遮罩 sortingOrder=9999, renderQueue=3100) _ringMaterial.renderQueue = 3250; var renderer = _ringVisual.GetComponent(); renderer.sortingOrder = 10001; renderer.material = _ringMaterial; } else { Debug.LogError( "[EchoSystem] 找不到 IndianOcean/EchoRing shader!红圈将不会渲染。\n" + "修复方法(二选一):\n" + " 1) 把 Assets/Light/shaders/EchoRing.shader 拖到本组件的 Ring Shader 字段;\n" + " 2) Edit → Project Settings → Graphics → Always Included Shaders " + "里添加该 shader,然后重新打包。"); } _ringVisual.SetActive(false); } private void Update() { // 触发:CD 好即可释放,无需等状态机结束(StartEcho 会重置所有状态) if (Input.GetKeyDown(echoKey) && Time.time > _lastEchoTime + cooldown) { StartEcho(); } // 状态机 switch (_state) { case State.Expanding: UpdateExpanding(); break; case State.Sustaining: UpdateSustaining(); break; case State.Fading: UpdateFading(); break; } // 每帧推送全局参数 Shader.SetGlobalVector(EchoCenterID, new Vector4(_center.x, _center.y, 0f, 0f)); Shader.SetGlobalFloat(EchoRadiusID, _radius); Shader.SetGlobalFloat(EchoWidthID, ringWidth); Shader.SetGlobalFloat(EchoOutlineIntensityID, _intensity); Shader.SetGlobalColor(EchoOutlineColorID, outlineColor); // 红圈独立渐隐与扩散 UpdateRingAlpha(); UpdateRingRadius(); Shader.SetGlobalFloat(EchoRingAlphaID, _ringAlpha); Shader.SetGlobalFloat(EchoRingRadiusID, _ringRadius); } private void StartEcho() { _state = State.Expanding; _radius = 0f; _ringRadius = 0f; _intensity = 1f; _stateTimer = 0f; _lastEchoTime = Time.time; // 播放摇铃音效 if (AudioManager.Instance != null) AudioManager.Instance.PlaySFXFromResources("SFX/Bell", 0.9f); // 记录释放时刻的玩家位置(回声中心固定,不跟随移动) Vector3 p = transform.position; _center = new Vector2(p.x, p.z); // 显示红圈:移动到回声中心位置 if (_ringVisual != null) { _ringVisual.transform.position = new Vector3(p.x, ringYOffset, p.z); _ringVisual.SetActive(true); } // 通知订阅者(敌人聆听等) OnEchoReleased?.Invoke(p); } private void UpdateExpanding() { _radius += expandSpeed * Time.deltaTime; if (_radius >= maxRadius) { _radius = maxRadius; _state = State.Sustaining; _stateTimer = 0f; } } private void UpdateSustaining() { _stateTimer += Time.deltaTime; _intensity = 1f; // 维持阶段满强度 if (_stateTimer >= sustainTime) { _state = State.Fading; _stateTimer = 0f; } } private void UpdateFading() { _stateTimer += Time.deltaTime; // 消散阶段强度从 1 → 0 _intensity = Mathf.Clamp01(1f - _stateTimer / Mathf.Max(0.0001f, fadeTime)); if (_stateTimer >= fadeTime) { _state = State.Idle; _intensity = 0f; } } /// /// 红圈独立渐隐逻辑: /// 从释放瞬间开始线性渐隐(1 → 0),ringFadeTime 秒后完全消失。 /// private void UpdateRingAlpha() { if (_state == State.Idle) { _ringAlpha = 0f; return; } float elapsed = Time.time - _lastEchoTime; _ringAlpha = Mathf.Clamp01(1f - elapsed / Mathf.Max(0.001f, ringFadeTime)); // 渐隐完成后隐藏红圈物体(节省渲染开销) if (_ringAlpha <= 0f && _ringVisual != null) _ringVisual.SetActive(false); } /// /// 红圈独立扩散:按 ringExpandSpeed 扩散,封顶 maxRadius。 /// private void UpdateRingRadius() { if (_state == State.Idle) { _ringRadius = 0f; return; } _ringRadius += ringExpandSpeed * Time.deltaTime; if (_ringRadius > maxRadius) _ringRadius = maxRadius; } /// /// 外部调用:强制停止回声 /// public void StopEcho() { _state = State.Idle; _intensity = 0f; _ringAlpha = 0f; _ringRadius = 0f; Shader.SetGlobalFloat(EchoOutlineIntensityID, 0f); Shader.SetGlobalFloat(EchoRingAlphaID, 0f); if (_ringVisual != null) _ringVisual.SetActive(false); } public bool IsActive => _state != State.Idle; // ===== 编辑器可视化 ===== private void OnDrawGizmosSelected() { // 绘制回声最大范围(青色线框) Gizmos.color = new Color(0f, 1f, 1f, 0.3f); Gizmos.DrawWireSphere(transform.position, maxRadius); // 绘制当前波纹半径(如果在扩散中) if (_state != State.Idle && _radius > 0) { Gizmos.color = new Color(1f, 1f, 0f, 0.5f); Gizmos.DrawWireSphere(new Vector3(_center.x, transform.position.y, _center.y), _radius); // 绘制波纹中心 Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(new Vector3(_center.x, transform.position.y, _center.y), 0.3f); } // 绘制冷却时间指示 if (Time.time < _lastEchoTime + cooldown) { float remaining = (_lastEchoTime + cooldown) - Time.time; float cooldownRatio = remaining / cooldown; Gizmos.color = new Color(1f, 0f, 0f, 0.3f); Gizmos.DrawWireSphere(transform.position, maxRadius * cooldownRatio); } } } }