
简介这是一套面向Unity开发者与休闲游戏学习者的少女装扮类游戏完整源码。项目支持2021.3.15f1及以上版本围绕舞会之夜主题实现换装核心玩法覆盖连衣裙、发型、配饰等超200件可交互物品。压缩包共2000个文件约345.65MB以PNG素材、Anim动画、Prefab预制体、Shader与C#脚本为主并集成AdMob等Android广告库含aar/dll/jar方便研究商业化打包流程。已有219人学习下载。资源提供可直接构建运行的Unity工程展现清晰的目录结构场景、控制器、材质、字体与配置文件彼此分离适合学习角色换装系统的状态管理、UI交互及资源组织方式对想快速搭建装扮类游戏或研究Unity与Android原生插件联调的开发者尤具参考价值无论是了解女孩游戏的设计思路还是快速产出换装玩法原型这套源码都值得参考。1. 舞会装扮游戏的核心Unity换装系统的技术选型与数据流装扮游戏表面上是“换件衣服”但真正决定手感的是那条由C#驱动的数据流点击Unity衣柜里的一个条目UI立刻把指令翻译成模型层的一次挂载替换再把选择写进存档。对“Prom Night Dress Up 舞会之夜装扮”这类豪华少女装扮项目Unity同样是最稳的选型Animator骨骼系统让头发、裙摆、饰品实时跟随骨架运动UGUI的ScrollView加Toggle能快速搭出衣柜列表。换装系统稳定与否取决于C#把配置数据翻译成GameObject操作时是否足够干净。下面按一条完整链路展开先在模型层解决怎么穿再在数据层用ScriptableObject和JSON管住整套衣柜然后落地UGUI界面最后处理存档与WebGL发布。适合Unity开发者和想快速搭建可扩展装扮模块的中小团队。2. 从骨骼到挂点Unity装扮系统的模型装配与更换实现2.1 挂点方案对比静态挂点 vs 动态骨骼绑定装扮游戏的模型装配最常用的做法是“挂点Socket”方案在角色骨骼的关键节点下挂一个子物体作为某个装扮槽位的挂载原点。以人形角色为例头发挂Head、上衣挂Hips、裙装也挂Hips但锚点放在腰后、鞋子挂Foot_L与Foot_R。换装时销毁挂点下的旧物体实例化新Prefab到这个挂点一次替换就完成。另一个方向是动态骨骼绑定通过SkinnedMeshRenderer的bones数组把服装网格与角色骨架实时映射适合“全身皮肤”级别的换装比如同一角色换成完全不同的体型。两个方案的取舍如下方案实现成本灵活度性能适用场景静态挂点 Prefab低中部件独立高只需管理实例发型、上衣、裙、鞋、首饰等分部件装扮动态骨骼绑定高需要做boneRemap高支持跨体型略低多一层蒙皮计算角色皮肤化换装、体型差异大的项目Mesh合并 GPU实例化很高低极高千人同屏时才会考虑对Prom Night Dress Up这种角色体型固定、以装饰丰富度为核心的项目静态挂点方案足够。关键在于挂点命名要统一。我一般会约定这样一套目录和节点命名Assets/Art/Characters/Root 角色根Prefab Assets/Art/Characters/Hair 发型Prefab根节点必须是 HairSocket Assets/Art/Characters/Top 上衣Prefab根节点是 HipsSocket Assets/Art/Characters/Skirt 裙子Prefab根节点是 HipsSocket Assets/Art/Characters/Shoes 鞋子Prefab根节点是 Foot_L/Foot_R Assets/Art/Characters/Accessory 项链、耳环Prefab根节点是 NeckSocket每个部件Prefab的根节点挂一个WardrobePart标记脚本声明它属于哪个槽位避免策划或美术在Inspector里把头发拖到鞋子槽位public enum WardrobeSlot { Hair 0, Top 1, Skirt 2, Shoes 3, Accessory 4 } public class WardrobePart : MonoBehaviour { [SerializeField] private WardrobeSlot slot; public WardrobeSlot Slot slot; }参数说明枚举值用连续数字存档时槽位会以int形式写入版本发布后不要调整枚举顺序否则旧存档解析会错位。WardrobePart挂在Prefab根节点换装逻辑读取Slot时只认这个脚本不依赖命名规则所以即使Prefab文件名改了也能正常识别所属槽位。2.2 用Prefab模板管理每个装扮槽位挂点方案下的根Prefab要保留完整的默认状态比如默认发型与默认裙装。每个装扮做成独立Prefab共享同一个挂点命名约定。头发部件Prefab内部一般是PromHair_01 (根) ├── PromHair_01_Mesh (SkinnedMeshRenderer) ├── PromHair_01_Bone (用于挂额外头饰)实例化并挂接的代码放在一个大工具方法里各模块复用public GameObject AttachPart(GameObject partPrefab, Transform socket) { GameObject instance Instantiate(partPrefab, socket); instance.transform.localPosition Vector3.zero; instance.transform.localRotation Quaternion.identity; instance.transform.localScale Vector3.one; return instance; }这段代码里有一个很常见的坑localScale必须强制设为Vector3.one不要沿用Prefab自身的缩放。如果角色根节点的缩放不是1正确做法是把部件Prefab的根缩放也调成1让骨骼缩放自然传导而不是在部件上手动填一个缩放值否则角色动画一旦带动骨骼部件比例就会出现漂移。2.3 换装核心代码穿、脱、替换业务里的换装不是简单销毁实例而是要区分穿、脱、替换三种操作。替换时先脱旧再穿新脱掉时保留空挂点。为了不把代码散落在按钮回调里我习惯把逻辑收敛在一个WardrobeControllerpublic class WardrobeController : MonoBehaviour { [Header(角色骨骼挂点)] public Transform headSocket; public Transform hipsSocket; public Transform footSocket; private readonly DictionaryWardrobeSlot, GameObject _slots new(); private readonly DictionaryWardrobeSlot, Transform _socketMap new(); private void Awake() { _socketMap[WardrobeSlot.Hair] headSocket; _socketMap[WardrobeSlot.Top] hipsSocket; _socketMap[WardrobeSlot.Skirt] hipsSocket; _socketMap[WardrobeSlot.Shoes] footSocket; } public void Replace(ItemConfig config) { Remove(config.slot); if (config.prefab null) return; Transform socket _socketMap[config.slot]; GameObject instance Instantiate(config.prefab, socket); instance.transform.localPosition Vector3.zero; instance.transform.localRotation Quaternion.identity; instance.transform.localScale Vector3.one; _slots[config.slot] instance; } public GameObject Remove(WardrobeSlot slot) { if (_slots.TryGetValue(slot, out GameObject old)) { _slots.Remove(slot); Destroy(old); return old; } return null; } }逻辑说明Replace先调用Remove再实例化新预制体保证一个槽位只有一个活跃模型不会出现两件裙子叠在一起。Remove返回旧实例是为了给“换装特效”留出切入位置——旧实例可以先播一个淡出再真正销毁。参数说明config.slot来自第3章要讲的ItemConfig配置项socket映射表在Awake里初始化后续新增槽位比如翅膀时只需要加枚举值、加一个Transform字段、在_socketMap里补一行。2.4 换装时的三个常见视觉问题换装系统最常见的三个问题我基本每个项目都遇到一遍。第一是Z-fighting闪烁。裙装模型和身体模型在腰臀处重叠摄像机拉远时边缘像素剧烈闪烁。解决办法是给身体模型加一层略薄的内衬网格或者把身体多边形沿法线方向内缩约0.001米让裙装永远处于外层。第二是材质变黑。SkinnedMeshRenderer在换装后变黑通常是骨骼绑定顺序错误导致法线翻转。排错时点开Inspector里的Bones数组逐个核对其引用是否和角色Skeleton节点对应尤其是boneRemap之后顺序被反转的情况。第三是部件偏移。挂在Hips上的裙装在角色奔跑时会“甩”出去。这种情况下优先检查是否存在两级挂点——例如裙装根挂在Hips下的HipsSocket子级又绕了一层空节点旋转插值重复计算。把挂点层级压平到一层问题一般就消失。这三个问题都属于“只看代码发现不了、必须在场景里盯帧”的类型所以开发阶段要在编辑器里留一个独立的调试场景角色摆成T-Pose方便快速定位是挂点问题还是蒙皮问题。3. 数据驱动用ScriptableObject与JSON管住豪华装扮配置3.1 为什么装扮数据必须和逻辑分离“豪华”意味着装扮数量不会是三件五件而是几十套裙子、十几款发型、多种配色和套装组合。如果每一次新增装扮都要手动拖Prefab、修改按钮回调策划改一次价格程序就要改一次代码这种开发模式在一个内容驱动的装扮游戏里完全走不通。所以从第一天就要建立数据驱动C#代码只关心“怎么穿”不关心“穿的是什么”。对应到Unity的实现数据分两层——编辑器里用ScriptableObject配置运行时序列化成JSON做存档与跨场景传递。这样新增一件裙子只需要新建一个ItemConfig并拖入Prefab代码零改动。这套关系可以归纳为下表数据项存放位置序列化方式变更频率装扮定义ScriptableObject引擎自动序列化版本更新时玩家存档PlayerPrefs / JSONJsonUtility每次换装套装组合SetConfig引擎自动序列化版本更新时临时试穿态内存字典不落盘每次点击3.2 用ScriptableObject定义装扮条目一个装扮条目ItemConfig需要承载的信息包括唯一ID、槽位、显示名、Prefab引用、缩略图、默认状态、解锁价格、可染色方案。定义成ScriptableObject的好处是可以在Inspector里直接看到、直接在资产目录右键创建[CreateAssetMenu(fileName ItemConfig, menuName PromDressUp/ItemConfig)] public class ItemConfig : ScriptableObject { public string itemId; // 存档里只存这个ID public WardrobeSlot slot; // 所属槽位 public string displayName 未命名; public GameObject prefab; // 3D部件Prefab public Sprite thumbnail; // 衣柜列表缩略图 public bool isDefault; // 是否作为该槽位默认装扮 public int unlockPrice -1; // -1表示初始解锁其他值表示售价 public ListColor availableColors; // 可染色配色方案 }字段说明itemId是存档唯一的受信来源建议直接用“类别_作用_序号”的命名规则比如“hair_ponytail_01”不要用中文名也不要用GUID——GUID在版本管理里会变化旧存档会失效。unlockPrice用-1而不是0表示免费是为了区分“价格为零”和“尚未购买”的状态。3.3 运行时装配从配置到穿到身上数据定义好之后需要一个装配层。启动时从Resources或AssetBundle加载所有ItemConfig按槽位分组生成“物品ID → 配置”的查找表public class WardrobeDatabase : MonoBehaviour { [SerializeField] private ItemConfig[] allItems; private Dictionarystring, ItemConfig _index; private Dictionarystring, ListItemConfig _bySlot; private void Awake() { _index new Dictionarystring, ItemConfig(); _bySlot new Dictionarystring, ListItemConfig(); foreach (ItemConfig item in allItems) { _index[item.itemId] item; string slotKey item.slot.ToString(); if (!_bySlot.ContainsKey(slotKey)) _bySlot[slotKey] new ListItemConfig(); _bySlot[slotKey].Add(item); } } public ItemConfig GetById(string itemId) _index.TryGetValue(itemId, out var c) ? c : null; public ListItemConfig GetBySlot(WardrobeSlot slot) _bySlot.TryGetValue(slot.ToString(), out var list) ? list : new ListItemConfig(); }逻辑说明allItems在Inspector里拖入所有装扮配置运行时建立两个索引一个是ID查配置另一个是槽位查列表。UI层向GetBySlot请求“这个槽位能穿什么”穿戴时按当前选中项的itemId向GetById取回配置。参数说明allItems数组在热更新工程里不建议直接放在场景里应改为AssetBundle加载或通过Addressables异步加载否则每次新增装扮都要到场景里再拖一次违背了数据驱动的初衷。3.4 套装与配色原子化与组合套装是装扮游戏的体验加分项选一套“皇家蓝调”上衣、裙子、首饰一起换。实现时不要为套装单独造脚本而是用组合一个SetConfig持有多个ItemConfig引用[CreateAssetMenu(fileName SetConfig, menuName PromDressUp/SetConfig)] public class SetConfig : ScriptableObject { public string setId; public string displayName; public ItemConfig[] items; // 套装内各个部件 public Sprite setIcon; }穿戴套装时遍历items数组逐个调用第2章里WardrobeController的Replace接口。由于Replace本身就做到了每个槽位“先脱再穿”套装切换和单件切换共用一条链路不会出现“穿套装时某件没穿上”的旁路逻辑。配色方案的实现思路是每个部件Prefab里SkinnedMeshRenderer的Material选用带Color属性的材质可用配色先定义在ItemConfig的availableColors里。穿戴完成后遍历Renderer匹配材质属性如“_BaseColor”或“_Color”直接赋值private void ApplyColor(GameObject part, Color target) { Renderer[] renderers part.GetComponentsInChildrenRenderer(); foreach (Renderer r in renderers) { foreach (Material mat in r.materials) { if (mat.HasProperty(_Color)) mat.color target; // URP下换成 _BaseColor } } }参数说明直接改material会有实例化材质的内存开销但装扮游戏单屏角色数量少这个开销可接受如果担心DrawCall变多可以把同色部件合并到同一Atlas或者开启MaterialPropertyBlock。搭配内容型游戏的节奏这套数据驱动的组合方案能做到“加装扮不动逻辑、加套装只拼配置”的迭代速度。4. 衣柜与试穿UGUI装扮界面的交互实现4.1 衣柜UI的结构拆分装扮界的衣柜界面核心结构是一个“左侧列表、右侧预览”的双栏布局。左侧是UGUI横向分类Tab加纵向ScrollView每个Item做成Toggle承载缩略图右侧是RawImage或独立Camera渲染出来的试穿预览。交互逻辑分为三类选分类、选款式、看结果。整个界面的组件划分可以这样对应UI区域组件作用分类TabToggleGroup Toggle切换发型/上衣/裙子等槽位物品列表ScrollView Viewport Content展示当前分类下的缩略图物品格Toggle Image LayoutElement单个装扮条目预览区RawImage 独立Camera实时显示角色试穿效果分类Tab用Toggle Group管理互斥切换分类时刷新ScrollView内容。刷新逻辑不重建整个列表而是复用节点只替换当前可见格子里的Image.sprite与Toggle状态public class WardrobeListView : MonoBehaviour { [SerializeField] private Toggle itemTemplate; [SerializeField] private Transform contentRoot; [SerializeField] private WardrobeDatabase database; [SerializeField] private WardrobeController player; private ListToggle _toggles new(); public void ShowSlot(WardrobeSlot slot) { ListItemConfig items database.GetBySlot(slot); for (int i 0; i items.Count; i) { Toggle toggle GetOrCreateToggle(i); ItemConfig item items[i]; toggle.GetComponentImage().sprite item.thumbnail; toggle.onValueChanged.RemoveAllListeners(); toggle.onValueChanged.AddListener(on OnItemSelected(item, on)); toggle.gameObject.SetActive(true); } for (int i items.Count; i _toggles.Count; i) _toggles[i].gameObject.SetActive(false); } private void OnItemSelected(ItemConfig item, bool selected) { if (selected) player.Replace(item); } }逻辑说明GetOrCreateToggle按索引从池子里取节点避免频繁实例化和销毁listener用RemoveAllListeners再重新添加防止连续切换分类导致的重复回调。每件装扮的选中状态直接写入WardrobeController不保留第二份UI态这样存档和试穿始终以控制器为准。4.2 滑动列表与选中反馈装扮列表和普通列表不同选中态要非常醒目因为“看到一件衣服”和“穿上它”是两个动作。我的做法是给Toggle的选中态加一个描边或缩放动画同时在切换分类时如果当前分类有默认装扮则自动选中否则高亮第一件private void RefreshList(WardrobeSlot slot) { ShowSlot(slot); // 查找默认装扮自动选中 foreach (Toggle t in _toggles) { if (t.isOn) { ItemConfig config t.GetComponentItemHolder().config; player.Replace(config); break; } } }这个逻辑的关键在于“自动选中”要和“点击选中”共用同一条回调链路不要单独写一套初始化代码。否则很容易出现进入页面时角色穿的是存档装扮但列表高亮却指向第一件的情况。Toggle Group还需要注意一个问题Toggle在资源重载时可能残留“全不选”状态。解决办法是在Toggle Group的Allow Switch Off保持关闭并手动保证进入页面时至少一个Toggle处于选中态。4.3 实时试穿预览与摄像机控制实时预览用单独一个相机渲染角色输出到RawImage。因为试穿时玩家经常要转着看摄像机需要提供水平旋转和缩放两个自由度public class PreviewCameraControl : MonoBehaviour { public Transform target; // 角色根节点 public float rotateSpeed 2f; public float zoomSpeed 1f; public float minDistance 1.2f; public float maxDistance 3.5f; private float currentAngle; private float currentDistance 2.2f; private void LateUpdate() { if (Input.GetMouseButton(0)) { currentAngle Input.GetAxis(Mouse X) * rotateSpeed; currentDistance - Input.GetAxis(Mouse ScrollWheel) * zoomSpeed; currentDistance Mathf.Clamp(currentDistance, minDistance, maxDistance); } Vector3 dir Quaternion.Euler(0, currentAngle, 0) * Vector3.back; transform.position target.position dir * currentDistance; transform.LookAt(target.position Vector3.up * 0.9f); } }参数说明缩放范围要根据角色模型高度来调高跟鞋或高发型的角色maxDistance要适当加大避免视角被头发穿模。LookAt时的y轴偏移0.9是一个经验值把视觉焦点放在脸部到胸部之间这个位置对装扮游戏来说观感最自然。开发时还有个细节RawImage所在Canvas的Raycast Target要关掉否则鼠标拖拽旋转会同时触发衣柜按钮的点击表现为“想转角色却选中了另一件衣服”。4.4 扩大按钮点击范围的三个做法“unity 如何扩大按钮的点击范围”是装扮游戏UI里经常被搜的问题。原因很简单头发和裙子的缩略图往往是长条形的而Toggle默认只有Image那么大手指在移动端很难精准点到。第一个做法是写一个改判点击区域的组件[RequireComponent(typeof(RectTransform))] public class ClickAreaExtender : MonoBehaviour, ICanvasRaycastFilter { public Vector2 expandSize new Vector2(20f, 20f); public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera) { Vector2 local; RectTransformUtility.ScreenPointToLocalPointInRectangle( (RectTransform)transform, sp, eventCamera, out local); Rect rect GetComponentRectTransform().rect; rect.xMin - expandSize.x / 2f; rect.yMin - expandSize.y / 2f; rect.xMax expandSize.x / 2f; rect.yMax expandSize.y / 2f; return rect.Contains(local); } }逻辑说明ICanvasRaycastFilter在UGUI的射线检测阶段介入把原Rect外扩后在判断是否命中。这样即使Image本身只有80×80点击范围也能扩到120×120而且不产生额外的透明Image节点不增加重建网格的负担。第二个做法是用LayoutElement配合ContentSizeFitter为每个格子设定最小宽高确保切图无论多窄多细点击区域始终有一个最低阈值LayoutElement le toggle.GetComponentLayoutElement(); le.minWidth 120f; le.minHeight 120f;第三个做法是把预览区角色本身也切成可点区域头发区域点击切换发型分类、裙摆区域点击切到裙装分类。这个在4.3的PreviewCameraControl基础上对RawImage上一层的透明Button用世界坐标换算命中区域再驱动分类切换相当于把“看角色”和“选部位”合并成一次操作。5. 装扮存档与WebGL发布的三个检查要点5.1 装扮存档JSON序列化与PlayerPrefs装扮存档只需要保存“每个槽位穿了哪件衣服”和“配色选择”。最简单的做法是用一个可序列化结构体配合JsonUtility做序列化[Serializable] public class WardrobeSaveData { public string hairId; public string topId; public string skirtId; public string shoesId; public string accessoryId; public Color hairColor Color.white; public Color skirtColor Color.white; }存档与读取public class SaveManager { private const string SaveKey PromDressUp_Save; public void Save(WardrobeSaveData data) { string json JsonUtility.ToJson(data, true); PlayerPrefs.SetString(SaveKey, json); PlayerPrefs.Save(); } public WardrobeSaveData Load() { if (!PlayerPrefs.HasKey(SaveKey)) return new WardrobeSaveData(); string json PlayerPrefs.GetString(SaveKey); return JsonUtility.FromJsonWardrobeSaveData(json); } }注意JsonUtility不支持Dictionary直接序列化所以存档结构用扁平字段而不是字典。字段的默认值处理要小心旧版本存的数据没有accessoryId字段时FromJson会把它留在默认值所以读取后要做一个“字段有效性检查”无效则回退到默认装扮ID。5.2 WebGL发布时IDBFS写入失败的兜底Unity发布WebGL后PlayerPrefs默认走IndexedDB。“unity 发布 webgl 使用 idbfs 写入失败”是常见问题尤其是浏览器清理了站点存储、或用户在无痕模式下运行时。我一般会做这样几件兜底操作一是把关键存档写入校验字段读取时先校验再解析避免IDBFS半写状态导致JsonUtility抛异常。二是初始化时检测PlayerPrefs可写性如果写入失败则切换内存模式并在UI层提示“浏览器存储不可用装扮将不会保存”。三是不要频繁Save——每换一件衣服就调一次PlayerPrefs.Save在WebGL下会明显卡顿因为每次Save都要刷一次IndexedDB。更稳的做法是“换装结束、关闭衣柜时统一存一次”。检查项做法失败现象IDBFS可用性Start时尝试写入一次并读回直接抛异常JSON合法性Save前校验itemId是否在数据库中存在旧存档加载后角色穿错衣服存储上限控制存档大小不要往PlayerPrefs里塞图片浏览器存储配额溢出5.3 性能收尾阴影、合批与Shader最后一个检查点是渲染性能。WebGL目标平台上阴影质量很敏感QualitySettings.shadowDistance建议压到30米以内阴影分辨率用Medium。移动浏览器GPU对Shadow Map带宽非常敏感阴影距离调得过大角色脚下的裙摆阴影会出现大面积的“acne”闪烁。URP项目里把角色穿着的无光照材质换成Unlit或者用Shader Graph打开按需阴影选项可以避免WebGL下由于Shader不支持而自动降级产生的粉色材质。合批方面把预览RawImage单独放一个Canvas并设置Sorting Order低于主UI能让UGUI在做图集重建时少一次遍历启用SRP Batcher后WebGL构建在手机浏览器上的DrawCall通常能压掉三分之一左右。发布前用Development Build Script Debugging在目标浏览器里跑一遍完整试穿流程重点观察Console有没有Shader编译警告——这个警告在本地Windows编辑器中不会出现但在WebGL目标平台下非常常见。本文还有配套的精品资源点击获取