ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity体素射击源码拆解:炮塔升级、黄金经济与30分钟循环

Unity体素射击源码拆解:炮塔升级、黄金经济与30分钟循环 简介Voxel Shooter是一份基于Unity的体素射击肉鸽幸存者游戏完整项目源码面向有C#基础且希望学习幸存者类玩法框架、炮塔升级与敌人碎片化战斗反馈的开发者。项目支持Unity 2021.1.17f1及以上版本压缩包为zip格式共1862个文件包含137个C#脚本、64个预制体、166个材质、150张PNG贴图以及Shader、anim/controller动画资源和Android AAR依赖整体大小约49.02MB。目前已有249人学习浏览。源码实现了30分钟独特游戏循环玩家通过摧毁敌人赚取黄金并不断升级炮塔项目已集成Yandex、Facebook、AppsFlyer等SDK且无广告无内购便于研究移动端接入与自行扩展。除常规meta、mat、txt外还包含asset、preset、asmdef等工程配置和第三方SDK资源这些文件基本按Unity目录组织预制体与材质一一对应目录较完整适合作为独立游戏Demo或课程设计的基础工程。同时描述中提及UX/UI存在若干问题可作为界面优化训练。1. 体素射击、炮塔强化、黄金经济的 30 分钟循环这个源码包不是又一个突突突的 FPS而是把肉鸽幸存者的资源滚雪球思路塞进体素射击里的完整工程玩家守住炮塔敌人被打碎成方块后掉落黄金黄金用来升级炮塔伤害、射速和范围再面对更强的一波敌人。一局固定三十分钟没有广告也没有内购只留下最核心的战斗循环。源码用 C# 写成基于 Unity 2021.1.17f1 或更高版本打开适合正在做俯视角射击、幸存者类玩法和塔防成长系统的人也适合想在老项目上重新学习和改造的开发者。拆这份源码的价值不光是看懂 Voxel Shooter 怎么运行而是看它用的第三方 Android SDK 要怎么处理、老版本工程要怎么迁到新 Unity以及那些遗留的 UX-UI 问题该怎么动手修。2. 从 AAR 清单反向拆解 Voxel Shooter 的模块结构与 C# 热循环2.1 Plugins/Android 里的 AAR 是项目血统的线索打开源码包会在Assets/Plugins/Android下看到 10 个 AAR 文件。第一眼看起来像一堆不相关的第三方库实际上它们暴露了这个项目的构建方向com.yandex.android.mobmetricalib-5.2.0是 Yandex 统计com.facebook.android.facebook-core-15.1.0与com.facebook.android.facebook-common-15.1.0是 Facebook 基础库com.appsflyer.af-android-sdk-6.8.2是 AppsFlyer 归因com.google.android.gms.play-services-base-18.0.1与play-services-basement-18.0.0是 Google 服务依赖剩下的appcompat-1.1.0、core-1.3.2、fragment-1.3.0、media-1.0.0是 AndroidX 支撑件。这些库与玩法本身没有直接关系却会参与 Android Gradle 构建并在升级 Unity 时成为依赖冲突的热点。在拆代码前最好先把这些 AAR 的归属列表打出来find Assets/Plugins/Android -name *.aar -exec basename {} \; | sort这段命令把目录下所有.aar文件名按字母排序打印。find负责递归查找-exec basename {}去掉路径只保留文件名sort让结果可读。通过这份清单你能把广告归因组件和 AndroidX 支撑件分开后面的迁移阶段才知道哪些能删、哪些必须留。建议先把 Yandex、Facebook、AppsFlyer 三组 AAR 移到临时目录只保留 AndroidX 基础件再用构建报错反推依赖关系。2.2 核心 C# 模块Turret、Enemy 与黄金经济的热循环Voxel Shooter 的玩法循环不是靠动画驱动的而是三个 C# 系统之间的数值互锁WaveManager 决定刷怪节奏Enemy 死亡时产出黄金TurretUpgrade 消耗黄金提升属性。只要把这三个脚本里的参数抽出来就能改变一整局游戏的难度曲线。典型的目录结构会按功能把脚本拆到Scripts/Turret、Scripts/Enemies、Scripts/Managers、Scripts/UI下升级炮塔的逻辑通常维护一个分级的属性表而不是在 Update 里逐帧计算伤害。[System.Serializable] public class TurretLevel { public float damage; public float fireRate; public float range; public int upgradeCost; } public class TurretUpgrade : MonoBehaviour { public ListTurretLevel levels new ListTurretLevel(); private int currentLevel 0; public void Upgrade() { if (currentLevel levels.Count - 1) return; currentLevel; ApplyStats(levels[currentLevel]); } private void ApplyStats(TurretLevel level) { var weapon GetComponentWeaponBase(); weapon.damage level.damage; weapon.fireRate level.fireRate; } }这段代码把每一级炮塔的伤害、射速、射程、升级花费统一放到levels列表里升级按钮只需要调用Upgrade()。参数说明upgradeCost与damage的增长倍率决定资源消耗曲线如果想让中后期更接近幸存者类游戏的膨胀感可以把upgradeCost改成指数增长而不是等差增长。实际工程里我一般会把levels序列化成 ScriptableObject这样调整 30 分钟的平衡不需要重新编译 C#直接在 Inspector 里改表就行。2.3 体素破碎效果与射击判定的协作方式体素射击的核心视觉是敌人被打碎成方块。这里说的“体素”不一定真的使用 Minecraft 式的体素网格而是把角色模型在运行时替换成一组预制的碎块 Prefab或者把模型顶点按位置重组成若干小立方体。常见做法是命中消息传到 Health 系统后先隐藏原模型再在受击点实例化碎块组给每个带刚体的碎块施加随机爆炸力最后由Destroy(debris, 3f)回收。private void SpawnVoxelDebris(Vector3 hitPoint) { GameObject debris Instantiate(voxelDebrisPrefab, hitPoint, Quaternion.identity); foreach (Transform piece in debris.transform) { Rigidbody rb piece.GetComponentRigidbody(); if (rb ! null) { rb.AddExplosionForce(explosionForce, hitPoint, explosionRadius, 1f, ForceMode.Impulse); } } Destroy(debris, 3f); }这块逻辑在命中点生成一个碎块组并对每个子刚体施加一次性冲击力。explosionRadius控制碎片飞散的半径ForceMode.Impulse让力瞬间作用而不是持续加速这样碎块会炸开而不是被慢慢推走。对于移动端碎块数量建议控制在 60 左右数量太低会失去打击感太高会瞬间生成大量 MeshRenderer造成掉帧和 GC Spike。2.4 项目中的核心可调参数表拆完模块后需要把散落在各个 MonoBehaviour 里的 public 变量汇总。对这种 30 分钟的幸存者循环最关键的几个参数如下参数位置建议起始值作用WaveSpawnIntervalWaveManager8s每一波敌人的刷新间隔BaseEnemyHealthEnemySettings30敌人基础血量PlayerGoldPerKillGoldManager5击杀一个敌人获得黄金TurretUpgradeCostTurretUpgrade50第一级炮塔升级费用DebrisCountVoxelDebrisSettings60每个敌人碎裂时的方块数量这五个参数决定难度曲线WaveSpawnInterval减小会让场面更忙但不一定更难真正决定压力的是BaseEnemyHealth和TurretUpgradeCost的比例。把这张表打印出来再对着修改就能在几局之内找到想要的节奏而不是每次改一个变量就跑一次完整构建。3. 将 2019 年老工程迁到 Unity 2021.1.17f1Gradle、Input 与依赖冲突3.1 先解决 Gradle 模板与 SDK 版本项目描述明确说游戏是在 2019 年 3 月 9 日之前用 Unity 版本制作的那时候默认的 Android 构建配置和现在有很大差异。第一次用 Unity 2021.1.17f1 打开工程时编辑器会自动做资源导入和脚本升级但 Gradle 模板不会自动升级到配套版本。我一般会在Project Settings - Player - Publishing Settings里勾选Custom Main Gradle Template让编辑器生成Assets/Plugins/Android/mainTemplate.gradle然后把compileSdkVersion提到 30 以上targetSdkVersion保持 30否则新版 Android 设备在安装时可能因为 target SDK 太低而弹兼容警告。生成模板后优先检查dependencies段落因为老工程有时把 AAR 直接写在libs目录里靠 Unity 自动打包这样的工程在升级后往往出现重复打包的问题。把 AAR 的引用方式统一改成implementation(name: xxx, ext: aar)构建链路会更清晰。3.2 处理 AAR 版本冲突与重复类如果保留 2.1 里那一堆 AAR 直接构建第一次往往会在 Gradle 阶段报Duplicate class或者More than one file with OS independent path META-INF/...。原因在于appcompat-1.1.0、core-1.3.2、play-services-base-18.0.1之间的传递依赖互相覆盖。处理思路是先移除与游戏玩法无关的 Yandex、Facebook、AppsFlyer再统一 AndroidX 基础件的版本。dependencies { implementation(name: androidx.core.core-1.3.2, ext: aar) implementation(name: androidx.appcompat.appcompat-1.1.0, ext: aar) implementation(name: androidx.fragment.fragment-1.3.0, ext: aar) implementation(name: androidx.media.media-1.0.0, ext: aar) }这个配置只保留 AndroidX 基础组件把统计和归因 SDK 全部排除在构建之外。参数说明implementation(name: xx, ext: aar)表示以本地 AAR 方式引入Unity 会在生成最终 APK 时把它作为依赖模块打进包里。去掉某个 AAR 之后C# 脚本里如果还用到了对应 SDK 的封装接口运行时会在初始化处报找不到类。所以在改完 Gradle 后要在编辑器里全局搜索YandexMetrica、AppsFlyerSDK、FB.Init这类关键字把初始化逻辑一起删掉否则就会出现“构建成功但一运行就崩”的现象。如果项目里还保留了旧的AndroidManifest.xml需要同时检查是否声明了com.yandex、com.appsflyer、com.facebook相关的 Activity 或权限。删除 AAR 后保留这些声明会让清单合并失败报错信息会指向Manifest merger failed这也是需要单独处理的点。3.3 旧版 Input 与新版 Input System 的切换这个项目的操作逻辑基本依赖Input.GetMouseButton、Input.touches这样的旧输入接口。Unity 2021 虽然仍然兼容旧 Input Manager但部分模板工程会同时开启两个 Input System一旦启用了新的InputSystemPackage但没做兼容映射原来的Input调用会变得不稳定。建议在Player Settings - Active Input Handling里选择Input Manager (Old)或Both而不是只选新的 Input System。如果工程里使用了UnityEngine.Input命名空间迁移后出现冲突优先检查.asmdef里是否引用了不匹配的输入类型。3.4 迁移过程的常见报错对照报错信息原因处理The type or namespace name UnityEditor could not be found运行时脚本引用了编辑器 API用#if UNITY_EDITOR包裹Versioning相关的ScriptingRuntime提示脚本运行时版本不一致在 Player Settings 设为 .NET Standard 2.1Gradle build failedAAR 依赖冲突或 SDK 版本低先移除三方 AAR再调 compileSdkVersionScene could not be loaded场景引用了丢失的 prefab 或脚本Reimport All检查 Console 的 GUID 报错处理这些报错时不要被 Console 窗口里一堆联动错误带着走只看第一条红色错误。改 Gradle 每动一次就执行一次构建不要一次性把模板、AAR、权限全部改掉否则出现问题很难定位是哪一步引入的。提示如果删除 AppsFlyer 的 AAR 后仍然报错通常是mainTemplate.gradle里的implementation被packagingOptions覆盖检查exclude段有没有把对应包路径写死。4. 修复 UX-UI 问题click 响应、Canvas 刷新与多分辨率适配4.1 先定位 UI 卡顿和点击失效的来源项目摘要里说 UX-UI 存在几个问题在实际工程里最典型的是三种升级按钮点击时灵时不灵、金币文本刷新慢、大屏手机上界面挤到一侧或者超出安全区。第一件事是在 Hierarchy 里搜索Text组件然后看脚本中是否有频繁修改text的状态。如果金币数字、波次倒计时在 Update 里刷新那么每帧都会触发一次重建移动端很容易出现掉帧和触控延迟。4.2 用事件驱动代替每帧刷新 UI幸存者游戏里金币数字最容易写成在Update()里做goldText.text gold.ToString()。这会产生字符串拼接和 Canvas 脏标记导致 GPU 每帧重新构建字体纹理区域。正确的做法是把显示逻辑放到数值变化的地方。public class GoldUI : MonoBehaviour { public Text goldText; private int currentGold; public void SetGold(int value) { if (currentGold value) return; currentGold value; goldText.text currentGold.ToString(); } }这里先判断currentGold与value是否相同只有数值真正变化时才会更新文本避免无效重建。参数说明如果使用 TextMeshPro把ToString()的结果直接传给text时会分配少量内存对于每秒几十次以内的刷新可以忽略如果想要更极致用int缓存并用char[]拼接再传给SetText。对波次倒计时这类每一秒才刷新一次的文本用协程每 0.25 秒检查一次也足够。4.3 扩大按钮点击范围炮塔升级按钮常见问题是贴图小但希望玩家手指能更容易点中。Unity UI 的点击范围由Image的 RectTransform 决定默认只有图片边界。最稳妥的实现是给按钮父节点放一个透明的Image并设置raycastTarget true这样点击区域等于父节点大小而不是去改按钮自身。如果不想引入额外层级也可以写一个基于IPointerClickHandler的扩展组件。[RequireComponent(typeof(Image))] public class ClickableArea : MonoBehaviour, IPointerClickHandler { public float padding 20f; public void OnPointerClick(PointerEventData eventData) { RectTransform rt GetComponentRectTransform(); Rect rect rt.rect; rect.xMin - padding; rect.xMax padding; rect.yMin - padding; rect.yMax padding; if (rect.Contains(eventData.position - (Vector2)rt.position)) { // 触发升级逻辑 GetComponentButton().onClick.Invoke(); } } }这里手动把 Rect 向外扩展padding并在OnPointerClick里判断点击点是否落在扩展后的区域。参数说明padding取值不要超过相邻按钮间距的一半否则两个按钮的可点击区域会重叠造成误触。由于OnPointerClick仍然依赖事件系统底层 Image 必须保持raycastTarget true否则事件系统不会把点击事件传给这个组件。4.4 多分辨率适配与 Canvas Scaler 设置老项目通常会把CanvasScaler的Scale Mode设为Constant Pixel Size结果就是在大屏手机上 UI 看起来特别小或贴边。推荐改成Scale With Screen Size参考分辨率设成 1920x1080Match Width or Height设为 0.5。这样横屏和竖屏之间的适配会平滑很多。工程里如果存在按Screen.width和Screen.height直接计算坐标的代码要全部替换成RectTransformUtility.ScreenPointToLocalPointInRectangle否则在异形屏上会出现明显偏移。适配完成后在 Game 视图里切换 16:9、18.5:9、19.5:9 三组分辨率重点看升级按钮、金币显示和波次倒计时三个区域是否重叠或移出安全区。这个验证步骤比代码本身更容易发现问题。5. 移除广告 SDK 后重跑 30 分钟局内验证5.1 从启动流程中清干净第三方初始化在 Gradle 里移除 Yandex、Facebook、AppsFlyer 之后还需要清理 C# 侧的调用。全局搜索这些 SDK 的类名删除对应初始化代码grep -rl YandexMetrica\|AppsFlyerSDK\|FB.Init Assets/ --include*.cs这个命令会在Assets/下递归搜索包含这些关键字的 C# 文件-rl表示只输出文件名。没有输出代表清理干净有输出则按文件路径逐个处理。删除初始化调用后我通常会在编辑器里执行一次Assets - Reimport All刷新程序集缓存避免旧的编译产物在下次构建时被错误链接。这个操作虽然耗时但能避免许多怪异的运行时报错。5.2 用参数曲线控制 30 分钟的紧张感移除 SDK 之后游戏数值不会再受到事件上报的额外开销影响此时可以放心调平衡。30 分钟听起来很长但如果炮塔升级曲线太陡第 20 分钟就能秒杀全场剩余时间只剩下等待太缓又会提前感到无力。常见做法是敌人血量按波数指数增长黄金产出按较低倍率增长造成后期压力逐渐大于玩家成长速度。private int GetWaveEnemyHealth(int waveIndex) { float multiplier Mathf.Pow(1.12f, waveIndex); return Mathf.RoundToInt(baseHealth * multiplier); }这里baseHealth是第 0 波敌人的基础血量waveIndex是当前波数1.12f是每波血量的增长率。改成 1.08 会让后期更轻松改成 1.2 会让玩家在第 10 波左右就陷入绝境。与之配套的黄金曲线应该低于敌人血量成长速率否则玩家的成长速度会快过压力增长游戏失去紧张感。5.3 用 Stopwatch 和 Profiler 验证完整循环最终验证不能只看能不能跑要看 30 分钟里数值是否健康、GC 是否稳定。在编辑器中运行场景打开 Profiler 的 Deep Profile挂机 5 分钟观察 GC Allocation。重点看两个指标碎块生成是否保持在预期数量UI 文本是否还在持续刷新。如果每帧 Allocation 超过 4KB回到 4.2 的方向继续把 Update 改成事件驱动。using System.Diagnostics; public class SessionTimer : MonoBehaviour { private Stopwatch stopwatch new Stopwatch(); private void Start() { stopwatch.Start(); } private void Update() { if (stopwatch.Elapsed.TotalMinutes 30f) { UnityEngine.SceneManagement.SceneManager.LoadScene(EndScene); } } }这段代码用Stopwatch统计实际经过时间达到 30 分钟就切换到结算场景。参数说明Stopwatch不受Time.timeScale影响比用Time.time累计更准确如果游戏有暂停功能记得在暂停时调用stopwatch.Stop()否则暂停时间会计入一局。跑完一整局后导出日志对比每个波次结束时玩家的炮塔 DPS、敌人血量和黄金余额就能看到曲线在哪个时间点开始失真再回到 2.4 的参数表调整直到最后一分钟仍然有压力但又不是必死局。本文还有配套的精品资源点击获取
返回列表