
简介这是一份完整的Unity鱼类卡通装扮游戏C#源代码包基于Unity 5.3.2p2及以上版本适合休闲换装手游开发者与初级Unity工程师学习参考。资源压缩包共包含2003个文件整体大小约99.57MB其中213个C#脚本负责核心玩法与交互逻辑311个PNG图片提供鱼体、鱼尾、鱼鳍等美术素材40个预制体用于搭建游戏场景配合材质、着色器、动画与音频文件形成完整可运行项目。该资源目前已有64人浏览学习适合需要研究完整休闲游戏框架或商业广告接入方案的开发者。游戏内置多部位装扮系统与鱼海水疗沙龙主题并集成Admob、Chartboost、Vungle等广告SDK以及谷歌分析支持Android、iOS、Windows跨平台部署。通过阅读源码可掌握装扮界面的数据管理、广告组件的调用流程、跨平台打包配置及声音集成方式适合直接作为二次开发的基础模板。1. Finding Fish Makeover寻找鱼大变身换装玩法到底在叠多少系统在同一条鱼上同样一只卡通鱼换顶帽子、换个尾鳍、加条围巾玩家就会觉得“这是我的鱼”。很多照着装扮玩法去做Unity项目的人做到一半翻车的位置往往不在美术而在三个工程点装扮槽位的数据结构、挂点与骨骼的绑定规范、UI和存档的联动节奏。Finding Fish Makeover寻找鱼大变身这个Unity鱼类卡通装扮游戏C#源代码方案拆开看就是把这三件事做成清晰可复现的代码骨架。它适合想做换装、装扮玩法又不想把坑重新踩一遍的Unity开发者也适合拿来做卡通风养成游戏的原型底座。先把数据结构和挂点纪律定明白再谈游动、渲染和存档后面接什么玩法都不会散。2. 装扮数据与挂点规范先让鱼“穿得住”衣服2.1 先定装扮部位枚举把“能穿在哪”锁死成代码装扮系统最容易失控的地方是“什么都能挂在鱼身上”这句话。美术今天加一个背鳍贴花明天加一个嘴部领结如果代码里没有部位概念最后就变成一堆散装GameObject到处挂。我一般会先从部位枚举开始把一条鱼身上能打扮的位置一次性定义完后续所有资源、UI、存档都围绕这组枚举走。// 定义一条鱼身上可装扮的部位一个部位对应一个挂点 public enum EquipSlot { Head, // 头顶帽子、皇冠、潜水镜 BackFin, // 背鳍颜色、贴花 Tail, // 尾鳍替换整条尾巴模型 BodyDecal, // 躯干花纹 MouthProps // 嘴部领结、鼻环这类小挂件 } [CreateAssetMenu(fileName NewFishItem, menuName FindingFish/装扮项)] public class FishItem : ScriptableObject { public string itemId; // 全局唯一ID存档、解锁判断都靠它 public string displayName; // UI显示名不做逻辑键 public EquipSlot slot; // 这个装扮挂到哪个部位 public GameObject prefab; // 要实例化的装扮模型 public Sprite icon; // 面板里的图标 public bool unlockedByDefault; // 初始解锁否则需要收集 }这一段代码里itemId是整条链路的钥匙存档里只存IDUI图标从ScriptableObject读换装时用ID找prefab。displayName只给玩家看千万别拿中文名当ID否则改个错别字就是一次存档迁移事故。CreateAssetMenu是一个很轻量的Unity扩展写法美术或策划可以在编辑器里右键直接生成装扮项配置不用碰代码这个习惯对中小团队特别值。枚举与ScriptableObject的组合让“新增一个装扮”从改代码变成加一条配置资源。新增一个部位则需要加枚举值、加挂点数组、加存档字段要同时改三处所以部位在立项初期一次定好后面尽量只加内容不加槽位。2.2 挂点自动对齐为什么换上去的帽子会悬空、会漂移装扮模型放进鱼身上最常出现的现象是帽子悬浮在头顶上方好几厘米或者围巾在游动时甩到身体里去。原因通常不是美术建模差而是挂点时没有重置本地变换。每个装扮prefab在建模软件里的原点位置不一样如果直接SetParent到一个骨骼节点下旧的世界坐标偏移会被保留穿上就乱。// 把装扮模型挂到骨骼/空节点下并重置本地变换 public void AttachToBone(GameObject piece, Transform slotBone) { piece.SetActive(true); piece.transform.SetParent(slotBone, false); piece.transform.localPosition Vector3.zero; piece.transform.localRotation Quaternion.identity; piece.transform.localScale Vector3.one; }SetParent的第二个参数传false很关键它的意思是“保持本地坐标不试图补偿世界坐标”。配合后面三行把本地位置、旋转、缩放全部归位装扮模型就会严格贴合挂点。如果建模时原点实在没法统一需要在prefab上记一个补偿偏移等挂完再叠加上去但不要把这个偏移藏在场景随意调的Transform里否则换一台机器重新导入就丢了。参数取值作用worldPositionStaysfalse让模型直接服从挂点的局部坐标系localPositionVector3.zero让模型原点与挂点原点重合localRotationQuaternion.identity不继承任何来源的旋转偏移localScaleVector3.one防止父骨骼缩放被额外叠加挂点的放置也有一点纪律鱼是不断游动的对象头、背、尾都在动挂点不要放到鱼根节点下要放到对应骨骼的子节点。比如帽子挂到头骨骼尾鳍替换物挂到尾骨骼这样鱼转弯、摆尾时装扮能跟着骨骼走而不是像贴在空气里。2.3 一键换装把“卸旧、上新”做成同一个入口换装本身不复杂复杂的是“所有入口都走同一条路”。无论是玩家点UI、随机生成、还是新手引导直接穿都应该调用同一个方法。如果每条路径自己写一套Instantiate和Destroy后期排查的时候会发现有的地方漏销毁有的地方重复创建。public class FishDresser : MonoBehaviour { public Transform[] slotBones; // 按 EquipSlot 顺序填 private readonly DictionaryEquipSlot, GameObject liveItems new(); public void EquipItem(FishItem item) { // 先卸掉同槽位旧装扮 if (liveItems.TryGetValue(item.slot, out var old)) { Destroy(old); liveItems.Remove(item.slot); } // 实例化新装扮并对齐挂点 var bone slotBones[(int)item.slot]; var piece Instantiate(item.prefab, bone, false); piece.transform.SetParent(bone, false); piece.transform.localPosition Vector3.zero; piece.transform.localRotation Quaternion.identity; liveItems.Add(item.slot, piece); } }这段代码把“拆旧换新”收敛到一个方法里slotBones数组下标与EquipSlot枚举顺序一一对应。如果枚举插入了新值这里的数组长度和顺序也要跟着调否则会出现帽子戴在尾巴上的诡异情况。运行期用Dictionary是安全的它只是不能被JsonUtility直接序列化存档层另想办法后面第4章会讲到。性能上频繁Instantiate和Destroy会对GC有压力换装过程中偶发掉帧。后续进阶可以把实例换成对象池但第一步先把逻辑入口统一这个结构后面所有扩展都靠得住。3. 让鱼游起来速度获取、摆尾动画与卡通渲染三件套3.1 用Rigidbody控制游动用velocity读取当前速度装扮是静态的鱼是动态的。很多换装项目做完发现“鱼看起来像标本”问题出在运动控制用了Transform位移导致尾巴、鱼鳍没有受力感。常见做法是给鱼挂Rigidbody用物理速度驱动运动动画再从速度里取参数而不是用Update直接改位置。[RequireComponent(typeof(Rigidbody))] public class FishMover : MonoBehaviour { public float cruiseSpeed 2.2f; // 巡航速度 public float turnSpeed 3.0f; // 转向灵敏度 private Rigidbody rb; private void Awake() { rb GetComponentRigidbody(); rb.useGravity false; // 鱼不受重力防止下沉 } private void FixedUpdate() { var forward transform.forward; var targetVelocity forward * cruiseSpeed; // 保持原有Y轴速度缓慢归零避免追逐时上下乱晃 targetVelocity.y Mathf.Lerp(rb.linearVelocity.y, 0f, Time.fixedDeltaTime * 2f); rb.linearVelocity Vector3.Lerp(rb.linearVelocity, targetVelocity, Time.fixedDeltaTime * turnSpeed); } // 给尾巴摆动等动画读取当前游动速度 public float CurrentSpeed rb.linearVelocity.magnitude; }这里用rb.linearVelocity而不是直接给Transform.position做增量是因为Rigidbody的物理积分会自动处理碰撞和反弹鱼撞到水草或边界时不会被直接推穿场景。查询“unity物体速度怎么获取”时核心答案就是这个属性Rigidbody.linearVelocity返回一个Vector3用magnitude拿速率用normalized拿方向。注意Unity 6里老API velocity改名成了linearVelocity老工程导入新版本后编译会提示你迁移。水下时间节奏调整要小心Time.timeScale。如果做关卡暂停或慢动作Time.timeScale影响Update和增量时间但FixedUpdate仍按固定步长跑。你可以在r目标的加速度、转向速度上乘一个自定义scaleFactor不要直接拿Time.timeScale乘物理量否则物理步进会变得很怪。3.2 尾巴摆动用Sin波做程序化动画替美术省一版骨骼动画卡通鱼的尾巴和鱼鳍最适合用程序化方式驱动。因为换装游戏里尾鳍是可替换的如果每套尾巴都让动画师做一套骨骼动画资源量会爆炸。程序化摆尾的好处是新prefab挂到同一个尾骨骼下逻辑完全复用不需要逐套匹配动画。public class FishTailAnimator : MonoBehaviour { public Transform tailBone; // 尾骨骼 public float speed 1.4f; // 摆动频率 public float amplitude 12f; // 摆动幅度度 public FishMover mover; // 用于读取速度 private void Update() { float phase Time.time * speed; float angle Mathf.Sin(phase) * amplitude; tailBone.localRotation Quaternion.Euler(0f, angle, 0f); } }参数建议区间说明speed1.02.0越快越亢奋慢速游时降到0.8amplitude818度幅度太大像抽搐太小像困了与速度联动amplitude mover.CurrentSpeed * 2f让鱼加速时摆尾更猛这里有一个实用技巧当鱼从静止到启动CurrentSpeed从0涨到巡航速度时如果幅度直接跳变会看到尾巴“突然甩起来”。给保存一个当前振幅字段用Mathf.Lerp每帧逼近目标值启动和停止会有非常自然的过渡。这就是“动画参数不要直接赋值要尽量平滑过渡”这条经验的来源。如果你手里的鱼已经有一套手工骨骼动画用Animator驱动更稳妥但要注意一个坑挂点必须放在Animator控制的骨骼层级里。如果把装扮模型挂到非动画骨骼节点动画每帧会把骨骼拉回原姿势挂件就会抖动或错位。程序化方案和Animator方案二选一不要混着用。3.3 卡通材质与Shader选型想要二次元感先解决渲染管线统一鱼的卡通感一半靠贴图一半靠材质。很多项目在本地画得好好的一到手机或者别的机器上鱼身变粉、装扮变紫十有八九是渲染管线不一致。Built-in管线和URP的shader关键字完全不同同样一张材质球换用管线后Shader找不到对应Pass就会显示粉色。想得到“二次元感”常见做法是基础色贴图加渐变Ramp再加一圈描边。如果不想一开始就写完整卡通Shader可以在URP Lit里把Smoothness拉到0用纯色贴图配合描边实现“像动画片”的效果。描边有两种做法后处理描边和Shell描边。Shell描边对静态封闭模型非常稳但对鱼鳍这类薄片会丢一半边。// Shell描边核心Pass正面正常渲染背面外扩成描边壳 Pass { Cull Front CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc float _OutlineWidth; fixed4 _OutlineColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert(appdata v) { v.vertex.xyz normalize(v.normal) * _OutlineWidth; v2f o; o.pos UnityObjectToClipPos(v.vertex); return o; } fixed4 frag(v2f i) : SV_Target { return fixed4(_OutlineColor.rgb, 1); } ENDCG }这个Pass原理是沿法线方向把顶点往外推一点然后只渲染背面让被推出的“壳”从边缘露出一圈深色线。_OutlineWidth要根据模型大小调一般0.01到0.05之间宽度太大会看到明显破面太小在手机上等于没有。鱼身侧面的背鳍、胸鳍是双面薄片法线外扩会不均匀这类模型建议在材质上单独禁用描边或改用屏幕后处理统一描边别在一个shader里硬扛所有模型。4. 装扮存档与UI换装面板把“穿什么”变成可回放的JSON4.1 装扮存档用JSON结构化存储不要用一堆PlayerPrefs散键玩家换了一身装扮退出重进要能原样穿回来。最省事的做法是每个槽位存一个PlayerPrefs字符串但槽位一多就变成散键查数据、加版本迁移都很痛苦。我推荐用一个可序列化的存档类整体转JSON落盘结构和运行时数据一一对应。// 存档实体只存ID和槽位不存模型引用 [Serializable] public class EquipEntry { public EquipSlot slot; public string itemId; } [Serializable] public class FishSaveData { public int saveVersion 1; // 版本号迁移数据的后悔药 public ListEquipEntry currentEquip new(); public Liststring unlockedItems new(); // 已解锁装扮ID集合 public long lastSavedUtc; }写成List 而不是DictionaryEquipSlot,string是因为JsonUtility不直接支持Dictionary序列化用List可以绕开这个限制代价是查找时要遍历一下装扮条目数量很小性能无所谓。saveVersion字段是非常便宜的保险以后加了新槽位或改了ID规则加载老存档时可以根据版本号走迁移逻辑而不是直接让玩家的鱼变裸奔。存取代码落盘时用文件而不是PlayerPrefspublic class FishSaveSystem { private readonly string filePath Path.Combine(Application.persistentDataPath, fish_save.json); public void Save(FishSaveData data) { data.lastSavedUtc DateTime.UtcNow.Ticks; string json JsonUtility.ToJson(data, true); string dir Path.GetDirectoryName(filePath); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); File.WriteAllText(filePath, json); } public FishSaveData Load() { if (!File.Exists(filePath)) return new FishSaveData(); try { string json File.ReadAllText(filePath); return JsonUtility.FromJsonFishSaveData(json) ?? new FishSaveData(); } catch { // 解析失败时不要直接覆盖旧档改个名保留现场 File.Copy(filePath, filePath .backup, true); return new FishSaveData(); } } }提示正式项目建议先写.tmp临时文件再替换原文件或者保留一份.backup防止写入一半断电导致的整档损坏。这套“后悔药”机制在移动端尤其重要存损坏档的用户流失速度远大于丢一个装扮。4.2 UI换装面板和预览鱼刷新一次不要每帧刷新换装面板的常见翻车点是每次打开面板都把整个列表重新Instantiate一遍然后每次点击又刷一次掉帧明显。实际做法是进入面板时重建一次列表点击装扮只调用FishDresser.EquipItem列表本身不动只更新选中态图标。public class EquipPanel : MonoBehaviour { public Transform itemGrid; // ScrollView 的 Content public GameObject itemButtonPrefab; public FishDresser dresser; public void RebuildPanel(FishItem[] catalog, FishSaveData data) { // 清理旧按钮只在面板打开时执行一次 foreach (Transform child in itemGrid) Destroy(child.gameObject); foreach (var item in catalog) { if (!data.unlockedItems.Contains(item.itemId) !item.unlockedByDefault) continue; var btn Instantiate(itemButtonPrefab, itemGrid); btn.GetComponentImage().sprite item.icon; btn.GetComponentButton().onClick.AddListener(() dresser.EquipItem(item)); } } }按钮上的Image.sprite每次都从icon字段取这里要注意icon必须放进图集。如果一张张散图UI重建时会产生大量DrawCall尤其背包里几十个装扮打开面板瞬间卡一下。图集打包在Unity里用Sprite Atlas管理图标命名和itemId保持一致导出时就不会串图。在高分辨率屏幕上出UI适配问题也是这个环节的常客。Canvas的CanvasScaler用“Scale With Screen Size”模式参考分辨率设为1136x640或1280x720匹配度拉到0.5横屏竖屏布局都不会突然放大缩水。遇到“手机上按钮位置飞了”先查这里别急着调每个图标的坐标。4.3 解锁与收集用List维护已解锁集合给首次解锁留事件出口收集玩法是装扮系统的黏性来源。解锁逻辑只做一件事把itemId加进已解锁列表并落盘。不要用“某个数值达到后直接改UI”这种写法UI是表现层存档才是最底层的事实。public class FishCollection { private FishSaveData data; private FishSaveSystem saveSystem; public event ActionFishItem OnFirstUnlock; // 外部订阅弹窗、特效、音效 public void UnlockItem(FishItem item) { if (data.unlockedItems.Contains(item.itemId)) return; data.unlockedItems.Add(item.itemId); saveSystem.Save(data); OnFirstUnlock?.Invoke(item); } public bool IsUnlocked(FishItem item) { return item.unlockedByDefault || data.unlockedItems.Contains(item.itemId); } }这里把“是否解锁”的判断收敛到FishCollection里UI、随机生成、存档迁移都调用这个接口避免到处写data.unlockedItems.Contains这样的遍历逻辑。OnFirstUnlock事件让首次解锁表现与数据逻辑解耦加弹窗和加音效都不需要改核心代码后面第6章的随机装扮也会复用这层判断。这样一条鱼身上的所有装扮状态都变得可跟踪、可回放。5. 避坑寻找鱼大变身里最容易翻车的5个排查点5.1 换上的装扮秒闪了一下就消失先怀疑挂点再怀疑生命周期现象调用了EquipItem屏幕上能看到装扮闪了一帧然后无影无踪日志里没有报错。原因最常见的是slotBones数组和EquipSlot枚举顺序不一致。比如枚举顺序是Head、BackFin、Tail数组填成了Head、Tail、BackFin装扮被挂到旁边一个动态生成的临时节点上那个节点在初始化流程后续被销毁挂在上面的模型自然跟着消失。另一个常见原因是挂点本身在场景里被其它脚本清理了比如切换场景时清掉了整个鱼预制体。解决给FishDresser加一个运行时调试方法每帧打印当前挂点的parent链路和liveItems数量出现秒闪时切到运行时看谁在销毁节点。同时把挂点统一做成鱼预制体内部固定空节点不要动态创建生命周期跟随鱼本体。5.2 存档“回档”和“明明解锁了重启就没了”现象玩家玩了一晚上收集了十几件装扮第二天打开游戏全部回到初始状态或者部分装扮没有图标但能穿。原因第一类回档是JsonUtility序列化了Dictionar y导致的。字典在JsonUtility里无声无息地输出为空对象存档成功但读出来是空的。第二类是把存档文件写到了Application.temporaryCachePath系统清缓存就丢。第三类更隐蔽加载时catch到异常直接new了一个新存档覆盖了损坏的旧档连现场都没留。解决数据结构按第4章改成List路径统一用Application.persistentDataPath加载失败时先把损坏文件复制成.backup再重建存档保住现场方便查问题。每次Save之后再做一次Load验证用测试代码跑一遍“存-读-断言”流程比人肉重启可靠。5.3 画面掉帧和粒子特效内存泄露换装技能特效必须走对象池现象越多装扮带粒子特效内存曲线越高某几个节点频繁触发GC游动开始一顿一顿。搜索“粒子特效内存泄露unity”能找到大量类似案例根本原因雷同。原因每个装扮技能都在触发时Instantiate一个ParticleSystem的prefab播完后没有回收只是stop和隐藏。ParticleSystem虽然停止了但Mesh网格、材质实例、场景引用还留在内存里。特效数量一多内存只涨不跌GC的压力随之爆表。解决给特效做一个对象池池子初始预创建几个实例播放时从池里取播放结束用协程等onParticleSystemStopped回调再还回去。池子扩容时一次性加多个不要每次单个创建。核心原则是运行期尽量不Instantiate、不Destroy只做SetActive切换。5.4 Unity版本和渲染管线不一致导致材质变粉、挂件错位现象项目从Unity 2021迁移到Unity 6后同一套鱼的材质全部变成粉色部分挂件位置偏移。干脆一个新装的Unity环境里打开老工程就出现一堆材质和骨骼警告。原因Unity 6引入了GPU Skins相关的骨骼处理变化老工程的网格绑定信息需要重新导入同时Built-in管线的shader在不兼容管线下找不到Pass材质球自动回退到错误状态。unity安装新版本后直接打开老项目很多兼容处理要手动触发。解决升级后先跑一次渲染管线升级向导把Built-in转URP或URP转Built-in的Material升级做干净shader引用不要用字符串Find要用直接引用或Addressable管理换版本后字符串路径最容易失效。Unity 6的Rigidbody.velocity改名linearVelocity之类的API变动也要趁升级时统一改完混用新旧API会让物理表现不一致。5.5 挂件抖动穿模鱼鳍和尾巴上的装饰绑点要跟随骨骼现象帽子很稳但挂在尾鳍或背鳍上的装饰一直在抖还会切进鱼身体里。原因这些部位的骨骼本身在持续旋转和位移如果挂件父节点绑在鱼根节点或骨架根节点动画每帧把骨骼拉走挂件还在原位置视觉上就是抖和穿模。另一个原因是挂件自己的localPosition没有归零叠加了骨骼动画的旋转后偏差被放大成一个圆周运动。解决AttachToBone统一重置本地变换挂点放到鳍、尾巴对应的小骨骼子节点上而不是骨架根节点。鱼在转弯、急停时动态骨骼的摆动幅度会突然变大此时多观察几帧如果抖动明显把挂点在骨骼级加一层Lerp阻尼让装饰的跟随稍微延迟一点看起来反而更柔和。6. 进阶配置表驱动随机装扮把生成器做成可复用的C#工具装扮系统的消费点除了手动搭配还有一个收集游戏常用的玩法随机开出随机装扮组合。随机生成的关键不在随机本身而在“只从已解锁池里抽、按权重分布、结果仍走同一个EquipItem入口”。public FishItem PickRandomOutfit(FishItem[] catalog, FishSaveData data) { // 先过滤出已解锁的装扮避免随机结果剧透未解锁内容 var pool new ListFishItem(); foreach (var item in catalog) { if (item.unlockedByDefault || data.unlockedItems.Contains(item.itemId)) pool.Add(item); } if (pool.Count 0) return null; // 权重累计法每个装扮按 weight 占区段长度随机落点落在哪个区段就抽中谁 float total 0f; foreach (var item in pool) total item.weight; float r Random.value * total; foreach (var item in pool) { r - item.weight; if (r 0f) return item; } return pool[0]; }这段生成器不关心UI不直接操作模型只负责返回一个FishItem调用方拿回去继续走FishDresser.EquipItem。给FishItem加一个float weight字段抽到稀有装扮时让OnFirstUnlock事件去播特效和音效玩家会感觉“中奖了”。随机装扮的验证方法很简单做一个开发测试按钮连续抽20次检查是否出现未解锁项、同一槽位是否出现重复、稀有度分布是否符合预期同时开Profile看GC峰值。我自己的经验教训是一开始做同类功能时图省事把随机逻辑直接写在UI回调里随机、换装、特效全搅在一起后来加“整套预设”功能时差点把代码推翻重写。现在所有装扮操作都收敛到FishDresser和FishCollection两个入口随机生成、手动搭配、新手引导都是一行调用。这套数据结构想往PICO这类一体机平台搬也不难数据层不动只换渲染和交互层。希望帮到你。本文还有配套的精品资源点击获取