做俯视角射击游戏的开发者,几乎都会在同一个地方卡住:美术素材导进来了,角色却糊成一团或者被背景挡住;输入代码也写了,角色要么不动,要么一松手就滑出老远。这两个问题看起来一个属于美术管线,一个属于代码逻辑,实际上它们都指向同一件事——你有没有把"精灵的坐标语义"和"输入的坐标语义"对齐。
精灵配置决定的是"看起来对不对"。Pixels Per Unit、Pivot、Sorting Layer、Transparency Sort Axis 这几个参数一旦错位,后面写再多代码也救不回来:角色永远站在地面下面,或者走到墙前面却显示在墙后面。
New Input System 决定的是"操作手感对不对"。它把输入从"每帧轮询一个全局静态类"改成了"事件驱动的动作与绑定",代价是你必须显式管理 Action Map 的启用与释放,否则角色不动、事件重复触发、手柄和键鼠互相打架这类问题会反复出现。
这篇文章沿着一条真实可跑的路径走完:从工程创建和包依赖,到角色精灵的完整导入配置,再到用 Input Actions 定义移动、瞄准、射击三组动作,最后给出可以直接复制的 PlayerController、AimController、WeaponFire 三段代码,以及一套按现象反查的排查表。读完你应该能得到一个能用 WASD 或左摇杆移动、用鼠标或右摇杆瞄准、按住开火键连续射击的俯视角角色原型,并且知道每一处配置为什么必须那样设。
1. 这篇文章真正要解决的问题
先明确边界。这篇文章不讨论关卡设计、敌人 AI、弹道物理、存档系统,只处理俯视角射击游戏最底层的两件事:角色在屏幕上如何被正确绘制,以及角色如何被正确驱动。听上去很基础,但恰恰是这一步决定了后面所有系统的开发效率。一个 Pivot 设在中心点的角色,做旋转瞄准时会绕着身体中心转,枪口位置会漂移;一个 Sorting Layer 没分好的场景,你后面每加一个特效都要重新调一遍层级。这些成本会在项目中期集中爆发。
很多教程把这两块拆开讲,导致读者拿到的是"能跑的移动代码"和"能看的静态图",却拼不出一个能玩的原型。真正的难点在交界处:鼠标屏幕坐标怎么转换成世界坐标,正交相机下这个转换和透视相机有什么区别,手柄摇杆的输入本身就是一个方向向量,不需要转换,这两种输入源如何在同一个 Aim 动作里共存而不互相覆盖。
还有一个容易被低估的问题:New Input System 与传统Input.GetAxis的思维模型完全不同。旧系统是"我每帧问一次现在按了什么",新系统是"按键状态变化时通知我"。前者容错高,写错了最多是不跟手;后者写错了直接没反应,因为回调根本没被注册上,或者 Action Map 压根没 Enable。新手最容易在这里怀疑人生,反复检查绑定路径却忽略了 Enable 这一步。
所以这篇文章的目标读者很明确:正在做或准备做 2D 俯视角项目、已经能写基础 C# 脚本、但在精灵管线和输入系统上还没形成稳定套路的开发者。如果你只想快速验证一个想法,下面这套流程可以直接照抄;如果你在做正式项目,第 9 节讲的输入抽象层和素材管理规范更值得看。
2. 核心概念与原理
2.1 精灵渲染的四个关键参数
Sprite 在 Unity 里不是一个"图片",而是一份带有坐标语义的渲染数据。同一张 PNG,导入参数不同,产生的结果可能完全相反。
Pixels Per Unit(PPU)定义了"多少个像素等于一个世界单位"。Unity 的物理系统默认认为 1 单位 = 1 米,Rigidbody2D 的碰撞体尺寸、移动速度、相机 Size 全都基于这个单位。如果你用 16×16 的像素素材但 PPU 设成 100,那么这个精灵在世界里只有 0.16 个单位宽,而你的移动速度可能是 5 单位每秒——角色会像子弹一样飞出去。反过来,PPU 设得太小,角色会巨大到塞满屏幕。
Pivot 是精灵的旋转与定位原点。它对俯视角射击的影响比想象中大:武器的挂点、朝向计算、受击判定通常都以 Pivot 为基准。角色类精灵一般用 Center,但如果你的角色需要贴地移动、做 Y 轴排序,Bottom Center 会更自然。关键在于全项目统一,不要一部分用 Center 一部分用 Bottom Center,否则后面写排序逻辑时要写两套。
Sorting Layer 与 Order in Layer 决定绘制顺序。俯视角游戏的层级需求是分层的:地面、地面装饰、阴影、角色、飞行物、特效、UI。建议在 Project Settings 的 Tags and Layers 里提前把这些 Sorting Layer 建好,而不是靠 Order in Layer 手动填数字。数字填法在团队协作里几乎必然冲突。
Transparency Sort Mode 是最容易被忽略的一项。俯视角游戏需要"角色 A 在角色 B 上方时,A 显示在 B 后面"。Unity 默认按 Sorting Layer 和 Order in Layer 排序,同层内不按 Y 坐标。解决办法是在 Project Settings > Graphics 里把 Transparency Sort Mode 设为 Custom Axis,Sort Axis 设为 (0, 1, 0)。这样同层精灵会按世界 Y 坐标排序,Y 越小(越靠下)越后绘制,视觉上就越靠前。
| 参数 | 作用 | 俯视角射击的推荐值 | 设错的后果 |
|---|---|---|---|
| Pixels Per Unit | 像素与世界单位的换算比例 | 与素材原始尺寸一致,如 16、32、100 | 角色过大或过小,速度手感全乱 |
| Pivot | 旋转与定位基准点 | 角色用 Center,贴地物件用 Bottom Center | 瞄准时枪口漂移,排序错位 |
| Filter Mode | 纹理采样方式 | 像素风用 Point (no filter) | 边缘发虚、颜色渗色 |
| Compression | 纹理压缩 | 像素风用 None | 出现色块噪点 |
| Sorting Layer | 绘制层级分组 | 按功能分层,不用数字硬填 | 角色被背景遮挡 |
| Transparency Sort Axis | 同层内排序依据 | Custom Axis (0, 1, 0) | 角色前后关系错乱 |
2.2 新旧输入系统的分工差异
New Input System 的核心抽象有三层:Action(做什么)、Binding(用什么触发)、Action Map(一组动作的集合)。Action 描述意图,比如 "Move" 或 "Fire";Binding 描述物理来源,比如键盘 W 键或手柄左摇杆;Action Map 描述上下文,比如"游戏中"和"菜单中"是两张不同的 Map,同一时间只启用其中一张。
这个分层带来的直接好处是设备无关。你不需要写if (Input.GetKey(KeyCode.W) || Input.GetAxis("Horizontal") > 0),只需要给 Move 动作加两条 Binding:一条 2D Vector 复合绑定映射 WASD,一条直接指向<Gamepad>/leftStick。运行时系统自动处理设备切换,玩家中途插上手柄也能立刻响应。
代价是生命周期必须自己管。Action 需要 Enable 才能接收输入,需要在对象销毁时 Dispose 释放,需要在组件禁用时取消订阅。漏掉任何一步都会出现"有时候能动有时候不能动"的诡异现象。
另一个必须提前决定的事是输入处理模式。Project Settings > Player > Other Settings 里的 Active Input Handling 有三个选项:Old、New、Both。选 Both 会让两套系统同时工作,代价是性能开销和潜在的重复触发。新项目建议直接选 New,除非你依赖的第三方插件还没适配。
3. 环境准备与前置条件
3.1 版本与包依赖
Unity 版本方面,2021.3 LTS 及以上都能完整支持本文的所有内容,Unity 6 也适用。需要特别注意的是,Unity 6 起 Rigidbody2D 的velocity属性已更名为linearVelocity,旧属性标记为过时。本文代码使用velocity并会在对应位置标注,如果你的项目在 Unity 6 上,请自行替换。
包依赖通过 Package Manager 管理。新建项目时建议直接选 2D 模板,它会预装大部分 2D 相关包。如果不是 2D 模板,需要手动确认以下包存在。
// 文件路径:Packages/manifest.json(节选,版本号请勿直接复制) { "dependencies": { "com.unity.inputsystem": "1.x.x", "com.unity.2d.sprite": "1.x.x", "com.unity.2d.tilemap": "1.x.x", "com.unity.2d.pixel-perfect": "1.x.x" } }上面用1.x.x占位是有意为之,不同 Unity 版本对应的包版本差异较大,写死一个具体数字反而会让人踩坑。正确做法是打开 Package Manager,在 Unity Registry 里搜索Input System和2D Pixel Perfect,点 Install 让编辑器自己选择兼容版本。安装 Input System 时 Unity 会弹窗询问是否启用新输入后端,选 Yes,编辑器会自动重启。
3.2 输入后端切换
如果错过了弹窗,手动改:项目设置切到 Project Settings > Player > Other Settings > Active Input Handling,改为Input System Package (New)。改完编辑器会提示重启,必须重启才生效。
这一步没做的话,最典型的现象是编译报错The type or namespace name 'InputSystem' could not be found,或者代码编译通过但运行时完全不响应。前者是包没装,后者是后端没切。
3.3 项目基础设置
在动手写代码前,先花几分钟把项目级设置做掉,能省掉后面大量返工。
第一项是刚才提到的 Transparency Sort Mode。Project Settings > Graphics > Transparency Sort Mode 设为 Custom Axis,Transparency Sort Axis 设为 X=0, Y=1, Z=0。
第二项是 Sorting Layer 规划。Project Settings > Tags and Layers,在 Sorting Layers 列表里按顺序添加:Background、Floor、FloorDecor、Shadow、Actors、Projectile、FX、UI。列表顺序即绘制顺序,越靠下越靠前。
第三项是物理层规划。俯视角射击游戏至少需要Player、Enemy、PlayerBullet、EnemyBullet、Wall这几个 Layer,并在 Physics 2D 设置里配置碰撞矩阵,让玩家子弹不与玩家碰撞、敌方子弹不与敌人碰撞。这件事在加第一个子弹预制体之前做,比事后补要省事得多。
4. 角色精灵配置完整流程
4.1 素材导入与关键设置
把角色贴图拖进 Assets 目录,选中它,在 Inspector 里做以下设置。这些选项决定了图片如何被 Unity 理解。
Texture Type 选Sprite (2D and UI),这是最基础的一步。Sprite Mode 根据素材形态选择:单帧角色用 Single,序列帧角色用 Multiple 并在 Sprite Editor 里切分。Pixels Per Unit 填素材的原始像素宽度对应的设计值,比如 32×32 的角色素材填 32,这样角色在世界里占 1 个单位宽。
Filter Mode 对像素风素材必须设为Point (no filter),否则拉伸后会模糊。Compression 设为None,避免压缩噪点破坏像素边缘。勾选Alpha Is Transparency让边缘抗锯齿正确工作。Max Size 建议设为素材尺寸的两倍以上,给后续放大留余量。
4.2 Sprite Renderer 与层级配置
在场景里创建空物体命名为 Player,添加 Sprite Renderer 组件,把切好的 Sprite 拖进 Sprite 字段。此时检查三个地方。
Sprite Renderer 的 Sorting Layer 设为Actors,Order in Layer 设为 0。如果角色有多个部件(身体、武器、阴影),阴影的 Sorting Layer 设为Shadow且 Order in Layer 为 -1,确保它永远在角色下面。
Transform 的 Scale 保持 (1,1,1)。如果发现角色大小不合适,优先回去改 PPU,而不是改 Scale。改 Scale 会让所有基于单位的计算(移动速度、碰撞体尺寸、射线长度)都失去意义,这是新手最常见的坏习惯。
添加 Rigidbody2D,设置 Body Type 为Dynamic,Gravity Scale 设为 0(俯视角没有重力),Collision Detection 设为Continuous(避免高速移动穿墙),Interpolate 设为Interpolate(消除视觉抖动),Freeze Rotation 的 Z 轴勾上(防止物理碰撞把角色转翻)。再添加一个 CapsuleCollider2D 作为受击体,尺寸调到贴合角色脚部区域而不是整个身体——俯视角游戏里判定用"脚下的小圆"比用整个身体更符合直觉。
4.3 像素完美相机配置
如果项目是像素风,相机配置不能随便。给 Main Camera 添加 Pixel Perfect Camera 组件,设置 Assets PPU 与素材 PPU 一致,Reference Resolution 设为设计分辨率(如 320×180 或 640×360),勾选Upscale Render Texture和Pixel Snapping。这样能保证像素在屏幕上严格对齐网格,移动时不会出现像素抖动。
如果不是像素风,用普通正交相机即可。Camera 组件的 Projection 设为Orthographic,Size 决定视野高度的一半。俯视角射击常见的 Size 在 5 到 10 之间,取决于你想要的视野范围。
5. New Input System 输入资产配置
5.1 Action Map 设计
在 Assets 下右键 Create > Input Actions,命名为PlayerControls。双击打开编辑窗口,默认会有一个 Action Map,重命名为Player。
在这个 Map 下创建三组 Action。第一组Move,Action Type 设为Value,Control Type 设为Vector2。第二组Aim,同样Value+Vector2。第三组Fire,Action Type 设为Button。
Move 的绑定用 Composite 里的2D Vector,展开后有 Up、Down、Left、Right 四个槽位,分别填<Keyboard>/w、<Keyboard>/s、<Keyboard>/a、<Keyboard>/d。再点加号添加一条普通 Binding,路径填<Gamepad>/leftStick。这样键鼠和手柄就都覆盖了。
Aim 的绑定分两类。手柄用<Gamepad>/rightStick,它本身就是 Vector2,直接绑定即可。鼠标的屏幕位置需要用<Mouse>/position,它的 Control Type 也是 Vector2,但语义完全不同——它是屏幕像素坐标,不是方向向量。这两条 Binding 都挂在 Aim 动作下,代码里需要区分当前是哪个设备在驱动。
Fire 绑定<Mouse>/leftButton和<Gamepad>/rightTrigger。如果想让扳机也有键程反馈,可以给扳机加一个PressInteraction 并设置触发阈值。
5.2 生成 C# 类
在 PlayerControls 资产的 Inspector 里勾选Generate C# Class,设置类名PlayerControls和保存路径,点 Apply。Unity 会生成一个强类型包装类,包含Player这个 Action Map 的访问器和每个 Action 的事件。
// 文件路径:Assets/Input/PlayerControls.inputactions(结构示意,GUID 已省略) { "name": "PlayerControls", "maps": [ { "name": "Player", "actions": [ { "name": "Move", "type": "Value", "expectedControlType": "Vector2", "initialStateCheck": true }, { "name": "Aim", "type": "Value", "expectedControlType": "Vector2", "initialStateCheck": true }, { "name": "Fire", "type": "Button", "expectedControlType": "Button", "initialStateCheck": true } ], "bindings": [ { "name": "WASD", "path": "2DVector", "action": "Move", "isComposite": true }, { "name": "up", "path": "<Keyboard>/w", "action": "Move", "isPartOfComposite": true }, { "name": "down", "path": "<Keyboard>/s", "action": "Move", "isPartOfComposite": true }, { "name": "left", "path": "<Keyboard>/a", "action": "Move", "isPartOfComposite": true }, { "name": "right", "path": "<Keyboard>/d", "action": "Move", "isPartOfComposite": true }, { "name": "", "path": "<Gamepad>/leftStick", "action": "Move" }, { "name": "", "path": "<Mouse>/position", "action": "Aim" }, { "name": "", "path": "<Gamepad>/rightStick", "action": "Aim" }, { "name": "", "path": "<Mouse>/leftButton", "action": "Fire" }, { "name": "", "path": "<Gamepad>/rightTrigger", "action": "Fire" } ] } ], "controlSchemes": [] }上面这份 JSON 是结构示意,方便你理解 .inputactions 文件本质上就是一个描述动作和绑定的序列化资产,实际文件里还包含大量 GUID 字段。不要手动复制这份内容覆盖你的资产文件,正确做法是在编辑窗口里点选配置,让 Unity 自己序列化。
initialStateCheck这个字段值得单独说一句。它设为 true 时,Action 在 Enable 的瞬间会主动读一次当前设备状态,保证角色在"已经按住方向键的情况下切回游戏"也能立刻响应。设为 false 则必须等下一次按键变化才触发,容易造成"按键没反应"的错觉。
6. 完整代码实现
6.1 PlayerController:基于 Rigidbody2D 的移动
// 文件路径:Assets/Scripts/Player/PlayerController.cs using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] public class PlayerController : MonoBehaviour { [Header("移动参数")] [SerializeField] private float moveSpeed = 5f; [SerializeField] private float acceleration = 60f; private Rigidbody2D rb; private Vector2 moveInput; private PlayerControls controls; private void Awake() { rb = GetComponent<Rigidbody2D>(); rb.gravityScale = 0f; rb.freezeRotation = true; controls = new PlayerControls(); } private void OnEnable() { controls.Player.Enable(); controls.Player.Move.performed += OnMove; controls.Player.Move.canceled += OnMove; } private void OnDisable() { controls.Player.Move.performed -= OnMove; controls.Player.Move.canceled -= OnMove; controls.Player.Disable(); } private void OnDestroy() { controls.Dispose(); } private void OnMove(UnityEngine.InputSystem.InputAction.CallbackContext ctx) { moveInput = ctx.ReadValue<Vector2>(); } private void FixedUpdate() { Vector2 targetVelocity = moveInput * moveSpeed; // Unity 6 及以上请将 velocity 替换为 linearVelocity rb.velocity = Vector2.MoveTowards( rb.velocity, targetVelocity, acceleration * Time.fixedDeltaTime); } }这段代码有几个设计决定需要解释。移动放在FixedUpdate而不是Update,因为物理引擎按固定时间步推进,在Update里改速度会导致不同帧率下移动距离不一致。加速度用Vector2.MoveTowards实现而不是直接赋值,是为了让角色起步和停止有一点过渡,手感更接近实体。如果直接rb.velocity = moveInput * moveSpeed,角色会瞬间满速,俯视角射击里会显得很僵。
输入读取放在performed和canceled两个回调里,意味着松手时会读到Vector2.zero,这一点必须确认。如果只订阅performed,松手后moveInput会保持最后的非零值,角色会一直朝一个方向走。这是 New Input System 里最高频的 bug 之一。
OnDisable里先取消订阅再 Disable,顺序不能反。反过来写虽然大多数情况也能工作,但一旦 Action 在 Disable 过程中触发回调,就会访问到正在失效的对象。
6.2 AimController:鼠标与手柄的瞄准统一
// 文件路径:Assets/Scripts/Player/AimController.cs using UnityEngine; using UnityEngine.InputSystem; public class AimController : MonoBehaviour { [Header("引用")] [SerializeField] private Transform weaponPivot; [SerializeField] private Camera mainCamera; [Header("参数")] [SerializeField] private float aimSmoothing = 25f; [SerializeField] private float gamepadAimDeadzone = 0.2f; private Vector2 aimRaw; private bool usingGamepad; private void Reset() { mainCamera = Camera.main; } private void Awake() { if (mainCamera == null) mainCamera = Camera.main; } public void OnAim(InputAction.CallbackContext ctx) { aimRaw = ctx.ReadValue<Vector2>(); usingGamepad = ctx.control != null && ctx.control.device is Gamepad; } private void Update() { Vector2 aimDir; if (usingGamepad) { if (aimRaw.magnitude < gamepadAimDeadzone) return; aimDir = aimRaw.normalized; } else { Vector3 screenPoint = new Vector3(aimRaw.x, aimRaw.y, 0f); Vector2 worldPoint = mainCamera.ScreenToWorldPoint(screenPoint); aimDir = worldPoint - (Vector2)transform.position; } if (aimDir.sqrMagnitude < 0.0001f) return; float angle = Mathf.Atan2(aimDir.y, aimDir.x) * Mathf.Rad2Deg - 90f; Quaternion target = Quaternion.Euler(0f, 0f, angle); weaponPivot.rotation = Quaternion.Slerp( weaponPivot.rotation, target, aimSmoothing * Time.deltaTime); } }这里最需要理解的是两类输入的语义差异。手柄右摇杆输出的Vector2本身就是方向,长度代表推动幅度,直接归一化就能用。鼠标位置输出的Vector2是屏幕像素坐标,必须先经ScreenToWorldPoint转换到世界空间,再和角色位置做向量减法。
ScreenToWorldPoint的第三个参数在正交相机下不影响 X 和 Y 结果,在透视相机下则决定投影平面深度,必须填相机到目标平面的距离,否则算出来的点会错位。很多教程在正交相机下直接传 0 恰好能跑通,换成透视相机就废了,原因就在这里。
- 90f的角度修正来自精灵朝向约定。如果你的武器精灵画的是枪口朝右,就不需要减 90;画的是枪口朝上,减 90 才能让transform.up与枪口方向一致。这一点没有标准答案,取决于你的素材,配错了表现为"角色总是侧着瞄准"。
ctx.control.device is Gamepad是设备判别的关键。它保证了玩家用鼠标时走屏幕坐标分支,用手柄时走方向分支,两种设备同时在场景里也不会互相干扰。
6.3 WeaponFire:基于事件的连续射击
// 文件路径:Assets/Scripts/Player/WeaponFire.cs using UnityEngine; using UnityEngine.InputSystem; public class WeaponFire : MonoBehaviour { [Header("引用")] [SerializeField] private Transform muzzle; [SerializeField] private GameObject bulletPrefab; [Header("参数")] [SerializeField] private float bulletSpeed = 14f; [SerializeField] private float fireInterval = 0.12f; [SerializeField] private float bulletLifeTime = 3f; private bool isFiring; private float nextFireTime; public void OnFire(InputAction.CallbackContext ctx) { if (ctx.performed) isFiring = true; else if (ctx.canceled) isFiring = false; } private void Update() { if (!isFiring || bulletPrefab == null || muzzle == null) return; if (Time.time < nextFireTime) return; nextFireTime = Time.time + fireInterval; SpawnBullet(); } private void SpawnBullet() { GameObject bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation); Rigidbody2D rb = bullet.GetComponent<Rigidbody2D>(); if (rb != null) { // Unity 6 及以上请将 velocity 替换为 linearVelocity rb.velocity = muzzle.up * bulletSpeed; } Destroy(bullet, bulletLifeTime); } }射击频率用Time.time累加而不是用协程,是因为协程在对象禁用时会中断,恢复后节奏会乱。用时间戳判断更稳定,也方便后面接射速加成、后坐力、弹药系统。
muzzle.up作为子弹方向,前提是 muzzle 的本地 Y 轴指向枪口朝向。因为 6.2 里我们让weaponPivot.rotation按瞄准角度旋转,muzzle 作为它的子物体自然跟着转,muzzle.up也就始终指向瞄准方向。这个链条一旦理顺,后面加枪口火焰、弹壳抛出都是同样的思路。
子弹预制体需要自带 Rigidbody2D(Body Type 设为Kinematic或Dynamic且 Gravity Scale 为 0)、BoxCollider2D 或 CircleCollider2D,以及一个Destroy或者碰撞回调逻辑。别忘了把子弹的 Layer 设为PlayerBullet,并在 Physics 2D 碰撞矩阵里取消它与Player的勾选。
6.4 用 PlayerInput 组件免代码接线
如果不想生成 C# 类,也可以用 PlayerInput 组件走另一条路。给 Player 添加 PlayerInput 组件,Actions 字段拖入 PlayerControls 资产,Default Map 选Player,Behavior 选Invoke Unity Events。然后逐个展开事件槽,把对象拖进去,选择对应的公开方法。
这条路径的好处是零代码接入,缺点是重命名方法时事件会断,且不方便做输入重映射。项目规模小、原型验证阶段可以用;正式项目更推荐 6.1 到 6.3 那种强类型绑定方式,编译器能帮你抓错。
还有第三种方案:完全用代码构建 InputAction,不依赖任何资产文件。适合输入很简单、或者需要动态生成的场景。
// 文件路径:Assets/Scripts/Player/SimpleInputProvider.cs using UnityEngine; using UnityEngine.InputSystem; public class SimpleInputProvider : MonoBehaviour { public Vector2 MoveInput { get; private set; } private InputAction moveAction; private void Awake() { moveAction = new InputAction( name: "Move", type: InputActionType.Value, expectedControlType: "Vector2"); moveAction.AddCompositeBinding("2DVector") .With("Up", "<Keyboard>/w") .With("Down", "<Keyboard>/s") .With("Left", "<Keyboard>/a") .With("Right", "<Keyboard>/d"); moveAction.AddBinding("<Gamepad>/leftStick"); moveAction.performed += ctx => MoveInput = ctx.ReadValue<Vector2>(); moveAction.canceled += ctx => MoveInput = Vector2.zero; } private void OnEnable() => moveAction.Enable(); private void OnDisable() => moveAction.Disable(); private void OnDestroy() => moveAction.Dispose(); private void Update() { Debug.Log($"当前输入: {MoveInput}"); } }这条路径要注意 Action 必须手动Dispose,否则在编辑器里反复进出播放模式会积累未被释放的 Action,久了会出现输入延迟或者重复触发。
7. 运行验证与效果检查
把三段脚本挂到 Player 上,注意挂载关系:游戏物体 Player 挂 PlayerController 和 AimController;weaponPivot 是 Player 的子物体,挂 WeaponFire 并把它自身的 Transform 拖给 muzzle 字段;AimController 的 weaponPivot 字段拖入同一个 transform。
回到 PlayerControls 资产,确认已经勾选 Generate C# Class 并 Apply,此时PlayerControls类才存在,否则 6.1 的代码会编译报错。
点播放,按下面顺序检查。第一,按 WASD,角色应当平滑加速到设定速度,松手后平滑停下,不会滑行也不会瞬停。第二,移动鼠标,武器应当跟随光标方向旋转,转到角色左侧时光标方向也应正确。第三,按住鼠标左键,应当按固定间隔连续生成子弹,子弹沿枪口方向飞出并在 3 秒后销毁。第四,如果有手柄,插上后不重启应当能直接用左摇杆移动、右摇杆瞄准。
如果第一步就不动,先看 Console 有没有报错,再确认 Action Map 是否 Enable,最后检查 Active Input Handling 是否切到 New。这三步能覆盖 80% 的"完全不响应"问题。
如果角色能动但方向不对,检查 2D Vector 复合绑定里四个槽位是否填反了上下。如果角色移动速度明显异常,检查 Pixels Per Unit 与相机 Size 是否匹配。如果瞄准时武器总是偏 90 度,回去改- 90f这个修正值。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 编译报错找不到 InputSystem 命名空间 | 包未安装 | Package Manager 搜索 Input System | 安装包并重启编辑器 |
| 代码通过编译但按键无反应 | Active Input Handling 仍为 Old | 查看 Project Settings > Player | 改为 Input System Package (New) 并重启 |
| Action 回调完全不触发 | Action Map 未 Enable | 在 OnEnable 打日志确认 | 补上 controls.Player.Enable() |
| 松手后角色持续移动 | 只订阅了 performed,未订阅 canceled | 检查订阅代码 | canceled 里把输入置为 Vector2.zero |
| 鼠标瞄准方向偏移 90 度 | 精灵枪口朝向与角度修正不匹配 | 观察静止时武器朝向 | 调整 -90f 修正值或更换精灵朝向约定 |
| 移动时角色画面抖动 | 相机跟随写在 Update 且无插值 | 检查 Rigidbody2D 的 Interpolate | 设为 Interpolate,相机跟随放 LateUpdate |
| 角色被地面遮挡 | Sorting Layer 配置错误 | 检查 Sprite Renderer 的 Sorting Layer | 按背景、地面、角色顺序重排 |
| 两个角色重叠时前后关系错乱 | 未启用自定义透明排序 | Project Settings > Graphics | Transparency Sort Axis 设为 (0,1,0) |
| 像素素材边缘发虚 | Filter Mode 为 Bilinear | 查看纹理导入设置 | 改为 Point (no filter),Compression 设 None |
| 手柄和键鼠同时操作导致乱跳 | 两条 Binding 同时驱动同一 Action | 在回调里打印 ctx.control.device | 用设备判别分支处理,或分设不同 Action |
| 高速子弹穿过墙体 | 碰撞检测为 Discrete | 检查 Rigidbody2D 组件 | 改为 Continuous,或用射线检测替代 |
| 反复进出播放模式后输入延迟 | InputAction 未 Dispose | 检查 OnDestroy | 补上 Dispose 调用 |
其中"松手后角色持续移动"和"Action 回调完全不触发"这两条占了新手问题的大半。前者是事件订阅不完整,后者是生命周期没管好。把这两个模式记住,后面接入技能、交互、暂停菜单时都能复用同一套写法。
还有一个隐蔽的坑:如果同一个物体上同时存在 PlayerInput 组件和手动创建的 PlayerControls 实例,两套输入会同时生效,表现为按键触发两次。解决方式是二选一,不要混用。
9. 最佳实践与工程建议
输入层要单独抽象。不要在 PlayerController 里直接写controls.Player.Move,而是加一个IInputProvider接口,由 MonoBehaviour 实现它对外暴露MoveInput、AimInput、IsFiring三个属性,PlayerController 只依赖接口。这样做的好处是将来做回放、AI 托管、教程引导时,可以塞一个脚本化的输入源进去,不用改业务代码。这个改动只有几十行,但会在项目中期省下大量重构。
素材按用途分目录。推荐结构是Assets/Art/Characters、Assets/Art/Environment、Assets/Art/VFX、Assets/Audio、Assets/Prefabs、Assets/Scripts、Assets/Input、Assets/Settings。贴图多了以后务必用 Sprite Atlas 打包,同一个小图集里的精灵可以合批渲染,Draw Call 会明显下降。图集按功能划分,比如角色一个、场景一个、UI 一个,不要把差异巨大的素材塞进同一个图集,否则加载时会把整个图集都读进内存。
物理与渲染的时间步要留意。默认 Fixed Timestep 是 0.02 秒(50Hz),对于大多数俯视角射击够用。如果子弹速度很快、判定要求高,可以降到 0.01。但要注意,降低时间步会线性增加物理计算开销,移动端的项目要权衡。相机跟随一定要放在LateUpdate,保证在角色移动完成后再更新位置,否则会出现跟随抖动。
输入相关的代码要加防御。所有GetComponent的结果都要判空,所有回调里都要先判断对象是否仍然有效。New Input System 的回调可能在对象被销毁的同一帧触发,访问已销毁的 Transform 会抛异常。OnDisable里取消订阅这个动作必须形成肌肉记忆。
版本兼容要提前确认。Unity 6 的velocity更名只是其中一项,2D 物理、UI Toolkit、Addressables 在不同版本间都有 API 变动。团队协作时建议在 README 里写清 Unity 版本和关键包版本,并在 ProjectSettings 里锁定。用 Git 管理时把ProjectSettings和Packages/manifest.json纳入版本控制,Library目录排除。
最后是图层与碰撞矩阵的纪律。每加一个新类型的可碰撞物体,先决定它的 Layer,再去 Physics 2D 设置里配置矩阵,最后才写逻辑。反过来做的话,等你发现子弹打到自己时,要回头改一堆预制体。
角色能跑起来、能瞄准、能开火之后,最值得继续补的是状态机。现在的三段脚本各自独立,一旦加入冲刺、翻滚、受击硬直,移动被多个来源同时修改就会打架。下一步可以把移动逻辑收进一个简单的状态机,让每种状态决定当前是否读取输入、是否允许转向、速度倍率是多少。这条演进路径比一开始就上复杂框架更稳,也更容易在出问题时定位到具体状态。