简介:面向Unity3D入门与进阶学习者的坦克大战完整工程源码,基于经典教程案例整理完善,可直接在编辑器中运行与拆解,适合想通过小型项目掌握游戏开发主流程的开发者。压缩包共2154个文件,约24.78MB,以info、bin、meta、dll等Unity工程与运行库文件为主,同时包含asset、prefab、controller、anim等场景与动画配置,以及14个cs脚本、png与bmp贴图、wav音频和unity场景文件,覆盖工程资源、脚本逻辑与打包产物。已有2576人学习下载。源码围绕场景与游戏对象、C#脚本控制、UI、物理碰撞、动画状态机、粒子与音频管理等模块展开,可用于理解坦克移动射击、碰撞检测、分数生命值显示与资源组织的实现方式,也便于对照修改参数、复用组件,作为课程设计或小型对战项目的起步模板。
1. 一份能直接按 Play 的坦克大战工程,适合拿来做什么
网上讲坦克大战 Unity3D 的文章不少,但大多数停在“我做了个能动的方块”这一步。真正稀缺的是一份结构没被写歪、打开就能按 Play、脚本职责分得清清楚楚的完整工程。这份坦克大战工程源码属于后者:它把一个完整的小型射击玩法拆成了移动、瞄准、开火、伤害、AI、关卡管理几个独立模块,每块都能单独拎出来复用。适合两类人——刚学完 Unity 基础、想看看一个“完整项目”到底长什么样的人;以及手上有玩法原型、需要一套现成的操控和战斗骨架去改的人。整套工程不含第三方插件依赖,脚本全是原生的 C# 加 Rigidbody 物理,换句话说,你不需要先理解一堆框架就能读懂它。
2. 工程全景:从 Assets 分层看这套坦克大战的技术选型
拿到一个 Unity 工程,我第一件事不是打开场景按 Play,而是先看Assets目录。目录结构基本等于作者脑子里对项目的分层方式,它比任何注释都诚实。这套坦克大战工程的目录分层是典型的小体量项目做法:按职责分文件夹,而不是按资源类型一把梭。
2.1 目录结构:脚本按职责分成五个文件夹
常见的组织方式大致是这样,你下载下来对照着看即可:
TankBattle/ ├── Assets/ │ ├── Scenes/ │ │ ├── MainMenu.unity 主菜单 │ │ └── Level_01.unity 第一关战斗场景 │ ├── Scripts/ │ │ ├── Core/ GameManager.cs / LevelLoader.cs / ObjectPool.cs │ │ ├── Tank/ TankMotor.cs / TurretAim.cs / TankShooting.cs / TankHealth.cs │ │ ├── Combat/ Shell.cs / Explosion.cs / IDamageable.cs │ │ ├── AI/ EnemyTankAI.cs / PatrolPath.cs │ │ └── UI/ HUDController.cs / TankHealthUI.cs │ ├── Prefabs/ │ │ ├── Tank_Player.prefab │ │ ├── Tank_Enemy.prefab │ │ ├── Shell.prefab │ │ └── Explosion.prefab │ ├── Models/ Materials/ Textures/ Audio/ │ └── Resources/ 按需放运行时动态加载的资源 ├── ProjectSettings/ 编辑器版本、物理、Tag/Layer、输入轴都在这 └── Packages/ 包依赖清单Scripts下的五个文件夹对应五类变化频率不同的代码:Core里的东西整个项目生命周期只存在一份,改动最少;Tank和Combat是玩法核心,改得最多;AI和UI属于可替换件。我一般给读者的建议是,先看Tank和Combat,因为这两个文件夹决定了“这游戏好不好玩”,Core只是把流程串起来。
Prefabs单独拎出来这点值得说。坦克、子弹、爆炸各自是预制体,而不是场景里手摆的 GameObject,意味着你可以把它们拖进任何场景直接用。新手容易犯的错是把玩家坦克直接摆在场景里、把参数写死在 Inspector 上,结果换关卡就得重新摆一遍。这套工程没这么干。
提示:不同作者对文件夹的命名可能略有出入(比如
Scripts/Tank也可能叫Scripts/Player),但“按职责分目录 + 玩法元素预制体化”的整体思路不会有大的出入,对照着看就行。
2.2 场景与预制体:玩法被拆成可拼装的零件
打开Level_01.unity,场景层级(Hierarchy)里大体是这样的结构:
Level_01 ├── --- Environment --- ├── Ground 带 Collider 的地面,Layer 设为 Ground ├── Walls 围墙,Collider + 静态 ├── --- Managers --- ├── GameManager 挂 GameManager.cs,持有玩家引用和计分 ├── --- Actors --- ├── PlayerSpawn 空物体,标记出生点 ├── EnemySpawnPoints 若干空物体,敌人出生点 └── --- UI --- └── Canvas HUD:血量条、剩余敌人数、得分注意野心:场景里几乎没有具体的坦克实例,只有出生点。玩家坦克和敌人坦克都是在运行时由GameManager或LevelLoader实例化出来的。这个设计带来的直接好处是,你想加第二关,只需要新建一个场景、摆好地面和出生点,玩法代码一行不用改。
场景里的Ground这一层也很关键。炮塔瞄准用的是从摄像机发射射线、打到Ground层的方式确定瞄准点,而不是用鼠标屏幕坐标硬换算。这样做的好处是,鼠标指向地面任意位置,瞄准都准,而且鼠标移到 UI 上时不会误触。代价是必须保证地面 Layer 设置正确,这也是后面排查章节里会单独讲的一个坑点。
2.3 技术选型:为什么用 Rigidbody 而不是 CharacterController
坦克移动这块,工程走的是Rigidbody+MovePosition的路线,而不是CharacterController或者直接改transform.position。这个选择有两层考虑。
第一层是物理互动。坦克撞墙、撞别的坦克、被爆炸冲击波推开,这些都需要物理引擎介入。Rigidbody天然参与碰撞检测和受力计算,CharacterController虽然也能撞墙,但它本质上是一个带胶囊体的“特殊移动器”,不参与刚体动力学,被子弹打退、被冲击波掀翻这类效果做起来很别扭。
第二层是控制精度。直接改transform.position最省事,但那样会绕过物理系统——你写的位置和物理系统算的位置会打架,表现就是坦克贴着墙抖动、或者穿过薄墙。用Rigidbody.MovePosition则是把“我想移动到哪”告诉物理系统,由它在下一个物理步里平滑地移动过去,同时处理碰撞。
至于用MovePosition而不是直接给velocity赋值,是因为坦克这种载具的速度需要自己控制加减速手感,用MovePosition可以让代码完全掌握位移曲线;如果直接改velocity,物理引擎的阻尼、摩擦会参与进来,调起来更玄学。
工程里还保留了Rigidbody的Interpolate(插值)选项。这一项在 60 帧下看不太出来,但一旦掉帧,没开插值的坦克就会明显一顿一顿。我见过不止一个人抱怨“我的坦克动起来像卡了帧”,最后查出来就是这一个勾。
3. 坦克操控链路:从键盘输入到履带位移的完整实现
这一章是整套工程最值得逐行读的部分。操控手感好不好,全在这几十行里。
3.1 输入采集与移动方式:把 Update 和 FixedUpdate 分清楚
Update和FixedUpdate的职责划分是 Unity 新手最容易翻车的地方之一。简单说:读输入放Update,动刚体放FixedUpdate。原因是Update的调用频率跟着渲染帧率走,不固定;FixedUpdate按固定时间步长调用,物理计算必须在这里做。如果你在Update里直接改Rigidbody的位置,物理系统下一个步长又会把它拉回去,两者打架,表现就是抖动或者“走两步退一步”。
工程里的写法是把输入缓存成字段,在Update里赋值,在FixedUpdate里消费。这个模式我强烈建议保留,哪怕你觉得多写两行很啰嗦。
3.2 TankMotor 逐段拆解:加速、转向与 MovePosition
先看移动脚本的完整实现:
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class TankMotor : MonoBehaviour { [Header("移动参数")] public float moveSpeed = 6f; // 前进/后退最大速度,单位 m/s public float turnSpeed = 90f; // 原地转向角速度,单位 度/秒 public float accelRate = 8f; // 速度趋近系数,越大起步越干脆 private Rigidbody rb; private float inputV; // W/S -> -1 ~ 1 private float inputH; // A/D -> -1 ~ 1 private float currentSpeed; // 当前实际速度,用于做加减速 void Awake() { rb = GetComponent<Rigidbody>(); // 坦克只在地面平面活动,锁死不需要的自由度能省很多奇怪问题 rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ; } void Update() { // 只在 Update 采样输入,不在这里动刚体 inputV = Input.GetAxisRaw("Vertical"); inputH = Input.GetAxisRaw("Horizontal"); } void FixedUpdate() { // 1. 目标速度:输入 * 最大速度 float targetSpeed = inputV * moveSpeed; // 2. 用 Lerp 逼近,做出"有惯性但不打滑"的手感 currentSpeed = Mathf.Lerp(currentSpeed, targetSpeed, accelRate * Time.fixedDeltaTime); // 3. 沿坦克自身 forward 平移 Vector3 nextPos = rb.position + transform.forward * currentSpeed * Time.fixedDeltaTime; rb.MovePosition(nextPos); // 4. 转向只绕世界 Y 轴,避免坦克被撞歪后一路歪着走 float yaw = inputH * turnSpeed * Time.fixedDeltaTime; rb.MoveRotation(rb.rotation * Quaternion.Euler(0f, yaw, 0f)); } }逐段说一下逻辑。Awake里锁死 X、Z 轴旋转,是为了让坦克永远四脚着地——不锁的话,撞上斜坡或者被爆炸一推,坦克就会侧翻,玩家体验直接崩。这一步很多教程会漏掉,属于典型的“不写也行,写了省一堆事”。
Update里用GetAxisRaw而不是GetAxis。差别在于GetAxis带 Unity 内置的平滑,返回值是渐变的;GetAxisRaw是 -1、0、1 的瞬时值。坦克的手感靠accelRate自己控制更可控,再用引擎的平滑会叠加出黏滞感。
FixedUpdate的第二步是核心。Mathf.Lerp(currentSpeed, targetSpeed, accelRate * Time.fixedDeltaTime)这一行做的是指数逼近:每一物理步让当前速度向目标速度靠近一点。accelRate越大,靠近得越快,手感越“硬”;越小,起步和刹车越绵。调到 8 到 12 之间是比较舒服的坦克手感,调到 3 会像开船,调到 40 就变成瞬间启停了。
第三步的rb.MovePosition传的是绝对位置,不是增量,这一点容易搞错。nextPos是“当前位置 + 位移”,所以每次都是算好终点的。乘Time.fixedDeltaTime是把速度换算成这一步的位移量。
第四步的转向用的是右乘四元数,rb.rotation * Quaternion.Euler(0, yaw, 0)。右乘表示绕自身坐标系旋转,配合前面锁死的 X、Z 轴约束,转出来才永远是绕世界竖直轴的偏航。反过来如果写成Quaternion.Euler(0, yaw, 0) * rb.rotation,转轴会跟着当前姿态变,坦克走一段路之后方向就会乱。
参数上还有一点要改:moveSpeed = 6f是这套工程默认值,如果你把坦克模型放大了两倍,这个速度看起来就慢了一半,得同步往上调。
3.3 炮塔瞄准与开火:射线拾取 + 冷却 + 子弹实例化
炮塔和车体是分离的两个 Transform,这样一边前进一边可以自由瞄准。瞄准脚本挂在炮塔的轴心上:
using UnityEngine; public class TurretAim : MonoBehaviour { [Header("引用")] public Transform turretPivot; // 炮塔旋转轴心,一般是 Turret 根节点 public Transform muzzle; // 炮口位置,子弹从这里生成 public GameObject shellPrefab; // 子弹预制体 [Header("参数")] public float aimSpeed = 180f; // 炮塔跟随转速,度/秒 public float fireInterval = 0.8f; // 开火冷却,秒 public float shellSpeed = 25f; // 出膛速度,m/s public float shellLife = 4f; // 子弹自动销毁时间,防泄漏 private float nextFireTime; void Update() { Aim(); if (Input.GetButton("Fire1") && Time.time >= nextFireTime) { nextFireTime = Time.time + fireInterval; Fire(); } } void Aim() { // 从摄像机发射射线,只和 Ground 层做检测 Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); int groundMask = LayerMask.GetMask("Ground"); if (!Physics.Raycast(ray, out RaycastHit hit, 300f, groundMask)) return; // 鼠标没指到地面,保持当前朝向,不抖 Vector3 dir = hit.point - turretPivot.position; dir.y = 0f; // 压平到水平面,炮塔不做俯仰 if (dir.sqrMagnitude < 0.01f) return; // 鼠标正好压在炮塔上,跳过 Quaternion target = Quaternion.LookRotation(dir, Vector3.up); turretPivot.rotation = Quaternion.RotateTowards( turretPivot.rotation, target, aimSpeed * Time.deltaTime); } void Fire() { GameObject shell = Instantiate(shellPrefab, muzzle.position, muzzle.rotation); Rigidbody srb = shell.GetComponent<Rigidbody>(); if (srb != null) { // 旧版本 Unity 用 velocity;新版改成了 linearVelocity srb.velocity = muzzle.forward * shellSpeed; } Destroy(shell, shellLife); // 兜底销毁,防止飞出场景的子弹永远存活 } }Aim里用Physics.Raycast打到地面,再把命中点压到水平面上算方向,比直接拿鼠标屏幕坐标换算成世界坐标稳。dir.y = 0f这一步很关键——不压平的话,鼠标离坦克越近,炮塔仰角越大,看起来像炮管在往天上抬。
Quaternion.RotateTowards限制每帧最大旋转角度,做出“炮塔缓缓转过去”的机械感。如果你想要更快的响应,把aimSpeed调到 360 以上,但别直接赋值turretPivot.rotation = target,那样炮塔会瞬间瞬移,看起来像跳帧。
Fire里的冷却用Time.time加时间戳对比,是 Unity 里最省事也最可靠的冷却实现。比用协程简单,比用Invoke可控。子弹生成后立刻给一个初速度,然后交给Destroy(shell, shellLife)兜底——这是血泪经验:不做兜底销毁,一旦某颗子弹因为碰撞体设置错误没被销毁,它就会一直在场景里飞,时间长了对象越积越多,帧率掉得莫名其妙。
注意:
Rigidbody.velocity在较新的 Unity 版本里被重命名为linearVelocity,老工程直接打开会报编译错误。这类 API 改名不止一处,导入工程后先看 Console 里的红字再动手改。
4. 战斗结算:子弹、伤害接口与敌人状态机
操控解决了“怎么动”,这一章解决“打中了会怎样”。这套工程在这块做了两件正确的事:用接口解耦伤害、用状态机管敌人行为。
4.1 子弹预制体与生命周期:别让 Destroy 成为性能黑洞
子弹预制体的构成很朴素:一个SphereCollider(或CapsuleCollider)+Rigidbody+Shell.cs。但有两个字段的取值会直接决定游戏能不能玩。
第一个是Rigidbody的Collision Detection。默认是Discrete,意思是物理引擎在每个物理步结束时检测一次碰撞。子弹速度 25m/s,物理步长默认 0.02 秒,一步飞 0.5 米——如果子弹的碰撞体半径小于这个值,或者目标很薄,就会直接穿过去。解法是把子弹的Collision Detection设成Continuous Dynamic,同时把碰撞体半径做到 0.1 米以上。这是子弹类玩法最经典的坑,几乎每份工程都会遇到一次。
第二个是Interpolate。子弹高速飞行时如果没开插值,视觉上会一卡一卡的。
再看Shell.cs:
using UnityEngine; [RequireComponent(typeof(Rigidbody))] public class Shell : MonoBehaviour { public float damage = 25f; // 命中伤害 public GameObject hitFxPrefab; // 命中特效预制体,可为空 void OnCollisionEnter(Collision col) { // 1. 从碰撞体往上找,拿到实现 IDamageable 的组件 var target = col.collider.GetComponentInParent<IDamageable>(); if (target != null) { target.TakeDamage(damage); } // 2. 播特效(如果配了) if (hitFxPrefab != null) { Instantiate(hitFxPrefab, transform.position, Quaternion.identity); } // 3. 子弹自己消失 Destroy(gameObject); } }GetComponentInParent而不是GetComponent,是因为坦克的碰撞体通常挂在子物体上(比如车体模型节点),IDamageable却在根节点的TankHealth上。用InParent一路往上找,能覆盖这种层级。
传递伤害用接口IDamageable而不是直接GetComponent<TankHealth>(),好处是墙壁、油桶、基地这些不同东西都能被子弹打,只要实现同一个接口。加新可破坏物的时候不用回头改子弹代码。这就是这套工程在架构上最值得抄的一点。
4.2 IDamageable 与 Health:把伤害和死亡解耦
接口本身只有一行:
public interface IDamageable { void TakeDamage(float amount); }实现类是TankHealth(或者叫Health):
using UnityEngine; using UnityEngine.Events; public class TankHealth : MonoBehaviour, IDamageable { public float maxHp = 100f; public UnityEvent onDamaged; // Inspector 里挂受击表现 public UnityEvent onDead; // 死亡表现:爆炸、掉分、禁用操控 private float hp; private bool dead; void Awake() => hp = maxHp; public void TakeDamage(float amount) { if (dead) return; // 已经死了就不重复结算 hp = Mathf.Max(hp - amount, 0f); onDamaged?.Invoke(); if (hp <= 0f) { dead = true; onDead?.Invoke(); // 死亡表现交给 Inspector,代码不掺和 } } }两个关键点是dead这个开关和UnityEvent的用法。
dead守卫解决的是“同一帧内多颗子弹命中导致死亡表现播多次”的问题。子弹是OnCollisionEnter触发的,一次爆炸可能同时命中好几颗子弹,没有守卫的话,爆炸特效会叠好几层,分数会加好几次。
UnityEvent的价值在于,死亡之后具体做什么(播爆炸、加分数、禁用操控、延迟加载下一关)没有写死在TankHealth里,而是在 Inspector 上连线。这意味着同一份TankHealth挂在玩家坦克和敌人坦克上,死亡后的行为完全不同,但脚本只有一份。这是 Unity 里做组件复用的标准做法,比在代码里写if (isPlayer) ... else ...干净得多。
4.3 敌人 AI:一个只有三个状态的状态机
敌人的行为不复杂,追、打、待机。这套工程用的是最朴素的三状态 FSM,没有引行为树,也没有上 NavMesh,因为坦克在开阔地面上直线追击就够用了。
using UnityEngine; public enum EnemyState { Idle, Chase, Attack } public class EnemyTankAI : MonoBehaviour { [Header("引用")] public Transform player; // 玩家坦克的 Transform public TurretAim turret; // 复用车体的瞄准组件 [Header("参数")] public float sightRange = 20f; // 发现距离 public float attackRange = 12f; // 进入攻击状态的距离 public float fireInterval = 1.5f; // 敌人开火间隔 private EnemyState state = EnemyState.Idle; private TankMotor motor; private float nextFireTime; void Awake() => motor = GetComponent<TankMotor>(); void Update() { if (player == null) return; float dist = Vector3.Distance(transform.position, player.position); UpdateState(dist); Tick(); } void UpdateState(float dist) { switch (state) { case EnemyState.Idle: if (dist < sightRange) state = EnemyState.Chase; break; case EnemyState.Chase: // 退出距离乘以 1.2,制造"迟滞",防止在边界反复横跳 if (dist > sightRange * 1.2f) state = EnemyState.Idle; else if (dist < attackRange) state = EnemyState.Attack; break; case EnemyState.Attack: if (dist > attackRange * 1.15f) state = EnemyState.Chase; break; } } void Tick() { switch (state) { case EnemyState.Idle: break; // 不动,也不打 case EnemyState.Chase: // 朝玩家方向前进:把目标点丢给坦克的朝向逻辑 FaceTo(player.position); motor.SetInput(1f, 0f); // 全速前进 break; case EnemyState.Attack: motor.SetInput(0f, 0f); // 站定 FaceTo(player.position); if (Time.time >= nextFireTime) { nextFireTime = Time.time + fireInterval; turret.Fire(); } break; } } void FaceTo(Vector3 target) { Vector3 dir = target - transform.position; dir.y = 0f; if (dir.sqrMagnitude < 0.01f) return; transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 5f * Time.deltaTime); } }状态迁移里那两个1.2f和1.15f系数是整个 AI 的灵魂。如果Chase退出条件写成dist > sightRange,和进入条件是同一个阈值,敌人就会在距离恰好 20 米时疯狂切换状态——追一下、停一下、追一下,看起来像在原地抽搐。把退出距离放大 20%,形成一个“迟滞区”,行为就稳了。这是所有状态机都会遇到的问题,跟具体项目无关。
motor.SetInput(1f, 0f)这里说明TankMotor需要额外暴露一个输入注入接口,让 AI 能像玩家一样驱动坦克。常见做法是给TankMotor加一个public void SetInput(float v, float h),内部维护inputV/inputH,玩家操控时由Update写入,AI 接管时由 AI 写入。这样一套移动逻辑同时服务玩家和敌人,改一次两边都生效。
FaceTo用Slerp而不是直接赋旋转,是为了让敌人转向有过程,看起来像真的坦克在拧方向盘。系数 5 是转向速度,调大转向更凶,调小更笨重。
5. 排查手册:这套坦克大战工程跑不起来时的五类常见问题
从下载到按 Play,中间总有几个坑等着。下面这五条是我在类似工程上真实遇到频率最高的,按“现象 → 原因 → 解决”的顺序写。
5.1 打开工程一片粉色,或者场景是空的
现象:打开Level_01按 Play,模型全是亮粉色,或者 Hierarchy 里只有几个空物体,看不到坦克和地面。
原因:两种可能。一是渲染管线不匹配——工程用的是内置管线(Built-in)的材质,你的项目模板却是 URP 或 HDRP,材质找不到对应的 Shader 就渲染成粉色。二是场景的加载路径断了,预制体引用丢失。
解决:先看ProjectSettings/GraphicsSettings里默认渲染管线是不是空,空的就是内置管线。如果不是空,去Packages/manifest.json看有没有 URP 包,有的话说明这是个 URP 工程,得用 URP 模板新建项目再导入资源,或者反过来把材质批量换成 URP 的 Lit。粉色问题不要手动一个个改材质,用Edit > Rendering > Materials > Convert Selected Built-in Materials to URP批量转,快得多。
5.2 坦克穿墙、贴墙抖动,或者按 W 走两步退一步
现象:坦克高速撞墙时直接穿过去,或者贴着墙的时候浑身抖。
原因:三个常见来源。第一,碰撞检测模式是Discrete,速度快时直接跨过薄墙。第二,在Update里改了transform.position,和物理系统打架。第三,墙的BoxCollider太薄(比如 0.1 米),即使速度不快也可能穿。
解决:先确认移动代码只在FixedUpdate里调MovePosition。然后给坦克的Rigidbody把Collision Detection设为Continuous Dynamic,给墙的碰撞体厚度加到 0.3 米以上。最后检查Rigidbody的Interpolate是否开了Interpolate,开了抖动会明显减轻。
5.3 子弹打不中敌人,或者敌人血量不掉
现象:视觉上子弹明显飞过了敌人,但敌人毫发无损;或者敌人掉血了但没播受击效果。
原因:命中判定依赖OnCollisionEnter,这条路要求双方都有Collider且至少一方有Rigidbody,并且 Layer 之间的碰撞矩阵是勾选状态。任何一环断了,回调都不会触发。另一个高频原因是被打的目标的IDamageable组件不在碰撞体的父链上,GetComponentInParent找不到。
解决:先打开Edit > Project Settings > Physics > Layer Collision Matrix,确认子弹层和敌人层是勾上的。然后在运行时选中一颗飞在空中的子弹,看 Inspector 里Rigidbody和Collider是否都在。最后检查敌人预制体:碰撞体挂在Body子节点,TankHealth挂在根节点,这个层级关系是对的;如果TankHealth挂在了兄弟节点上,InParent就找不到。
5.4 敌人原地转圈、抽搐或者完全不动
现象:敌人围着一个点疯狂旋转,或者在发现距离边缘反复进入退出攻击状态。
原因:状态迁移阈值没做迟滞,或者 AI 和玩家输入同时写TankMotor的输入字段,两边每秒互相覆盖几十次。还有一种少见情况是player引用为空,Update里直接return,敌人从出生起就一动不动。
解决:把退出阈值统一乘以 1.1 到 1.2 的系数,制造迟滞区。检查TankMotor里是否存在“玩家输入”和“AI 输入”两条写入路径,有的话用状态标志区分,同一时刻只允许一方写。最后在Awake里加一句if (player == null) player = GameObject.FindWithTag("Player").transform;兜底,避免引用丢失导致静默失败。
5.5 退出 Play 模式后场景被改脏了
现象:跑了一次游戏,再退出 Play,发现场景里的物体位置变了、血量归零了、Hierarchy 里多了一堆东西,保存后工程就乱套了。
原因:运行时修改了场景中已存在的对象,或者修改了ScriptableObject资源——后者尤其阴险,因为它在编辑器里是持久化的,Play 结束后改动不会回滚。
解决:养成“运行时只改实例化出来的对象”的习惯。如果一定要在运行时改配置,用Instantiate一份副本再改,别直接改资源引用。发现场景已经脏了,直接不保存关掉,重新打开工程即可。这条我吃过亏,之后每次改完玩法脚本,进 Play 之前都会先Ctrl+S存一次,改坏了还能回退。
6. 二次开发:在这套坦克大战骨架上加玩法的三个切口
这套工程最值钱的地方不是它现在的样子,而是它的接缝留得够干净。想加点东西,基本不用动核心代码。
第一个切口:换弹药系统。现在子弹是“出一发、打一发、固定伤害”。想加穿甲弹、爆破弹,改TurretAim.Fire()就行——把shellPrefab从单个字段换成数组或者ScriptableObject配置表,开火前先选弹种。Shell.cs里的damage字段本来就是public的,实例化后直接覆写就能让同一份预制体打出不同伤害。实测下来,这条路两小时能跑通,不用碰任何其他脚本。
第二个切口:加关卡与波次。现在的敌人是在场景里用出生点摆的。想做成波次刷怪,新建一个ScriptableObject存“第几波、刷几个、间隔多久”,然后在GameManager里写一个协程循环读取。这里有个小设计建议:把ObjectPool用起来,敌人死了回收而不是Destroy,一波刷二三十个的时候 GC 压力差别肉眼可见。下面这张表是我改这类工程时的对照清单:
| 改造目标 | 主要动哪里 | 需要留意的风险 |
|---|---|---|
| 新增弹种 | TurretAim.cs+ 新建ShellData资产 | 弹种切换时 UI 要同步刷新 |
| 波次刷怪 | GameManager.cs+ObjectPool.cs | 对象回收时事件要解绑,否则重复触发 |
| 新增敌人类型 | 继承EnemyTankAI重写Tick() | 状态枚举共用,别在子类里改状态机核心 |
| 加护甲减伤 | TankHealth.TakeDamage内部计算 | 别改IDamageable签名,会牵连所有实现 |
第三个切口:改操控手感。TankMotor的三个参数(moveSpeed、turnSpeed、accelRate)全在 Inspector 上,直接在运行时拖就能试出感觉,试好了再存回预制体。想让坦克有“履带差速”的效果,把转向和前进解耦成左右两条履带的速度,再按下式计算实际位移,比现在的单速度模型真实得多:
// 差速转向:左右履带速度不同 → 线速度取平均,角速度取差值 float vLeft = inputV * moveSpeed - inputH * turnSpeed * 0.5f; float vRight = inputV * moveSpeed + inputH * turnSpeed * 0.5f; float linear = (vLeft + vRight) * 0.5f; float angular = (vRight - vLeft) / trackWidth; // trackWidth 为履带间距 rb.MovePosition(rb.position + transform.forward * linear * Time.fixedDeltaTime); rb.MoveRotation(rb.rotation * Quaternion.Euler(0f, angular * Mathf.Rad2Deg * Time.fixedDeltaTime, 0f));验证方法很简单:把trackWidth设小,坦克原地转向会变快;设大,转向变迟钝。改完之后回到场景里跑一圈,看坦克过弯时是不是“内侧履带慢、外侧履带快”,是的话就对了。这个技巧我每次做载具类原型都会用,它比调三个魔法数字更容易调到想要的手感。
最后说一句我自己的习惯:拿到任何一份陌生工程,我都会先新建一个空场景,把Tank_Player.prefab拖进去、按 Play 跑一圈,确认移动和开火没问题,再回头去看关卡和 UI。这样出问题时排查范围最小,不会一上来就被一堆场景对象绕晕。这套坦克大战工程照这个顺序摸一遍,一晚上足够上手改出自己的东西。希望帮到你。
本文还有配套的精品资源,点击获取