using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
///
/// 方向感知水平偏置扩展(Facing-aware Horizontal Bias)。
///
/// 持续估算跟随目标的水平移动速度,动态将相机 X 轴向玩家前进方向偏移,
/// 使玩家始终出现在画面偏后侧,前方预留更多可见视野。
///
/// 方向逻辑:
///
/// - 向右移动 → 相机向右偏置 → 玩家出现在屏幕左侧 → 右方视野增加。
/// - 向左移动 → 相机向左偏置 → 玩家出现在屏幕右侧 → 左方视野增加。
/// - 静止或速度低于阈值 → 保持当前偏置方向,不回中(避免静止时镜头漂回)。
///
///
///
/// 注意:本扩展通过直接修改 state.RawPosition.x 在 Body 阶段之后叠加偏置,
/// 不与 的 ScreenPosition.x 冲突;
/// 但偏置量会占用 Confiner 的裁剪空间,在较窄区域中建议通过
/// 设为较小值或 0 以防止越界。
///
///
/// 挂载顺序(扩展执行顺序由组件顺序决定):
///
/// - — Y 轴非对称阻尼
/// - — 下坠 Y 偏置
/// - 本扩展(CameraFacingBiasExtension) — X 轴方向偏置
/// - CinemachineConfiner3D — 最后裁剪至限位边界
///
///
[AddComponentMenu("Cinemachine/Extensions/Camera Facing Bias")]
[DisallowMultipleComponent]
public class CameraFacingBiasExtension : CinemachineExtension
{
[Tooltip("相机沿面朝方向的偏移量(世界单位)。\n" +
"玩家向右时相机向右偏此值,使玩家出现在画面左侧,前方(右侧)视野增加;反之亦然。\n" +
"0 = 禁用。推荐 1.5~2.5,视房间宽度和视口大小而定。")]
[Range(0f, 6f)]
[SerializeField] private float _facingBias = 2f;
[Tooltip("偏置方向切换时的过渡时长(秒)。越大越平滑但延迟越长。\n" +
"推荐 0.3~0.5。")]
[Min(0f)]
[SerializeField] private float _transitionTime = 0.4f;
[Tooltip("触发方向切换所需的最小水平速度(世界单位/秒)。\n" +
"低于此值时不更新目标偏置,防止静止时微小输入造成镜头反复横跳。\n" +
"推荐 1.0~2.0。")]
[Min(0f)]
[SerializeField] private float _directionThreshold = 1.5f;
[Tooltip("水平速度估算的平滑强度(越大响应越快)。推荐 6~10。")]
[Min(0.1f)]
[SerializeField] private float _velocitySmoothing = 8f;
// ── 内部状态 ──────────────────────────────────────────────────────────
private float _currentBiasX; // 当前实际偏置(世界单位,已指数平滑)
private float _targetBiasX; // 当前目标偏置
private float _estimatedVX; // 平滑后的水平速度估算(世界单位/秒)
private float _lastFollowX;
private bool _initialized;
private int _externalFacing = 0; // 0 = 未设置(回退到速度估算);+1 = 朝右;-1 = 朝左
// ── 公开 API ──────────────────────────────────────────────────────────
///
/// 供 运行时按区域写入偏置量。
/// 0 = 禁用此区域的方向偏置。
///
public float FacingBias { get => _facingBias; set => _facingBias = value; }
///
/// 由 PlayerController 在精灵翻转时直接注入面朝方向,使偏置立即响应角色转向。
/// direction: +1 = 朝右,-1 = 朝左,0 = 清除外部输入(回退到速度估算)。
/// 外部常量优先于速度估算,即使玩家缓慢行走或原地站立翻转,相机也能立即更新。
///
public void SetExternalFacing(int direction)
{
_externalFacing = Mathf.Clamp(direction, -1, 1);
}
///
/// 重置内部状态。在相机硬切(instantCut)时由 CameraStateController 调用,
/// 防止旧房间的方向偏置和速度历史带入新房间。
///
public void ResetState()
{
_initialized = false;
_estimatedVX = 0f;
// 硬切时直接吸附到当前目标值,不保留旧房间的过渡惯性
_currentBiasX = _targetBiasX;
}
// ── Cinemachine Extension ─────────────────────────────────────────────
protected override void PostPipelineStageCallback(
CinemachineVirtualCameraBase vcam,
CinemachineCore.Stage stage,
ref CameraState state,
float deltaTime)
{
// 在 Body 阶段之后(Composer 已计算出位置)叠加水平偏置
if (stage != CinemachineCore.Stage.Body) return;
// 编辑器预览 / 初始帧:重置平滑器
if (deltaTime <= 0f)
{
_initialized = false;
return;
}
// 偏置量为 0 时跳过,不修改位置
if (_facingBias <= 0f) return;
Transform follow = vcam.Follow;
if (follow == null) return;
// ── 初始化:第一帧直接吸附,不做过渡动画 ────────────────────────
if (!_initialized)
{
_lastFollowX = follow.position.x;
_initialized = true;
_currentBiasX = _targetBiasX;
return;
}
// ── 估算水平速度(帧率无关指数平滑)────────────────────────────
float rawVX = (follow.position.x - _lastFollowX) / deltaTime;
_lastFollowX = follow.position.x;
_estimatedVX = Mathf.Lerp(_estimatedVX, rawVX, 1f - Mathf.Exp(-deltaTime * _velocitySmoothing));
// ── 方向判定(带死区,静止时保持当前目标方向)──────────────────
// 优先使用外部注入朝向(PlayerController.OnFlip 传入);
// 未设置时回退到速度估算(却保留静止不回中行为)。
if (_externalFacing != 0)
{
_targetBiasX = _externalFacing * _facingBias;
}
else
{
if (_estimatedVX > _directionThreshold)
_targetBiasX = +_facingBias; // 向右 → 相机右偏 → 玩家在左,前方(右侧)视野增
else if (_estimatedVX < -_directionThreshold)
_targetBiasX = -_facingBias; // 向左 → 相机左偏 → 玩家在右,前方(左侧)视野增
// else: 速度不足,保持 _targetBiasX 不变(不回中)
}
// ── 指数平滑过渡 ─────────────────────────────────────────────────
float t = _transitionTime > 0f
? 1f - Mathf.Exp(-deltaTime / _transitionTime)
: 1f;
_currentBiasX = Mathf.Lerp(_currentBiasX, _targetBiasX, t);
// ── 叠加到 Composer 已计算的位置上 ──────────────────────────────
// 注意:此偏置在 CinemachineConfiner3D 之前施加,
// Confiner 会在之后将结果裁剪回限位边界内。
var pos = state.RawPosition;
pos.x += _currentBiasX;
state.RawPosition = pos;
}
}
}