ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity飞机大战高分攻略:对象池、状态机与特效实战

Unity飞机大战高分攻略:对象池、状态机与特效实战 简介这是一份面向Unity 3D课程设计与期末大作业的飞机大战游戏完整项目开发语言为C#适合需要完成高分课设或希望从零上手Unity游戏开发的学生参考。项目基于Unity引擎组织工程资源共约2000个文件包含场景、预制体、C#脚本、动画剪辑、三维模型、材质贴图、着色器、音频字体等主要类型并附有PDF/Word使用文档压缩包约649.9MB下载后可直接运行。代码注释详细能帮助初学者理解游戏循环、碰撞检测、敌机生成、计分与UI管理等核心模块同时角色动画与枪械资源齐全整个项目具备从玩家操控、敌机波次生成、碰撞判定到得分展示的完整游戏流程项目结构清晰学有余力者可在此基础上扩展功能或替换美术资源进行二次开发。配套使用文档包含运行环境、场景结构、核心代码解析等内容方便对照学习。项目整体达到97分高分设计水准现有501人学习下载适合需要完整可演示高分项目、快速搭建期末作业框架的读者作为实用参考尤其适合答辩或期末提交前需要稳定可运行项目的情况。1. 飞机大战是Unity期末作业里最容易“做完却拿不到90分”的项目几乎所有Unity初学者都会在期末选择飞机大战因为玩法固定、素材好找、逻辑直观。但正因如此老师对这类项目的评分标准会格外苛刻界面是否美观、代码是否规范、是否用了对象池、是否有完整的UI状态切换、音效与粒子是否到位、打包后是否能在不同分辨率和平台上稳定运行。很多人的作业停留在“飞机能飞、子弹能打、敌人会撞”的层面代码全塞在一个Update里UI只有两个按钮这样的项目通常只能拿70到80分。真正能上95分的飞机大战拼的不是玩法创新而是工程组织能力——把输入、对象池、碰撞、UI状态机、特效、音频、文档这些环节用合理的方式串起来。这篇博客就按我平时带项目验收的思路把这个过程完整拆开每一步都给出可复现的代码、参数和检查点适合你直接照着自己做一份高完成度的期末作业。2. 核心循环与对象池让子弹和敌机不再频繁Instantiate飞机大战的卡顿根源往往不是模型面数而是每帧创建和销毁GameObject。默认的Instantiate和Destroy会触发GC分配、以及生命周期回调弹幕一多就开始掉帧。正确做法是预创建足够数量的实例运行时只激活和休眠。2.1 玩家飞机的输入与边界约束玩家控制常见有两种方案鼠标跟随和键盘方向键。期末作业建议两者都做鼠标控制适合演示键盘控制适合可靠操作。输入读取放在Update里物理移动放在FixedUpdate里这里有个容易踩的坑。public class PlayerController : MonoBehaviour { public float moveSpeed 12f; public Vector2 xRange new Vector2(-7f, 7f); public Vector2 yRange new Vector2(-4f, 4f); private Vector2 moveInput; private Rigidbody2D rb; void Start() { rb GetComponentRigidbody2D(); } void Update() { float h Input.GetAxisRaw(Horizontal); float v Input.GetAxisRaw(Vertical); if (Input.GetMouseButton(0)) { Vector2 mousePos Camera.main.ScreenToWorldPoint(Input.mousePosition); moveInput (mousePos - (Vector2)transform.position).normalized; } else { moveInput new Vector2(h, v); } } void FixedUpdate() { Vector2 next rb.position moveInput * moveSpeed * Time.deltaTime; next.x Mathf.Clamp(next.x, xRange.x, xRange.y); next.y Mathf.Clamp(next.y, yRange.x, yRange.y); rb.MovePosition(next); } }逻辑说明把输入解析和物理移动拆到两个函数中Update只负责获取方向FixedUpdate负责实际位移避免帧率波动影响移动速度。GetAxisRaw比GetAxis更适合做精确移动因为它是离散的-1/0/1不会带平滑过渡。屏幕到世界坐标的转换用的是主摄像机的ScreenToWorldPoint需要注意把Z轴设为摄像机距离否则可能得到近裁剪面的坐标。Clamp把飞机限制在预设的边界矩形内这比直接限制Transform更稳定因为刚体移动会绕过一些碰撞检测。2.1.1 为什么用Rigidbody2D而不是直接改Transform直接改Transform在无碰撞需求时更简单但飞机大战里后续要加撞击伤害、被击退、爆炸后停止运动等状态用Rigidbody2D的MovePosition可以保证物体不会穿透静态碰撞体而且将来接OnCollisionEnter2D不需要额外设置。如果你用Transform移动同时挂Collider快速移动时容易发生隧穿。2.2 对象池设计同时容纳200发子弹也不产生GC子弹、敌机、爆炸特效都要进对象池。推荐一种不依赖Unity生命周期的最简实现核心是栈存储压入时SetActive(false)弹出时SetActive(true)并重置数据。public class SimplePool { private StackGameObject pooled; private GameObject prefab; private Transform parent; public SimplePool(GameObject prefab, int preloadCount, Transform parent) { this.prefab prefab; this.parent parent; pooled new StackGameObject(); for (int i 0; i preloadCount; i) { GameObject go Object.Instantiate(prefab, parent); go.SetActive(false); pooled.Push(go); } } public GameObject Get() { if (pooled.Count 0) pooled.Push(Object.Instantiate(prefab, parent)); GameObject go pooled.Pop(); go.SetActive(true); return go; } public void Release(GameObject go) { go.SetActive(false); pooled.Push(go); } }逻辑说明预加载数量通常设为屏幕同时出现对象峰值的1.2倍。比如同时最多20架敌机、50发子弹那就预加载24架敌机和60发子弹。池内没有对象时再Instantiate后续复用不再分配内存。Release由对象自身在生命周期结束时调用比如子弹飞出屏幕或碰到敌机不是用Destroy而是调用Release。2.2.1 子弹自回收的两种触发方式子弹脚本里建议同时写两个回收条件超出屏幕边界或命中目标。用事件委托把回收逻辑发给池比让子弹直接引用池实例更解耦。public class Bullet : MonoBehaviour { public System.ActionGameObject OnReturn; public float lifetime 3f; private float elapsed; void OnEnable() { elapsed 0f; } void Update() { elapsed Time.deltaTime; if (elapsed lifetime) { OnReturn?.Invoke(gameObject); } } private void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Enemy)) { OnReturn?.Invoke(gameObject); } } }注意这里的OnEnable重置计时器否则从池里拿出来的子弹会残留上一次的存活时间。lifetime参数需要根据屏幕尺寸和子弹速度调整我通常设成这样如果子弹每帧移动speed 20屏幕高度为Camera.main.orthographicSize * 2那么lifetime 屏幕高度 / speed 0.5f多出的0.5秒作为边界缓冲。2.3 碰撞检测从触发器换成自定义区域查询当子弹和敌机用OnTriggerEnter2D直接判断时一个子弹要跟所有敌机做碰撞检测复杂度是O(N×M)。当屏幕单位超过200个时物理引擎开销会明显上涨。常见做法是用空间哈希或简单的分格检测。不过对于期末项目你不需要实现完整四叉树可以用一个更轻的方案把敌机按X坐标分桶子弹只检查邻近桶中的敌机。public class SpatialBucket : MonoBehaviour { public float bucketWidth 2f; private Dictionaryint, ListEnemy buckets new Dictionaryint, ListEnemy(); public int GetBucket(float x) { return Mathf.FloorToInt(x / bucketWidth); } public void RegisterEnemy(Enemy enemy) { int b GetBucket(enemy.transform.position.x); if (!buckets.TryGetValue(b, out ListEnemy list)) { list new ListEnemy(); buckets[b] list; } list.Add(enemy); } public ListEnemy GetNearby(Vector2 pos, float range) { int minB GetBucket(pos.x - range); int maxB GetBucket(pos.x range); ListEnemy result new ListEnemy(); for (int b minB; b maxB; b) { if (buckets.TryGetValue(b, out ListEnemy list)) result.AddRange(list); } return result; } }用法是敌机生成时注册到对应分桶子弹每帧调用GetNearby拿候选列表再逐个做距离判断。这个方案比物理碰撞快很多而且代码量小还能在UI上画出Debug信息演示“我做了空间优化”这是加分项。要注意敌机移动后需要更新分桶通常做法是在敌机Update末尾检查是不是已经进到新桶是则移除旧桶注册。这块逻辑如果写不好容易出错建议直接让敌机持有当前的桶ID。3. UGUI界面状态机开始、暂停、结算与计分不再是一团糟很多作业只有“开始游戏”和“退出”两个按钮暂停直接拖时间缩放结算用Debug.Log。这样的项目即便玩法完整老师也会在工程结构上扣分。用状态机管理UI面板是非常基础且必要的。3.1 用Canvas Group和Enum切换面板创建一个UIManager持有所有面板的引用每帧只激活一个面板。面板切换用淡入淡出而不是直接SetActive这个细节能提升完成度。public enum UIState { MainMenu, Playing, Paused, GameOver } public class UIManager : MonoBehaviour { public CanvasGroup mainMenuPanel; public CanvasGroup playingPanel; public CanvasGroup pausedPanel; public CanvasGroup gameOverPanel; public float fadeDuration 0.3f; public GameObject scoreText; private UIState currentState; private Coroutine fadeRoutine; public void SwitchTo(UIState newState) { if (fadeRoutine ! null) StopCoroutine(fadeRoutine); CanvasGroup target GetPanel(newState); CanvasGroup previous GetPanel(currentState); fadeRoutine StartCoroutine(FadeGroup(previous, target)); currentState newState; } private IEnumerator FadeGroup(CanvasGroup from, CanvasGroup to) { if (from ! null) { from.interactable false; while (from.alpha 0) { from.alpha - Time.deltaTime / fadeDuration; yield return null; } from.gameObject.SetActive(false); } to.gameObject.SetActive(true); to.interactable true; while (to.alpha 1) { to.alpha Time.deltaTime / fadeDuration; yield return null; } } private CanvasGroup GetPanel(UIState state) { switch (state) { case UIState.MainMenu: return mainMenuPanel; case UIState.Playing: return playingPanel; case UIState.Paused: return pausedPanel; case UIState.GameOver: return gameOverPanel; default: return null; } } }逻辑说明CanvasGroup的alpha、interactable、blocksRaycasts三个属性是面板控制的标配。这里让切换的协程在结束前把旧面板禁用避免隐藏面板上的按钮仍然响应点击。currentState初始值要设为MainMenu在Start里调用SwitchTo前把所有面板alpha设为0或直接禁用否则会出现闪烁。3.1.1 暂停时别用Time.timeScale 0一劳永逸正确的暂停做法是冻结所有游戏逻辑但同时让UI的动画和音效维持。如果直接Time.timeScale 0暂停面板的Fade协程也会被冻结因为协程依赖WaitForSeconds。解决方式用WaitForSecondsRealtime让协程不受timeScale影响或者在暂停状态下直接瞬间切到暂停面板而不是淡入。我一般选择后者暂停时界面快速切换不用动画。3.2 计分系统用单例加事件而不是到处查找计分值需要被UI文本框、结算面板、排行榜如果有共同访问。用全局单例ScoreManager保存分数提供AddScore方法并在变化时触发事件。public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } public int CurrentScore { get; private set; } public event System.Actionint OnScoreChanged; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } public void AddScore(int value) { CurrentScore value; OnScoreChanged?.Invoke(CurrentScore); } public void ResetScore() { CurrentScore 0; OnScoreChanged?.Invoke(CurrentScore); } }逻辑说明DontDestroyOnLoad使得切场景时分数不重置。UI面板在Start中订阅事件在OnDestroy中退订防止内存泄漏。用事件让计分显示和游戏逻辑解耦后续如果增加连击判定只需在连击逻辑里调用AddScore乘数即可不需要改UI代码。3.2.1 分数文本的本地格式在UI文本上显示分数时注意用字符串拼接会频繁产生GC建议用IntToString缓存或者使用ZString库。但对于期末作业简单做法是scoreText.text 分数: ScoreManager.Instance.CurrentScore.ToString();如果觉得演示时有不流畅可以用StringBuilder每帧clear一次。实际上Text组件重构文字本身是主要开销建议只在OnScoreChanged事件中更新而不是每帧刷新。3.3 音效和特效的挂载方式飞机大战需要射击音效、爆炸音效、背景音乐。为了避免每个敌机都挂一个AudioSource用一个全局AudioManager单脚本来播放把所有音效clip做成数组用枚举索引。public class AudioManager : MonoBehaviour { public static AudioManager Instance; public AudioClip[] clips; private AudioSource sfxSource; private AudioSource musicSource; public void PlaySfx(int clipIndex, float pitchVariation 0.05f) { sfxSource.pitch Random.Range(1 - pitchVariation, 1 pitchVariation); sfxSource.PlayOneShot(clips[clipIndex]); PlayerPrefs.SetFloat(sfxVolume, sfxSource.volume); } }注意PlayOneShot是叠加播放的不会打断前一个同音效适合密集射击。pitchVariation是为了避免多次播放同一音效听起来像机械重复这是游戏音频设计中很便宜但很有效的手段。4. 让游戏看起来像95分PBR材质、粒子拖尾、屏幕震动与后处理这一章解决“看起来”的问题。飞机大战的模型往往是自己用Cube或Sprite拼的如果直接用默认材质画面非常廉价。通过参数调整和免费插件可以让画面提升一个档次。4.1 给简易战机模型升级材质如果战机模型是用Blender做的低多边形用Unity内置Standard材质时要注意金属度、光滑度、自发光三个参数。金属度过高会导致环境反射过强灯光不足时发黑。推荐设置如下表参数推荐值说明Metallic0.1~0.3模拟机身漆面过高会失去细节Smoothness0.4~0.7控制高光锐度EmissionColor 1.2~2.0引擎喷口、驾驶舱发光Albedo不超过0.8灰度防止白色过曝材质上可以给飞机加一个MaterialPropertyBlock在不打断合批的情况下换颜色或发光强度。但这里有个误区不要每个升级都生成新材质实例要用PropertyBlock。var block new MaterialPropertyBlock(); renderer.GetPropertyBlock(block); block.SetColor(_EmissionColor, Color.red); renderer.SetPropertyBlock(block);4.2 粒子系统做爆炸和拖尾爆炸用ParticleSystem的Burst配合子发射器实现。核心参数Simulation Space 设为 World这样飞机移动时粒子留在原地Start Speed 控制飞散范围Start Lifetime 控制烟雾持续时间。拖尾最简单的是给子弹挂一个TrailRenderer把Time设为 0.3MinVertexDistance设为 0.1这样不会因为子弹速度太快产生断点。一个经验爆炸粒子的颜色曲线在ColorOverLifetime中设置从黄到红再到黑速度快一点比用单一颜色真实得多。别忘了在Renderer模块中把Material换成背景透明的粒子材质否则会看到方形黑底。4.2.1 对象池和粒子系统的回收配合粒子系统播放完毕后不会自动销毁。可以在粒子脚本里监听主模块的stopAction设置为Callback然后调用池回收public class ExplosionEffect : MonoBehaviour { public System.ActionGameObject OnFinished; public void OnParticleSystemStopped() { OnFinished?.Invoke(gameObject); } }需要注意ParticleSystem.Stop(false, ParticleSystemStopBehavior.StopEmittingAndClear)后也会触发OnParticleSystemStopped所以回收条件要判断是否是持久状态。4.3 别忽视Cinemachine和Post Processing这两个包都可以在Unity Package Manager中免费安装。Cinemachine用于实现屏幕震动和镜头跟随虽然飞机大战通常是固定视角但震动效果非常提升爽快感。public class ShakeOnExplosion : MonoBehaviour { public CinemachineVirtualCamera vcam; public float shakeIntensity 2f; public float shakeTime 0.2f; void Start() { var noise vcam.GetCinemachineComponentCinemachineBasicMultiChannelPerlin(); noise.m_AmplitudeGain shakeIntensity; noise.m_FrequencyGain 1.5f; Invoke(nameof(StopShake), shakeTime); } void StopShake() { var noise vcam.GetCinemachineComponentCinemachineBasicMultiChannelPerlin(); noise.m_AmplitudeGain 0f; } }Post Processing里最值得调的是Bloom和Vignette。Bloom可以让子弹发光的感觉更强烈但注意不要开太高Intensity0.3到0.6即可否则整个画面泛白。Vignette默认值即可增加视觉焦点。5. 写一份让老师愿意细看的使用文档与打包验收清单很多作业的README只有“鼠标移动左键射击”一行字。但其实使用文档是评分中的重要部分尤其在“源码使用文档”被明确写在标题里时文档质量直接影响最终分数。5.1 使用文档必须包含的六个章节章节内容要点项目介绍游戏玩法、操作方式、得分条件运行环境Unity版本、目标平台Windows/Mac、屏幕分辨率要求操作说明每个按键对应的功能注意写清楚手柄/键盘/鼠标三种方式源码结构每个核心脚本的作用以及它们之间的关系自定义修改如何改飞机速度、子弹伤害、敌机生成频率等参数常见问题打包后无法运行、中文路径报错、字体缺失等每个章节不用长但要把关键参数表列出来。比如在“自定义修改”里列一个参数表参数位置参数名默认值作用PlayerControllermoveSpeed12玩家飞机移动速度EnemySpawnerspawnInterval1.0敌机生成间隔Bulletdamage1子弹对敌机造成的伤害5.2 构建时最容易翻车的四个问题第一项目内所有素材路径不能包含中文字符和特殊符号否则打包后在非中文系统上可能加载失败。第二Build Settings里要选PC, Mac Linux Standalone然后在Player Settings中设置Default Icon和Resolution勾选Fullscreen Window而不是Exclusive Fullscreen方便演示时随时切窗口。第三如果用了DontDestroyOnLoad要确认场景切换后UI不重复生成否则会出现多个计分管理器。第四打包后字体丢失往往是因为动态字体没有包含在构建中在Font的高级设置里勾选Include Font Data。5.3 30分钟全功能自测表这一节值得写进文档的结尾也能用来自己检查。按这个顺序过一遍问题会在验收前暴露检查项操作预期结果开始游戏点击主菜单按钮切换到游戏画面背景音乐开始键盘移动WASD飞机按相应方向移动且不越界鼠标控制按住鼠标左键拖动飞机跟随鼠标移动且相对平滑发射子弹空格或左键子弹从机头前方生成无卡顿击毁敌机让子弹命中敌机敌机爆炸粒子播放分数增加暂停功能按ESC或点击暂停暂停面板显示时间冻结重新开始在结算面板重新开始回到开始界面分数清零打包运行双击exe帧率稳定无报错背景音乐正常自测表本身也是文档的加分项它能证明你做了系统性的验证。最后再强调一个细节把Unity版本号和编辑器版本号写在文档里因为很多老师打开项目时会因为版本不匹配而无法运行这也会影响评分。文档结尾留一页写“开发环境与贡献者信息”就足够了。本文还有配套的精品资源点击获取
返回列表