第一次打开Unity时,那个默认的蓝色天空盒和孤零零的主摄像机,总让我想起自己刚入门时的迷茫。别担心,今天我们就用最直白的方式,从空项目开始搭建一个完整的第三人称射击Demo。我建议直接使用Unity 2021 LTS版本(比如2021.3.21),这个版本既稳定又兼容大部分插件。
安装完Unity后,强烈建议配置VS Code作为代码编辑器。在Unity的Preferences > External Tools里设置VS Code路径,记得安装C#扩展包。有个小技巧:在VS Code设置中勾选"Omnisharp: Use Modern Net",可以避免奇怪的代码提示问题。我吃过亏才明白,开发环境配置不当会导致后续各种灵异bug。
很多教程会跳过GDD(游戏设计文档)直接写代码,这就像没画图纸就盖房子。我们的Demo虽然简单,但也要明确核心玩法:玩家用WSAD移动,鼠标控制视角,左键射击。敌人有巡逻和警戒机制,场景中有可交互的掩体和拾取物。
具体实现要分几个关键模块:
创建一个空对象命名为"Environment"作为场景根节点,这是保持层级整洁的好习惯。用默认立方体缩放成地面,我习惯设置Scale为(20,1,20)作为基础战场。添加几个不同高度的平台时,按住V键可以启用顶点吸附,让物体精准对齐。
制作掩体时,先用立方体堆叠出基本结构(比如2x2的底座加1x4的护板)。全选这些物体右键创建预制件时,Unity会自动生成蓝色预制件图标。之后在场景中放置4个实例,修改任意一个实例后点击预制件面板上的"Apply All",所有实例都会同步更新——这比手动复制修改效率高10倍。
给拾取物添加旋转动画:在Animation窗口创建新Clip,在第0、30、60、90、120帧分别插入Y轴旋转关键帧。记得把Wrap Mode设为Loop,然后调整曲线让旋转更平滑。我常用的是Linear切线模式,避免动画卡顿。
给玩家胶囊体添加Rigidbody时,一定要冻结X和Z轴旋转!这是我踩过的经典坑——不设置的话角色会像保龄球一样满地滚。移动代码有两种写法:
csharp复制// 方法一:直接修改Transform(适合简单移动)
void Update() {
transform.Translate(Input.GetAxis("Horizontal") * speed * Time.deltaTime,
0,
Input.GetAxis("Vertical") * speed * Time.deltaTime);
}
// 方法二:物理移动(更真实)
void FixedUpdate() {
_rb.MovePosition(transform.position +
transform.forward * verticalInput * speed * Time.fixedDeltaTime);
_rb.MoveRotation(_rb.rotation * Quaternion.Euler(Vector3.up * horizontalInput));
}
第三人称摄像机需要解决两个核心问题:镜头碰撞和视角切换。这是我的万能摄像机脚本:
csharp复制public class ThirdPersonCam : MonoBehaviour {
public Transform target;
public float distance = 5f;
public float height = 2f;
public LayerMask obstacleMask;
void LateUpdate() {
Vector3 targetPos = target.position + Vector3.up * height;
Vector3 dir = (transform.position - targetPos).normalized;
// 射线检测避免穿墙
if(Physics.Raycast(targetPos, dir, out RaycastHit hit, distance, obstacleMask)) {
transform.position = hit.point - dir * 0.2f; // 留出缓冲空间
} else {
transform.position = targetPos - dir * distance;
}
transform.LookAt(target);
}
}
实现真实跳跃需要处理三个细节:
csharp复制private bool IsGrounded() {
return Physics.CheckCapsule(_collider.bounds.center,
new Vector3(_collider.bounds.center.x,
_collider.bounds.min.y,
_collider.bounds.center.z),
0.1f,
groundLayer);
}
void Update() {
if(IsGrounded() && Input.GetKeyDown(KeyCode.Space)) {
shouldJump = true;
}
}
void FixedUpdate() {
if(shouldJump) {
_rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
shouldJump = false;
}
}
创建子弹预制件时要设置合理的初速度(我常用50-100),并添加自动销毁组件:
csharp复制public class Bullet : MonoBehaviour {
public float lifeTime = 3f;
public int damage = 10;
void Start() {
Destroy(gameObject, lifeTime);
}
void OnCollisionEnter(Collision other) {
if(other.gameObject.CompareTag("Enemy")) {
other.gameObject.GetComponent<EnemyHealth>().TakeDamage(damage);
}
Destroy(gameObject);
}
}
在枪口位置添加Particle System组件:
真实射击需要后坐力反馈,这段代码实现了上下反冲效果:
csharp复制public class Recoil : MonoBehaviour {
public float recoilAmount = 0.1f;
public float recoverySpeed = 2f;
private Vector3 currentRotation;
void Update() {
currentRotation = Vector3.Lerp(currentRotation, Vector3.zero, recoverySpeed * Time.deltaTime);
transform.localRotation = Quaternion.Euler(currentRotation);
}
public void AddRecoil() {
currentRotation += new Vector3(-recoilAmount, Random.Range(-0.1f, 0.1f), 0);
}
}
用枚举实现简单的AI状态切换:
csharp复制public enum AIState { Patrol, Chase, Attack }
public class EnemyAI : MonoBehaviour {
public AIState currentState;
public Transform[] waypoints;
private int currentWaypoint;
void Update() {
switch(currentState) {
case AIState.Patrol:
PatrolBehavior();
break;
case AIState.Chase:
ChaseBehavior();
break;
}
}
void PatrolBehavior() {
if(Vector3.Distance(transform.position, waypoints[currentWaypoint].position) < 1f) {
currentWaypoint = (currentWaypoint + 1) % waypoints.Length;
}
MoveTo(waypoints[currentWaypoint].position);
}
}
用SphereCast实现锥形视野检测:
csharp复制void CheckSight() {
Vector3 dirToPlayer = (player.position - transform.position).normalized;
float angle = Vector3.Angle(transform.forward, dirToPlayer);
if(angle < viewAngle * 0.5f) {
if(Physics.SphereCast(transform.position, 0.5f, dirToPlayer,
out RaycastHit hit, viewDistance)) {
if(hit.transform.CompareTag("Player")) {
currentState = AIState.Chase;
}
}
}
}
GameManager应该使用单例模式确保全局访问:
csharp复制public class GameManager : MonoBehaviour {
public static GameManager Instance;
public int playerHealth = 100;
public int ammoCount = 30;
void Awake() {
if(Instance == null) {
Instance = this;
DontDestroyOnLoad(gameObject);
} else {
Destroy(gameObject);
}
}
}
用C#事件实现低耦合通信:
csharp复制public static class EventHandler {
public static event Action<int> OnAmmoChanged;
public static void CallAmmoChanged(int amount) {
OnAmmoChanged?.Invoke(amount);
}
}
// 在射击脚本中调用
EventHandler.CallAmmoChanged(-1);
// 在UI脚本中监听
void OnEnable() {
EventHandler.OnAmmoChanged += UpdateAmmoUI;
}
频繁实例化子弹会引发GC问题,对象池是完美解决方案:
csharp复制public class BulletPool : MonoBehaviour {
public GameObject bulletPrefab;
public int poolSize = 20;
private Queue<GameObject> pool = new Queue<GameObject>();
void Start() {
for(int i=0; i<poolSize; i++) {
GameObject bullet = Instantiate(bulletPrefab);
bullet.SetActive(false);
pool.Enqueue(bullet);
}
}
public GameObject GetBullet() {
if(pool.Count > 0) {
GameObject bullet = pool.Dequeue();
bullet.SetActive(true);
return bullet;
}
return Instantiate(bulletPrefab);
}
public void ReturnBullet(GameObject bullet) {
bullet.SetActive(false);
pool.Enqueue(bullet);
}
}
在Project Settings > Physics中:
角色卡住时检查:
遇到物体莫名抖动或穿透:
完成基础Demo后,可以尝试:
记得在代码架构上留出扩展空间,比如使用ScriptableObject管理武器数据,或者用状态模式重构角色控制系统。我在实际项目中发现,前期良好的架构设计能为后期节省80%的调试时间。