简介:这是一套面向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 换装核心代码:穿、脱、替换
业务里的换装不是简单销毁实例,而是要区分穿、脱、替换三种操作。替换时先脱旧再穿新;脱掉时保留空挂点。为了不把代码散落在按钮回调里,我习惯把逻辑收敛在一个WardrobeController:
public class WardrobeController : MonoBehaviour { [Header("角色骨骼挂点")] public Transform headSocket; public Transform hipsSocket; public Transform footSocket; private readonly Dictionary<WardrobeSlot, GameObject> _slots = new(); private readonly Dictionary<WardrobeSlot, 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 / JSON | JsonUtility | 每次换装 |
| 套装组合 | 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 List<Color> 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 Dictionary<string, ItemConfig> _index; private Dictionary<string, List<ItemConfig>> _bySlot; private void Awake() { _index = new Dictionary<string, ItemConfig>(); _bySlot = new Dictionary<string, List<ItemConfig>>(); foreach (ItemConfig item in allItems) { _index[item.itemId] = item; string slotKey = item.slot.ToString(); if (!_bySlot.ContainsKey(slotKey)) _bySlot[slotKey] = new List<ItemConfig>(); _bySlot[slotKey].Add(item); } } public ItemConfig GetById(string itemId) => _index.TryGetValue(itemId, out var c) ? c : null; public List<ItemConfig> GetBySlot(WardrobeSlot slot) => _bySlot.TryGetValue(slot.ToString(), out var list) ? list : new List<ItemConfig>(); }逻辑说明: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.GetComponentsInChildren<Renderer>(); 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区域 | 组件 | 作用 |
|---|---|---|
| 分类Tab | ToggleGroup + 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 List<Toggle> _toggles = new(); public void ShowSlot(WardrobeSlot slot) { List<ItemConfig> items = database.GetBySlot(slot); for (int i = 0; i < items.Count; i++) { Toggle toggle = GetOrCreateToggle(i); ItemConfig item = items[i]; toggle.GetComponent<Image>().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.GetComponent<ItemHolder>().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 = GetComponent<RectTransform>().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.GetComponent<LayoutElement>(); 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.FromJson<WardrobeSaveData>(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目标平台下非常常见。
本文还有配套的精品资源,点击获取