diff --git a/unity/Assets/Scenes/Test.unity b/unity/Assets/Scenes/Test.unity index b616ffe..429742a 100644 --- a/unity/Assets/Scenes/Test.unity +++ b/unity/Assets/Scenes/Test.unity @@ -1673,7 +1673,7 @@ GameObject: - component: {fileID: 609157176} m_Layer: 0 m_HasEditorInfo: 1 - m_Name: GameObject + m_Name: "\u8FFD\u51FB" m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 @@ -2249,6 +2249,8 @@ MonoBehaviour: lockYAxis: 1 smoothRotation: 1 rotationSpeed: 1 + snapAngle: 0.5 + deadZone: 0.2 --- !u!114 &743379413 MonoBehaviour: m_ObjectHideFlags: 0 @@ -3949,6 +3951,38 @@ SpriteRenderer: m_WasSpriteAssigned: 1 m_MaskInteraction: 0 m_SpriteSortPoint: 0 +--- !u!1 &2024604079 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 7 + m_Component: + - component: {fileID: 2024604080} + m_Layer: 0 + m_HasEditorInfo: 1 + m_Name: GameObject (1) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &2024604080 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2024604079} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &2116328956 GameObject: m_ObjectHideFlags: 0 @@ -4488,3 +4522,4 @@ SceneRoots: - {fileID: 8914096} - {fileID: 444470370} - {fileID: 609157177} + - {fileID: 2024604080} diff --git a/unity/Assets/camera/CameraBounds.cs b/unity/Assets/camera/CameraBounds.cs new file mode 100644 index 0000000..431a498 --- /dev/null +++ b/unity/Assets/camera/CameraBounds.cs @@ -0,0 +1,122 @@ +using UnityEngine; + +namespace IndianOceanAssets.Engine2_5D +{ + /// + /// 摄像机边界(空气墙)—— 框定摄像机可移动的矩形范围。 + /// 摄像机碰到边界后停下,玩家仍可继续走。 + /// + /// 原理:在 LateUpdate(晚于 CameraFollow 的 Update)把摄像机位置钳制到 + /// 以本物体为中心、size 为尺寸的矩形内(仅 XZ 平面,Y 不限制)。 + /// [DefaultExecutionOrder(10000)] 保证本脚本在其它 LateUpdate 之后执行, + /// 即使以后把 CameraFollow 改成 LateUpdate 也能正确钳制。 + /// + /// 为什么不会抖: + /// CameraFollow 每帧用 Lerp 把摄像机往目标推(可能推出边界), + /// 本脚本随后把它钳回边界。边界是固定直线,钳制结果每帧一致 → 稳定不抖。 + /// + /// 用法: + /// 1. 场景中创建空物体,挂上此脚本 + /// 2. 把空物体放到摄像机活动范围的中心点 + /// 3. 设置 size(X=宽度,Y=深度,以本物体位置为中心) + /// 4. targetCamera 留空则自动用 Camera.main + /// 5. Scene 视图会显示青色线框,方便对齐 + /// + [DefaultExecutionOrder(10000)] + public class CameraBounds : MonoBehaviour + { + [Tooltip("要约束的摄像机(留空则使用 Camera.main)")] + [SerializeField] private Camera targetCamera; + + [Tooltip("边界尺寸(X=宽度,Y=深度,以本物体位置为中心)")] + [SerializeField] private Vector2 size = new Vector2(30f, 30f); + + [Tooltip("Scene 视图是否显示边界线框")] + [SerializeField] private bool drawGizmo = true; + + [Tooltip("线框颜色")] + [SerializeField] private Color gizmoColor = Color.cyan; + + private Transform _camTransform; + private bool _resolved; + + private void Start() + { + ResolveCamera(); + } + + private void ResolveCamera() + { + var cam = targetCamera != null ? targetCamera : Camera.main; + if (cam != null) + { + _camTransform = cam.transform; + _resolved = true; + } + else + { + _resolved = false; + } + } + + private void LateUpdate() + { + if (!_resolved || _camTransform == null) + { + ResolveCamera(); + if (_camTransform == null) return; + } + + Vector3 pos = _camTransform.position; + Vector3 center = transform.position; + float minX = center.x - size.x * 0.5f; + float maxX = center.x + size.x * 0.5f; + float minZ = center.z - size.y * 0.5f; + float maxZ = center.z + size.y * 0.5f; + + _camTransform.position = new Vector3( + Mathf.Clamp(pos.x, minX, maxX), + pos.y, + Mathf.Clamp(pos.z, minZ, maxZ) + ); + } + + /// + /// 把世界坐标钳制到边界内(仅 XZ,Y 不变)。供外部调用。 + /// + public Vector3 ClampPosition(Vector3 pos) + { + Vector3 center = transform.position; + return new Vector3( + Mathf.Clamp(pos.x, center.x - size.x * 0.5f, center.x + size.x * 0.5f), + pos.y, + Mathf.Clamp(pos.z, center.z - size.y * 0.5f, center.z + size.y * 0.5f) + ); + } + + private void OnDrawGizmos() + { + if (!drawGizmo) return; + + Vector3 c = transform.position; + float hx = size.x * 0.5f; + float hz = size.y * 0.5f; + Vector3 p1 = new Vector3(c.x - hx, c.y, c.z - hz); + Vector3 p2 = new Vector3(c.x + hx, c.y, c.z - hz); + Vector3 p3 = new Vector3(c.x + hx, c.y, c.z + hz); + Vector3 p4 = new Vector3(c.x - hx, c.y, c.z + hz); + + Gizmos.color = gizmoColor; + Gizmos.DrawLine(p1, p2); + Gizmos.DrawLine(p2, p3); + Gizmos.DrawLine(p3, p4); + Gizmos.DrawLine(p4, p1); + + // 中心十字 + Gizmos.color = new Color(gizmoColor.r, gizmoColor.g, gizmoColor.b, 0.6f); + const float k = 0.5f; + Gizmos.DrawLine(c - new Vector3(k, 0, 0), c + new Vector3(k, 0, 0)); + Gizmos.DrawLine(c - new Vector3(0, 0, k), c + new Vector3(0, 0, k)); + } + } +} diff --git a/unity/Assets/camera/CameraBounds.cs.meta b/unity/Assets/camera/CameraBounds.cs.meta new file mode 100644 index 0000000..35f0656 --- /dev/null +++ b/unity/Assets/camera/CameraBounds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: DylKsyylBn7lcA2y8oKcMWusHhL4i0Lo5tk6EFfHLt0CelquVgviXtA= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/enemy/BillboardSprite.cs b/unity/Assets/enemy/BillboardSprite.cs index 8388d6e..57b1eaa 100644 --- a/unity/Assets/enemy/BillboardSprite.cs +++ b/unity/Assets/enemy/BillboardSprite.cs @@ -3,25 +3,44 @@ using UnityEngine; namespace IndianOceanAssets.Engine2_5D { /// - /// 场景物件面向摄像机脚本(饥荒风格)。 + /// 场景物件面向摄像机脚本(饥荒风格)v2。 /// 让物体始终朝摄像机方向旋转,默认只绕 Y 轴(保持竖直不倒)。 /// + /// 抖动修复(v2): + /// 旧版平滑旋转用 Quaternion.Slerp(rot, target, speed * deltaTime), + /// 它永远无法真正到达目标(每帧只走剩余距离的几分之一),叠加 deltaTime + /// 逐帧波动,会持续产生微小旋转 → 看起来在抖动。若摄像机本身也用 Lerp + /// 跟随(CameraFollow 永不收敛),两者叠加更明显。 + /// + /// 修复要点: + /// 1. 指数平滑 t = 1 - exp(-speed * dt),帧率无关,比 speed * dt 更稳定。 + /// 2. 剩余角度 ≤ snapAngle 时直接吸附到目标,杜绝永不收敛的微旋转。 + /// 3. 已吸附后,目标方向在 deadZone 内的微小漂移(摄像机/物件微抖)一律忽略, + /// 只有方向变化超过 deadZone 才重新吸附 → 摄像机静止时物件彻底不动。 + /// /// 用法:挂到需要面向摄像机的精灵/物件上即可。 - /// 适合:树木、岩石、角色、道具等 2D 精灵在 3D 场景中的朝向修正。 /// public class BillboardSprite : MonoBehaviour { [Tooltip("只绕Y轴旋转(饥荒风格,保持竖直不倒)。关闭则完全面向摄像机。")] [SerializeField] private bool lockYAxis = true; - [Tooltip("平滑旋转(lerp过渡),关闭则瞬间转向")] + [Tooltip("平滑旋转(过渡),关闭则瞬间转向")] [SerializeField] private bool smoothRotation = false; - [Tooltip("平滑旋转速度")] + [Tooltip("平滑旋转速度(越大转得越快)")] [SerializeField] private float rotationSpeed = 8f; + [Tooltip("剩余角度小于此值时直接吸附到目标,避免永不收敛的微旋转(主要抖动来源)。")] + [SerializeField] private float snapAngle = 0.5f; + + [Tooltip("已吸附后,目标方向在此角度内的微小漂移视为噪声忽略(过滤摄像机微抖)。")] + [SerializeField] private float deadZone = 0.2f; + private Camera _mainCamera; private Transform _camTransform; + private Quaternion _convergedRot; + private bool _hasConverged; private void Start() { @@ -50,11 +69,35 @@ namespace IndianOceanAssets.Engine2_5D Quaternion targetRot = Quaternion.LookRotation(dir); - if (smoothRotation) - transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, - rotationSpeed * Time.deltaTime); - else + if (!smoothRotation) + { transform.rotation = targetRot; + _convergedRot = targetRot; + _hasConverged = true; + return; + } + + // ---- 平滑旋转 ---- + float angleToTarget = Quaternion.Angle(transform.rotation, targetRot); + + if (angleToTarget <= snapAngle) + { + // 已接近目标。仅当目标相对上次吸附转了超过 deadZone 才重新吸附, + // 否则视为噪声保持不动(摄像机静止时物件不抖) + if (!_hasConverged || Quaternion.Angle(targetRot, _convergedRot) > deadZone) + { + transform.rotation = targetRot; + _convergedRot = targetRot; + _hasConverged = true; + } + } + else + { + // 还差较远,指数平滑趋近(帧率无关,比 speed*deltaTime 更稳) + float t = 1f - Mathf.Exp(-rotationSpeed * Time.deltaTime); + transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, t); + _hasConverged = false; + } } } }