路线阶段:Unity 入门实战第 10 章。
本章目标:把输入系统从“设备绑定代码”升级为“意图驱动接口”,为后续 WebGL 和移动端铺路。
学习目标
完成本章后,你应该能做到:
- 定义统一输入意图模型(移动、交互、施法、菜单)。
- 将键鼠、手柄、触屏输入适配到同一套接口。
- 解决 WebGL 焦点丢失、鼠标锁定、按键重复触发问题。
- 让战斗与交互逻辑不再依赖具体输入设备。
常见问题
- PC 可玩,切 WebGL 后按键响应异常。
- 触控方案和键鼠方案各写一套业务逻辑。
- UI 打开时仍然吞掉战斗输入或反向串线。
- 回放与自动化测试无法复现输入路径。
输入意图模型
public struct InputIntent
{
public float MoveX;
public float MoveY;
public bool Sprint;
public bool Interact;
public bool LockTarget;
public bool CastQ;
public bool CastW;
public bool CastE;
public bool CastR;
public bool Pause;
public bool OpenInventory;
}
输入采集接口
public interface IInputProvider
{
InputIntent Sample();
bool IsAvailable();
}
键鼠实现
public sealed class KeyboardMouseInputProvider : IInputProvider
{
public bool IsAvailable()
{
return true;
}
public InputIntent Sample()
{
InputIntent intent;
intent.MoveX = Input.GetAxisRaw("Horizontal");
intent.MoveY = Input.GetAxisRaw("Vertical");
var mag = Mathf.Sqrt(intent.MoveX * intent.MoveX + intent.MoveY * intent.MoveY);
if (mag > 1f)
{
intent.MoveX /= mag;
intent.MoveY /= mag;
}
intent.Sprint = Input.GetKey(KeyCode.LeftShift);
intent.Interact = Input.GetKeyDown(KeyCode.F);
intent.LockTarget = Input.GetMouseButtonDown(1);
intent.CastQ = Input.GetKeyDown(KeyCode.Q);
intent.CastW = Input.GetKeyDown(KeyCode.W);
intent.CastE = Input.GetKeyDown(KeyCode.E);
intent.CastR = Input.GetKeyDown(KeyCode.R);
intent.Pause = Input.GetKeyDown(KeyCode.Escape);
intent.OpenInventory = Input.GetKeyDown(KeyCode.B);
return intent;
}
}
触控实现(虚拟摇杆)
public interface IVirtualJoystick
{
Vector2 Value { get; }
}
public interface IVirtualButton
{
bool DownThisFrame { get; }
bool Pressing { get; }
}
public sealed class TouchInputProvider : IInputProvider
{
private readonly IVirtualJoystick _move;
private readonly IVirtualButton _sprint;
private readonly IVirtualButton _interact;
private readonly IVirtualButton _lock;
private readonly IVirtualButton _q;
private readonly IVirtualButton _w;
private readonly IVirtualButton _e;
private readonly IVirtualButton _r;
private readonly IVirtualButton _pause;
private readonly IVirtualButton _bag;
public TouchInputProvider(
IVirtualJoystick move,
IVirtualButton sprint,
IVirtualButton interact,
IVirtualButton lockTarget,
IVirtualButton q,
IVirtualButton w,
IVirtualButton e,
IVirtualButton r,
IVirtualButton pause,
IVirtualButton bag)
{
_move = move;
_sprint = sprint;
_interact = interact;
_lock = lockTarget;
_q = q;
_w = w;
_e = e;
_r = r;
_pause = pause;
_bag = bag;
}
public bool IsAvailable()
{
return Input.touchSupported;
}
public InputIntent Sample()
{
var axis = _move.Value;
InputIntent intent;
intent.MoveX = axis.x;
intent.MoveY = axis.y;
intent.Sprint = _sprint.Pressing;
intent.Interact = _interact.DownThisFrame;
intent.LockTarget = _lock.DownThisFrame;
intent.CastQ = _q.DownThisFrame;
intent.CastW = _w.DownThisFrame;
intent.CastE = _e.DownThisFrame;
intent.CastR = _r.DownThisFrame;
intent.Pause = _pause.DownThisFrame;
intent.OpenInventory = _bag.DownThisFrame;
return intent;
}
}
输入路由器
public sealed class InputRouter : IUpdatable
{
public int Order { get { return 100; } }
private readonly List<IInputProvider> _providers;
private readonly EventBus _eventBus;
private InputIntent _latest;
public InputRouter(List<IInputProvider> providers, EventBus eventBus)
{
_providers = providers;
_eventBus = eventBus;
}
public InputIntent LatestIntent
{
get { return _latest; }
}
public void Tick(float dt, float unscaledDt)
{
for (var i = 0; i < _providers.Count; i++)
{
var p = _providers[i];
if (!p.IsAvailable())
{
continue;
}
_latest = p.Sample();
_eventBus.Publish("InputIntentUpdated", _latest);
return;
}
_latest = default(InputIntent);
}
}
WebGL 专项处理
1. 焦点状态
public sealed class WebGlFocusGuard : MonoBehaviour
{
public bool HasFocus { get; private set; }
private void OnApplicationFocus(bool focus)
{
HasFocus = focus;
FoundationLog.Info("Input", "focus=" + focus);
}
private void OnApplicationPause(bool pause)
{
HasFocus = !pause;
}
}
当 HasFocus=false 时,输入路由器可直接输出空意图,避免后台误触。
2. 鼠标锁定策略
public sealed class CursorLockService
{
public void SetBattleMode(bool active)
{
if (active)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
else
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
}
}
与战斗系统对接
PlayerController读取LatestIntent.MoveX/MoveY。SkillCastController读取CastQ/W/E/R和LockTarget。InteractionController读取Interact。UIRootManager读取Pause/OpenInventory。
所有业务只读 InputIntent,不再直接读 Input 静态 API。
自动化与回放收益
输入抽象后可直接构造伪输入:
public sealed class ReplayInputProvider : IInputProvider
{
private readonly Queue<InputIntent> _queue;
public ReplayInputProvider(Queue<InputIntent> queue)
{
_queue = queue;
}
public bool IsAvailable() { return true; }
public InputIntent Sample()
{
if (_queue.Count == 0) return default(InputIntent);
return _queue.Dequeue();
}
}
验收清单
- 键鼠、触控都能控制同一套角色与战斗逻辑。
- WebGL 切换焦点后恢复输入正常,不出现粘键。
- UI 打开时战斗输入可按策略屏蔽。
- 回放输入可稳定复现操作序列。
常见坑
坑 1:InputIntent 混入设备细节
例如包含 MouseDeltaX 直接喂业务,会破坏抽象边界。
坑 2:多个输入源同时生效
需明确优先级,避免键鼠与触控冲突。
坑 3:UI 层直接消费原始 Input
会绕过输入路由,造成行为不一致。
本月作业
实现“输入录制回放联动”:
- 每帧记录
InputIntent到回放数据。 - 支持用
ReplayInputProvider驱动完整战斗。 - 对比实时输入与回放输入导致的关键指标差异。
下一章进入 Unity 入门实战 11:关卡流程与状态机(菜单、加载、战斗、结算一体化切换)。