ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity 2D炮台追踪与子弹发射:旋转、射击与对象池优化

Unity 2D炮台追踪与子弹发射:旋转、射击与对象池优化

做2D游戏遇到“炮台追踪主角并发射子弹”这个需求,基本是塔防、射击、Boss战里绕不开的一环。很多刚接触Unity的朋友第一反应就是“让炮台look at主角就行”,结果一做就发现要么炮台转起来像抽搐,要么子弹满天乱飞,要么管子口和枪口对不上。这篇文章就把这个功能拆开揉碎了讲,从追踪旋转到子弹生成,再到对象池优化,带着你一步步把炮台做出来且做得稳。

先说清楚最终效果:炮台会始终平滑地旋转,把炮口对准场景中的主角;当炮管方向和主角方向的夹角小于某个阈值后,才会开火;子弹从炮管口位置生成,沿炮口方向匀速飞行,碰到角色或边界后销毁。整套逻辑覆盖了旋转计算、角度插值、冷却时间、子弹生命周期这几个核心知识点,适合已经会搭2D场景、能跑通简单脚本的开发者参考。新手也能照做,涉及原理的地方我会用生活化的方式解释清楚。


1. 拆解需求与搭建前的建模思路

1.1 先把“炮台追踪”拆成三个独立问题

这个功能表面看就一句话,但动手前最好先把它拆开,否则代码越写越乱。我习惯拆成三件事:

  1. 炮台怎么感知主角在哪。
  2. 炮台怎么平滑地转向主角。
  3. 炮台怎么在合适的时机、从合适的位置把子弹射出去。

这三个问题对应三个脚本:一个负责感知和旋转,一个负责发射时机与子弹生成,另一个是子弹自身的飞行与碰撞。拆开的好处是以后换需求很方便——比如炮台改成预判射击,只需改感知脚本;把单发改成扇面三连发,只需改发射脚本;子弹要带反弹功能,只需改子弹脚本,互不干扰。

不少初学者喜欢把感知、旋转、发射全塞进一个Update里,最后脚本两三百行,调试时连哪出问题都难定位。我建议宁可多用几个脚本,也别图简单堆在一起。老项目里维护成本最低的,恰恰是那种“一个类只管一件事”的代码。

1.2 为什么不能直接让炮管“怼”着目标旋转

Unity里有个Transform.LookAt方法,做3D时很方便,直接让物体看向目标。但放在2D横版或俯视角里用起来往往很别扭,尤其是像炮台这种只需要绕一个轴旋转的对象,直接LookAt可能导致意想不到的旋转结果,甚至模型本身也会跟着翻转。

还有个更常见的问题:炮台如果每帧都把炮口精确对准目标,开火倒是没问题,但炮台一旦需要表演“缓慢瞄准”的效果,比如给玩家一个反应时间,直接锁定式的旋转就完全不适用了。所以我的做法是:基于目标方向计算出一个“目标角度”,然后用平滑插值把当前角度一点点逼近目标角度。核心是朝着正确方向渐进调整,而不是一步到位。

你可以把这种旋转想象成开车调头:真正的驾驶员不会瞬间把方向盘打死,而是根据角度差决定转动速度,角度差越大转得越快,快对准时减速平稳停下。放在游戏里,就是为了既表现机械感,又避免像素级的抖动。

1.3 坐标系与旋转轴的选择

做2D炮台前先确认一件事:你的场景主角是俯视视角下移动的(比如平面地图上四个方向走),还是横版视角(主角在地面上左右移动、跳跃)。虽然追踪代码逻辑差不多,但旋转轴完全不一样。

稍等,我说清楚旋转轴的问题。

在我的例子中,炮台默认是一个Sprite,正面朝右(即局部坐标的X轴正方向)。如果是俯视视角,炮台平放在场景中,这时它需要绕Y轴旋转来追踪主角。如果是横版视角,炮台立着看屏幕,它需要绕Z轴旋转。游戏里2D Sprite的宽高在XY平面,默认你从Z轴正方向看过去,所以横版时转的是Z轴。

一个快速判断方法:在Scene视图里操作旋转工具,拖动不同方向的旋转轴,看炮台是否在它所属的平面内转。转对了,就记住这个轴,后面所有角度计算都围绕这个轴来。

我后面写的代码用一种统一写法来处理:通过Atan2计算出要转到的角度,然后根据实际项目把角度值赋给对应轴的旋转分量。这样不管你是横版还是俯视,核心逻辑都不用改。


2. 核心细节解析与追踪旋转的实现

2.1 计算朝向目标方向的角度

2D平面内,从当前位置指向目标位置,可以分解为X和Y方向上的差值,这个差值向量与某个轴的夹角就是我们需要的旋转角度。数学上通过Atan2来计算,非常直观:

Vector2 direction = target.position - transform.position; float targetAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;

这段代码的含义是:先算出从炮台指向主角的方向向量,再把向量和X轴正方向的夹角算出来。算出来的角度就是炮台应该朝向的角度。因为2D Sprite默认朝向是正右方(X轴正方向),所以用X轴做基准刚刚好。

有个常见误区是使用Mathf.Atan,而不用Atan2。Atan只能接受一个比值,无法区分向量落在哪个象限,会导致角度错误。比如敌人出现在左前方,Atan算出的角度和右后方一样,炮台就会往相反方向转。Atan2接受Y和X两个参数,可以正确处理四个象限,所以2D方向角度计算一律用它。

如果你的炮台Sprite默认朝向不是右方,比如是朝上,只需要在计算出的角度基础上做一个偏移。比如朝上意味着初始角度是90度,那就在targetAngle基础上加上或减去90度让算法对齐。这个可以自己试,我一般用一根调试线把方向画出来,找个参照物就能判断角度对不对。

2.2 平滑旋转的几种方式与选择

算出目标角度后,接下来是旋转。最容易想到的是直接赋值:

transform.rotation = Quaternion.Euler(0, 0, targetAngle);

这样写的好处是简单,坏处是炮台瞬间跳变到目标角度,完全没有追踪过程。假如你只是想做个测试Demo,可以接受。但大多数游戏需要炮台匀速转动,表现得更像“机械”追踪,那就要用插值。

我推荐两种写法。

第一种:使用Mathf.MoveTowardsAngle。它专门用来处理角度间的移动问题,能自动处理角度环绕(比如从350度转到10度,它知道应该走20度而不是绕340度):

float currentAngle = transform.eulerAngles.z; float newAngle = Mathf.MoveTowardsAngle(currentAngle, targetAngle, rotateSpeed * Time.deltaTime); transform.rotation = Quaternion.Euler(0, 0, newAngle);

这里rotateSpeed是每秒转多少度的意思。如果你想让炮台转速为每秒180度,就用180。

第二种:使用Mathf.SmoothDampAngle,它能实现带阻尼的缓动,早期角度差大时转得快,接近目标时减速,适合做那种“有点迟钝但非常人性化”的炮台:

float currentAngle = transform.eulerAngles.z; float angleVelocity = 0f; float newAngle = Mathf.SmoothDampAngle(currentAngle, targetAngle, ref angleVelocity, smoothTime); transform.rotation = Quaternion.Euler(0, 0, newAngle);

smoothTime是阻尼时间,数值越大转得越慢越平滑。两种都行,我自己的项目里更喜欢MoveTowardsAngle,因为“恒定转速”更好控制,玩家看着能猜到炮台行为,更有游戏性;SmoothDampAngle则适合做更自然、更有重量的炮台。

有一点必须提醒:直接使用transform.eulerAngles的时候,要确保只修改自己需要的轴,不要顺便把其它轴的角度也改了。比如俯视视角时炮台只需要绕Y轴转,但你的赋值代码如果写成了Quaternion.Euler(0, 0, angle),就会把Z轴转得乱七八糟,Sprite看起来就跟翻倒一样。所以一定先确认好项目里炮台绕什么轴旋转,这个前面已经说过。

2.3 开火条件的判定

炮台转向主角时,不是任何角度都能开火。比如炮管还在朝后的时候,子弹会往天上打或往地上打,很违背直觉。常规做法是设置一个发射阈值:当炮口方向与指向主角的方向夹角小于某个值(比如5度)时,才能开火。

判断夹角可以直接用Vector2.Angle:

Vector2 toTarget = (target.position - transform.position).normalized; Vector2 gunDirection = transform.right; // 因为Sprite默认朝右,所以炮口方向是transform.right float angleDiff = Vector2.Angle(gunDirection, toTarget); if (angleDiff < 5f) { Shoot(); }

注意我把两个方向都normalized再比较。normalize的意思是忽略距离长短,只保留方向,这样才能正确计算夹角。如果不normalize,向量长短不同会直接影响夹角结果。

开火还要加一个冷却时间,否则炮管一旦对准目标,每帧都会发射一个子弹,几秒钟就把性能拖垮。冷却时间可以通过记录上次开火时间来实现:

private float lastShootTime = -999f; if (angleDiff < 5f && Time.time - lastShootTime >= fireInterval) { lastShootTime = Time.time; Shoot(); }

fireInterval就是你设定的发射间隔,比如1秒一发或0.5秒一发。这里的思路是:用当前帧时间减去上次开火时间,如果差值大于等于冷却时间,就允许开火。为什么给lastShootTime一个负初值?是为了保证炮台第一次进入范围时就能立刻开火,而不必等待一个间隔周期,这个小细节很多人容易忽略。

现在炮台知道该什么时候打了,但对准的过程中绝不乱发,玩家觉得有反应空间。这个设计如果你玩过几款射击或塔防游戏会非常熟悉——敌人炮塔锁定你时会有一段预备期,之后才真正开枪,实际上就是做了这个判断。


3. 子弹的创建、飞行与销毁逻辑

3.1 子弹Prefab如何搭最简单

我见过的很多炮台射出的子弹都从炮台中心往外飞,看起来像是炮台肚子发射的,特别出戏。正确做法是:子弹从炮管口生成。这意味着你需要把发射点位置单独提出来。

具体的做法如下:

  1. 在炮台对象下创建一个空物体,命名为MuzzlePoint。
  2. 把它移动到炮管末端,也就是你希望子弹出现的那一端。
  3. 确保它继承炮台的所有变换(它是炮台的子物体),这样炮台转动时发射点也跟着转动,发射点位置就是炮管口的世界坐标。

所谓世界坐标,就是场景里的实际坐标,不论父物体怎么旋转,它都会准确反映炮口此时的位置。代码里用muzzlePoint.position即可获得这个坐标。

接下来创建子弹本身。最简单的方法是新建一个圆形Sprite,大小合适,加上Rigidbody2D(选Kinematic或Dynamic都行,由你控制)和一个CircleCollider2D,再挂上子弹脚本。我建议初始给你的子弹加一个明显的材质或颜色,调试时一眼能看清飞行的方向和轨迹。

不建议把子弹的颜色和炮台颜色做成一样,边界模糊很容易把弹道方向和距离判错,测试阶段尽量高对比。

3.2 给子弹赋方向和速度

子弹生成时的核心三要素:位置、朝向、速度。我在发射脚本里这样写:

GameObject bullet = Instantiate(bulletPrefab, muzzlePoint.position, muzzlePoint.rotation); Bullet bulletScript = bullet.GetComponent<Bullet>(); bulletScript.SetDirection(muzzlePoint.right);

这里的关键是muzzlePoint.rotation——它携带了炮台当前的朝向,所以子弹Prefab如果本身是朝右的,生成后它的朝右方向就和炮管一致。然后设置子弹的飞行方向为muzzlePoint.right,也就是炮管当前指向的方向。

有些教程里会直接用target.position - muzzlePoint.position作为方向来发射子弹,这样会有一个问题:如果主角正在移动,或者炮台还没完全对准,子弹方向就会跟炮口显示的方向不一致,玩家看到炮管朝这、子弹却飞那。所以我自己一直坚持“炮口对着哪就射哪”的原则,也就是用muzzlePoint.right作为子弹方向,除非做预判射击才去单独计算提前量。

子弹脚本的关键是让子弹沿固定方向匀速前进:

public class Bullet : MonoBehaviour { private Vector2 direction; private float speed; public void SetDirection(Vector2 dir, float bulletSpeed) { direction = dir.normalized; speed = bulletSpeed; } void Update() { transform.position += (Vector3)(direction * speed * Time.deltaTime); } }

用Update去移动的话注意:不要在Update里用transform.Translate直接移动,除非你明确知道它的移动基准坐标系。我直接用transform.position += velocity * deltaTime的方式,简单透明,不容易踩到旋转漂移的坑。

如果你想把移动放入物理系统,可以在Rigidbody2D的velocity上赋值。但那样子弹的碰撞更依赖物理引擎的计算频率,我接下来的例子为了保持逻辑简洁,用Update直接移动,而碰撞检测通过OnTriggerEnter2D来写。

3.3 生命周期与命中处理

子弹飞出去不能永远留在场景里跑,否则一分钟后场景里几百个子弹,帧率就崩了。常见做法有两种:

  1. 设置生命周期,比如3秒后自动销毁,或越过游戏边界就销毁。
  2. 命中目标时销毁。

我两个都做。命中检测用Trigger模式,这在2D里最常见也最稳定:

private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) { // 处理伤害逻辑 Destroy(gameObject); } }

如果不做命中销毁而只是设置生命周期,子弹会穿过角色而去,然后在远处消失,玩家看到的是子弹打了个寂寞。除非你希望子弹穿透目标,那另当别论。

越过边界自动销毁的写法也很简单,我不会给每发子弹单独做很复杂的边界判断,直接设定一个最长飞行时间:

private float lifeTimer = 3f; void Update() { lifeTimer -= Time.deltaTime; if (lifeTimer <= 0f) { Destroy(gameObject); return; } transform.position += (Vector3)(direction * speed * Time.deltaTime); }

考虑到性能,子弹数量不多时可以放心用Destroy。但如果一场Boss战里一颗子弹都不该被浪费,或子弹生成频率很高,几百发累积销毁会导致垃圾回收压力增大,这时就要上对象池。我后面会专门讲一下对象池怎么做,先别急。


4. 完整流程实操记录与参数调试心得

4.1 场景搭建和角色引用

用最简单的方式搭建场景:主角是一个带碰撞体和脚本的圆形Sprite,场景里放炮台、子弹Prefab、主角,然后建一个空物体挂Turret脚本。主角最好再加一个“被击中后扣血并闪烁”的临时状态,方便确认子弹是否真的命中,不然光看子弹飞过去不知道有没有打中。

主角的引用方式有几种:在Inspector面板手动把主角拖到炮台的target字段;或者用GameObject.FindGameObjectWithTag("Player")。前者更直接,适合固定场景;后者更灵活,适合动态生成主角的场景。我习惯在Inspector里赋值,理由是简单清晰、不会因为改Tag出错。只有在主角会频繁创建销毁、不方便拖引用的时候才用查找方式。

关于私有变量的序列化显示:我给大多数调试参数加了[SerializeField],因为项目改到后期,最好能在Inspector里随时调参数,不用回代码改。这个习惯是我踩过几次坑后养成的——每次改个旋转速度都要打开IDE、改代码、编译,太浪费生命了。你能在Inspector里调的话,测试速度翻倍。

4.2 核心脚本完整代码与逐段注释

下面给出一个完整可用的组合。我会把三个核心脚本全部贴出来,并加上注释说明。

首先是炮台控制脚本。它只负责两件事:感知目标方向并旋转,然后判断是否开火。这个脚本我建议放在炮台根节点上。

using UnityEngine; public class TurretController : MonoBehaviour { [SerializeField] private Transform target; // 主角的Transform [SerializeField] private float rotateSpeed = 180f; // 每秒转多少度 [SerializeField] private float fireAngleThreshold = 5f; // 允许开火的夹角阈值 [SerializeField] private float fireInterval = 1f; // 发射间隔(秒) [SerializeField] private GameObject bulletPrefab; // 子弹预制体 [SerializeField] private Transform muzzlePoint; // 炮口发射点 private float lastShootTime = -999f; void Update() { if (target == null) return; Vector2 direction = target.position - transform.position; float targetAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; float currentAngle = transform.eulerAngles.z; float newAngle = Mathf.MoveTowardsAngle(currentAngle, targetAngle, rotateSpeed * Time.deltaTime); transform.rotation = Quaternion.Euler(0, 0, newAngle); Vector2 gunDirection = transform.right; Vector2 toTargetDirection = ((Vector2)(target.position - transform.position)).normalized; float angleDiff = Vector2.Angle(gunDirection, toTargetDirection); if (angleDiff <= fireAngleThreshold && Time.time - lastShootTime >= fireInterval) { lastShootTime = Time.time; Shoot(); } } private void Shoot() { if (bulletPrefab == null || muzzlePoint == null) return; GameObject bullet = Instantiate(bulletPrefab, muzzlePoint.position, muzzlePoint.rotation); Bullet bulletScript = bullet.GetComponent<Bullet>(); if (bulletScript != null) { bulletScript.SetDirection(muzzlePoint.right, 10f); } } }

然后是子弹脚本。它负责直线飞行与命中判断:

using UnityEngine; public class Bullet : MonoBehaviour { private Vector2 direction; private float speed; private float lifeTimer = 3f; public void SetDirection(Vector2 dir, float bulletSpeed) { direction = dir.normalized; speed = bulletSpeed; } void Update() { lifeTimer -= Time.deltaTime; if (lifeTimer <= 0f) { Destroy(gameObject); return; } transform.position += (Vector3)(direction * speed * Time.deltaTime); } private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) { // 这里可以调用主角受到伤害的方法,比如 other.GetComponent<PlayerHealth>().TakeDamage(1); Destroy(gameObject); } } }

两个脚本合在一起,功能闭环就完成了。要注意一个问题:在2D里给炮台根节点挂Transform时,主炮管的Sprite子物体会跟着整体旋转。炮台如果只有一根管子,直接旋转根节点就行;如果炮台还有底座和炮管两个物体,通常底座不转或只朝固定方向转,而炮管单独转,那脚本就应该挂到炮管子物体上,MuzzlePoint也放在炮管子物体里。

判断挂哪一层有个简单原则:看谁需要旋转,脚本就挂谁。如果底座会跟着炮管一起转动,那挂根节点或子物体都可以;如果底座固定,管子在底座上转,那你就把旋转和发射逻辑都放在炮管子物体上。

4.3 场景参数调试心得

我在测试过程中最常用的辅助手段是Debug.DrawRay。它可以在Scene视图里画出一条射线,用来实时观察角度和方向的判断是否正确。在炮台脚本里加一句:

Debug.DrawRay(transform.position, transform.right * 2f, Color.green); Debug.DrawLine(transform.position, target.position, Color.red);

绿色线表示炮台当前的朝向,红色线表示炮台指向主角的方向。如果绿色线和红色线在炮台旋转后逐渐重合,说明旋转逻辑正确;如果始终偏离,说明角度计算有问题。在2D物理里,这种可视化调试比log输出来得快得多。

参数调整方面,我的建议是:先设置rotateSpeed = 180,fireAngleThreshold = 5,fireInterval = 1,跑通整个流程。等确认整个管线流畅了,再按游戏手感调:比如想让炮台更灵敏,就调大rotateSpeed;想让炮台更迟钝,就调小rotateSpeed同时把fireAngleThreshold调大一点。

自己在做的时候遇到过一种情况:旋转速度和开火阈值不匹配,炮管还没完全对准就开火,子弹到处乱飞。解决方式是确保rotateSpeed足够大,至少能在一个fireInterval内完成从最大角度差到阈值范围内的旋转。如果你想让炮台看起来“吃力”地追踪,可以故意调小rotateSpeed,让弹道分散,那也是一种玩法设定。

另外建议在做任何复杂参数调整之前先把Time.timeScale测试功能用起来。把Time.timeScale调成0.1,游戏会以十分之一速度运行,你能清楚看到子弹飞行、碰撞的全过程,一眼就能看出问题出在哪。这是我在调试弹道类功能时最喜欢的一招,省下无数反复猜测的时间。


5. 常见问题与排查技巧实录

5.1 炮台旋转时抖得像脑血栓

这个现象出现最多的原因是:旋转直接一步到位,没有插值。也就是把transform.rotation = Quaternion.Euler(...)那句话写死了,导致角度不断闪烁。另一个常见原因是:你把旋转代码挂在炮台的父物体和子物体上重复执行,两处旋转叠加,互相抵消或放大,最终表现成抖动。

排查方法:先看Hierarchy里有没有多个脚本在同时修改同一个物体的旋转角度。关掉一个,看是否恢复;如果只有一个脚本,再检查角度计算里是否直接用了eulerAngles,而不是对当前角度做插值。

用Mathf.MoveTowardsAngle代替直接赋值基本就能解决抖动。还有一个隐蔽问题是角度从350度跳到0度时,如果直接lerp会导致旋转一整圈。MoveTowardsAngle会自动处理这种环绕边界,这也是我推荐它的重要原因。

5.2 子弹从炮台中心而非炮口飞出

这个问题基本可以断定是发射位置用了transform.position,而不是muzzlePoint.position。如果炮管比较长,子弹从中心飞出会明显穿模,非常出戏。

解决方法是:在炮管末端创建子物体MuzzlePoint,并调整好位置。然后用muzzlePoint.position作为生成位置。有一点需要注意:如果MuzzlePoint的位置是在炮管子物体上设置的,要确保它在Inspector的坐标是相对坐标,也就是局部坐标,而不是世界坐标。当你把它拖到正确位置时,它会自动显示子物体相对父物体的偏移量。

5.3 子弹完全打不到主角

通常有三种原因:碰撞检测没有触发、碰撞检测模式不对、子弹飞行速度太快导致穿过了角色。

先讲碰撞检测。2D的碰撞分触发器和非触发器。如果你的子弹挂着Rigidbody2D和Collider2D,主角挂着Collider2D和Rigidbody2D,哪怕只有一个Rigidbody2D且是Kinematic,也能触发触发器事件。但我见过不少新手只给子弹挂了Collider,没挂Rigidbody,这样OnTriggerEnter2D永远都不会触发,因为引擎要求至少一方有Rigidbody2D才会生成碰撞事件。

再讲穿透。如果子弹速度是50单位每秒,而主角的碰撞体半径只有0.5单位,物理计算在帧间隔内可能直接跳过碰撞体,看起来就是子弹飞过去了、什么都没发生。解决方法是把Rigidbody2D的Collision Detection Mode设为Continuous,或者在Bullet里用射线检测。对2D游戏来说,最简单粗暴的可靠方案是降低子弹速度,或增大子弹碰撞体范围。真要做得精确,用Physics2D.Raycast检测前方障碍物也行,但普通子弹用Continuous就够了。

5.4 子弹乱射、方向离谱

子弹生成时如果不设置朝向,或者使用了transform.forward,在2D里很容易出现方向莫名其妙、飞行路线偏离屏幕的情况。建议始终通过muzzlePoint.right来传递方向。

顺便解释一下为什么用right而不是up:因为Unity的2D Sprite默认纹理是平铺在XY平面上的,它的“前方”在局部坐标系中默认是X正方向,也就是right。除非你特意改了Sprite的Pivot或模型导入设置,否则用right才是最稳的。

如果在FPS或3D项目里,改成transform.forward是符合直觉的,但这是2D项目,一定要养成用right的习惯。

5.5 后期性能:子弹对象池

如果你的炮台一秒射10发,一分钟后场景里有600发子弹在飞,每帧都做物理移动和碰撞检测,性能迟早出问题。Destroy虽然简单,但频繁创建和销毁对象会让垃圾回收触发频率变高,结果就是游戏有明显卡顿。

对象池的思路:预先创建一批子弹,隐藏起来不用的时候放进池子,用的时候从池子里取出来、设置好位置和方向再激活,等子弹超出生命周期或命中后,不要销毁,而是再次隐藏并放回池子。这样避免了反复Instantiate和Destroy,性能提升非常明显。

子弹很少时可以不用对象池,比如冷却时间2秒一发,一关打完也不超过50发。但如果碰到弹幕Boss,子弹数量上百甚至更多,就必须上对象池。我自己的Boss战里用过最简单的一个对象池方案:

public class BulletPool : MonoBehaviour { public GameObject bulletPrefab; public int poolSize = 30; 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(Vector3 position, Quaternion rotation) { GameObject bullet = pool.Count > 0 ? pool.Dequeue() : Instantiate(bulletPrefab); bullet.transform.position = position; bullet.transform.rotation = rotation; bullet.SetActive(true); return bullet; } public void ReturnBullet(GameObject bullet) { bullet.SetActive(false); pool.Enqueue(bullet); } }

注意这个简单版的细节:池子空的时候动态创建新子弹,虽然这破坏了“完全不创建”的初衷,但作为兜底手段可以保证功能不会因为池子容量不足而出错。而且容量设置合理的话,你几乎不会触发那个兜底分支。GetBullet里要对子弹重新设置位置和旋转,避免复用时仍停留在上次的位置和朝向,这是新手最容易漏掉的一步。ReturnBullet前也要把子弹的lifeTimer重置回初始值,否则复用的子弹可能飞到一半就自己销毁了。

对象池看起来是多了一点代码量,但实际用下来,收益远大于成本,尤其当你把同一套逻辑复制给多个炮台时,共享一个池子节省的创建开销非常可观。


6. 扩展玩法思路/后续还能做什么

我做完这套基础炮台后,最深的感受是:这个功能的价值不在于“炮台打人”本身,而在于它的逻辑可以平移到一堆类似功能上。比如把这个追踪目标从“主角”换成“场景中离自己最近的敌人”,再比如把子弹方向从“炮口当前方向”换成“目标未来位置”来实现预判射击,代码骨架甚至都不用动。

最常见的扩展是:给炮台加一个“攻击范围”。炮台不必在全图追踪主角,而是在一定半径内才开始搜索和旋转。这个可以用Vector2.Distance或者一个CircleCollider2D做触发器做范围判断。有了范围限制后,炮台的追踪行为会更自然,不会全图盯着你转个没完。

更进一步,想做瞄准提前量的话,子弹方向就不能是muzzlePoint.right,而要根据目标的移动速度和坐标算出命中时间,再计算未来的目标位置。这个其实也不复杂,但前提是你已经把基础版本跑通,再去叠加,才有可能调试得明白。我建议新手先把基础版本做得顺手了,再来玩高级花样。

我就是这样一步步过来的。曾经被炮台抖动问题折磨了一晚上,后来发现只是漏了插值;也曾经因为没挂Rigidbody导致子弹一直打不中人,排查了半天才意识到是物理组件的问题。这些坑如果你绕过了,这套功能很快就能长在你的手上,后续做Boss战、做关底防守都会轻松很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进