
简介面向Unity开发者和女生装扮类游戏爱好者这份魔法美甲沙龙项目源码是一套完整的女性向休闲装扮游戏解决方案适用于美妆、换装、模拟经营等场景支持Unity 2018.3.3f1及以上版本。项目围绕美甲玩法展开包含手部装饰与美甲装饰两大模块覆盖9种指甲形状、16色普通指甲油、16款闪亮图案、16张指甲贴、16种图案设计、16颗水钻及16款贴花并搭配48种戒指、手链和纹身装饰品还实现了不同画笔的绘画引擎、撤销操作与拍照场景完整呈现从修剪护理到涂绘拍照的游戏闭环。资源包共1092个文件以C#脚本、Prefab预制体、Animation动画、Animator Controller、材质和PNG贴图为主辅以Android/iOS原生接入文件aar、jar、so、m及AdMob广告SDK配置压缩包整体81.35MB目录结构清晰便于定位逻辑。目前已有288人浏览学习适合需要完整美甲游戏参考实现或女性向休闲游戏原型的开发者直接运行调试与二次扩展。1. 美甲游戏不是“换贴图”Unity 装扮项目的玩法与技术骨架做美甲类装扮玩法多数 Unity 开发第一反应是“做个涂色 Demo”真开工就会碰到三个绕不开的问题手指模型怎么适配不同甲型指甲油颜色怎么在不重做贴图的情况下换色叠加整套“洗手—修型—上色—照灯”的流程用什么状态机才能不掉手感。魔法美甲沙龙这类 Unity 女生装扮项目技术核心并不在建模精度而在 C# 侧的交互编排和资源管理。下面按我组织这类项目时的顺序先把指甲参数化建模和三层渲染说清再给工序状态机的 C# 实现最后落到颜色数据结构与存档读写末尾补两个上线前的优化技巧。适合正在做装扮类玩法的 Unity 客户端也适合接手 Unity C# 源码项目后先摸骨架的工程师。2. 指甲参数化建模BlendShape 定形Shader 三层画甲面装扮项目的模型清单往往很长但美甲玩法真正频繁交互的只有十个指甲盖。项目前期如果按“整只手的静态模型 多套贴图”来做后面每个甲型、每个颜色方案都变成新模型策划改一个参数就要重导一次资源。常见做法是拆出一套指甲盖专用管线指甲盖独立成 mesh形变交给 BlendShape颜色交给材质参数。2.1 模型约定指甲盖独立 meshUV 分区的导出标准我一般会把指甲盖模型放到以手指命名的空物体下局部坐标原点放在指甲根部X 轴指向甲尖。十个指甲盖共用一个材质球贴图是 2048×2048 的图集十个手指分 2 行 5 列每根手指的指甲盖固定占 512×512 的块在这个块内再按下表切分。运行时 C# 只需改公共材质的参数不需要为单根手指创建材质实例。UV 区块U 范围V 范围用途底色区0.00~0.500.00~1.00指甲油主色图案区0.50~0.850.00~1.00印花、贴纸、渐变高光遮罩区0.85~1.000.00~1.00灰度高光 / 粗糙度为什么单独画高光遮罩而不是让美术在底色的 Alpha 里画因为运行时玩家会换底色甲面高光不应该跟着底色一起被换掉。把反光信息独立成灰度图Shader 里直接采样红色通道当 gloss mask后续做“亮面/哑光”指甲油时只需调一个 float 参数。2.2 用 BlendShape 在 C# 里切换方圆甲/杏仁甲美术侧给的甲面一般只有一种基础形状剩下的交给 BlendShape。命名约定是必须的Square、Round、Oval、Almond、Stiletto 一一对应 UI 里的甲型选项。代码里直接用名称匹配而不是硬编码索引因为不同手模的 BlendShape 顺序可能不一致。C# 侧切换代码如下public enum NailShapeType { Square 0, Round, Oval, Almond, Stiletto } public void ApplyShape(SkinnedMeshRenderer nail, NailShapeType shape, float weight 100f) { Mesh mesh nail.sharedMesh; for (int i 0; i mesh.blendShapeCount; i) { string shapeName mesh.GetBlendShapeName(i); bool isTarget shapeName.Contains(shape.ToString()); nail.SetBlendWeight(i, isTarget ? weight : 0f); } }weight 参数支持 0~100 的过渡值做“预览甲型”时可以直接从 0 渐变到 100。这里有一个容易踩的坑SetBlendWeight 写进 Update 会造成 SkinnedMeshRenderer 每帧重新生成网格装饰性预览可以这样用正式确认选择后要把 weight 固定写入一次。为什么不直接换一套高模 mesh一是换 mesh 会把 SkinnedMeshRenderer 的骨骼绑定、材质列表整体替换后续加饰品、加钻饰挂点都要重接一遍二是 BlendShape 过渡在装扮游戏里经常用来做“打磨前/打磨后”的连续形变整 mesh 替换做不出这个效果。2.3 甲面三层混合 Shader底色、图案、高光一次采样把甲面需要的三层画进同一个 Pass比叠三张 UI 图再混合省一次渲染。我只要一个主贴图和一个图案贴图甲面高光直接采样主贴图的高光遮罩区。这是核心采样段属性定义部分省略half4 frag(Varyings input) : SV_Target { float2 uv input.uv; half4 baseTex SAMPLE_TEXTURE2D(_BaseTex, sampler_BaseTex, uv); half4 pattern SAMPLE_TEXTURE2D(_PatternTex, sampler_PatternTex, uv) * _PatternColor; half glossMask SAMPLE_TEXTURE2D(_GlossTex, sampler_GlossTex, uv).r; half3 color lerp(baseTex.rgb, pattern.rgb, pattern.a * _PatternAlpha); color _ClearcoatColor.rgb * glossMask * _Clearcoat; return half4(color, baseTex.a * _BaseAlpha); }参数说明_PatternColor 是玩家选的指甲油颜色_PatternAlpha 是涂刷透明度_ClearcoatColor 一般直接给场景主光源色_Clearcoat 控制亮面强度。pattern.a 当蒙版用美术画图案时不用画彩色只画白色形状和 alpha运行时换色由 _PatternColor 决定同一个花纹就能出多个色号。需要注意的边界_BaseTex 里不能带预乘的暗部否则换深色指甲油时会显得像脏了发光和反射不要写死在烘焙图里否则换色后高光会跟着颜色一起偏。运行时要换色就分材质实例但数量控制在十个指甲盖以内。3. 工序状态机与点击手感从洗手到照灯的全流程 C# 控制美甲玩法真正耗时的是流程而非渲染。玩家要先软化死皮、修剪、打磨、上底油、上色、照灯、再封层每一步都允许回退和中断。这套流程用 Animator 去做状态多了会越改越乱逻辑也散落在动画资产里版本 diff 时很难查。3.1 工序状态机为什么用枚举加协程而不是 AnimatorAnimator 适合表现层的位移和表情切换而工序逻辑有前置条件、进度百分比、中断和重入。用 StateMachineBehaviour 分散写这些约束调试时没法一眼看出“当前阶段为什么进不去”。我采用枚举加协程的状态机配表驱动每个工序的时长和进入限制。一张基础工序配置表长这样阶段配置时长进入限制完成后触发软化12s手模型在盆内进入修型修型0s已选甲型进入打磨打磨8s覆盖率 0.98进入上色上色6s至少涂 1 层进入照灯照灯30s甲面在灯下进入封层状态机主体只需要一个可中断的协程循环private IEnumerator RunStage(NailStage stage, float duration) { if (!CheckEntry(stage)) yield break; currentStage stage; float elapsed 0f; while (elapsed duration) { if (interruptionRequested) { interruptionRequested false; yield break; } elapsed Time.deltaTime; onStageProgress?.Invoke(stage, elapsed / duration); yield return null; } onStageFinished?.Invoke(stage); }用协程而不是 Update 加 switch 的原因协程把单个工序的进入检查、进度、退出条件收拢在约 20 行代码内。Update 里写 switch每加一个工序要动多处分支还要单独管状态生命周期。duration 从配置表读取照灯 30 秒、软化 12 秒这类数值调整不用碰逻辑代码。interruptionRequested 用 bool 而不是直接调用 StopCoroutine是因为 StopCoroutine 后 onStageFinished 不会触发后续工序链就断了。3.2 指尖命中判定射线半径放宽 15% 保手感移动端小屏幕上指甲盖是很小的目标Physics 严格命中会让人点不中。我会给每根指甲加一个 NailPart 组件记录甲面中心和交互半径。点击时用主相机发一条射线命中后把击中点与甲面中心做距离判定半径放宽到预设值的 1.15 倍public bool TryHitNail(Camera cam, Vector2 screenPos, out NailPart hitPart) { Ray ray cam.ScreenPointToRay(screenPos); if (!Physics.Raycast(ray, out RaycastHit hit, 100f, nailLayer)) { hitPart null; return false; } NailPart part hit.collider.GetComponentInParentNailPart(); float dist Vector3.Distance(hit.point, part.Center.position); if (dist part.tapRadius * 1.15f) { hitPart part; return true; } hitPart null; return false; }nailLayer 只给十个指甲盖避免掌心、手背的 collider 一起响应。实际项目里摄像机跟随视角下指甲盖可能与手指本身遮挡我用 hit.collider 的 transform 反查 NailPart而不是假设射线命中物体就是指甲本体这样指腹压住甲面时也能正确命中。UI 侧的同类问题是“扩大按钮的点击范围”。不要把透明区域塞进美术图而是保持 Image 的 Raycast Target 开启在可点击范围下垫一张纯透明 Image 放大 RectTransform尺寸比可见图标大 15%~20%。既不会误伤邻近按钮也避免美术在透明区里画了看不见的硬边。3.3 UGUI 滑动条驱动照灯进度照灯阶段是 30 秒等待纯进度条会让人干等。我给 Slider 加上“手动回退”能力玩家往回拖照灯时间也回退。关键点是区分程序写进度和玩家拖进度private void OnStageProgress(NailStage stage, float ratio) { if (stage NailStage.Cure) progressSlider.SetValueWithoutNotify(ratio); } private void OnSliderValueChanged(float ratio) { if (currentStage ! NailStage.Cure) return; float sliderTime Mathf.Lerp(0f, cureDuration, ratio); if (Mathf.Abs(sliderTime - stageElapsed) 0.1f) stageElapsed sliderTime; }SetValueWithoutNotify 的作用是程序更新时不会再次触发 OnValueChanged否则会出现“程序设值 → 事件回调 → 把 stageElapsed 改回去”的循环。手动拖动只允许在 Cure 阶段生效其他阶段拖 slider 会直接忽略。0.1 秒的容差用来过滤 Slider 自身的精度抖动。Unity 滑动条有个常见坑Slider 的 Fill Area 和 Handle Area 锚点没对齐progress 显示正常但 handle 在两端时数值不是 0 和 1。排查时先看 Slider 的 minValue、maxValue 和 directionLeft To Right 下 Fill Rect 的 anchorMin.x 应为 0。3.4 涂刷覆盖率阈值不读回 RenderTexture涂色阶段如果每帧把 RenderTexture 读回 CPU 判断覆盖率移动端会卡。常见的做法是用“笔刷扫过的面积累加”替代真实像素统计private void AccumulateCoverage(float strokeLength, float brushWidth, float nailArea) { float step (strokeLength * brushWidth) / nailArea; coverage Mathf.Clamp01(coverage step); if (coverage 0.98f) stageCanFinish true; }strokeLength 取笔刷帧间移动距离brushWidth 是笔刷的世界宽度nailArea 在 NailPart 初始化时用 collider 包围盒估算。这个近似值不会精确到 100%所以完成阈值放到 0.98留出浮点误差余量。coverage 同时写进存档结构玩家重进游戏后可以继续没涂完的指甲。用 RenderTexture.ReadPixels 做真实统计只用于截图或调试不放进线上流程。4. 颜色与存档数据层结构体、位图与 JsonUtility 持久化装扮项目的数据层往往被轻视真正接好后反而省事。美甲的颜色选择、饰品布局、完成工序状态都要序列化进本地存档。先从颜色说起。4.1 颜色用 HSV 结构体解决调色脏色和结构体传值问题调色盘如果直接给三个 RGB 滑条玩家拖动时很容易调出又灰又脏的颜色。HSV 更符合直觉H 是色相0~360S 是饱和度V 是明度。运行逻辑里用 HSV 做中间颜色转成 Color 再交给 Shader[Serializable] public readonly struct PolishColor { public readonly float h; public readonly float s; public readonly float v; public PolishColor(float h, float s, float v) { this.h h; this.s s; this.v v; } public Color ToRgb() Color.HSVToRGB(h, s, v); }readonly struct 不是装饰。C# 里如果把结构体作为 in 参数传入方法而结构体方法没有标记 readonly编译器会在调用点生成一次完整拷贝字段只有三个 float 时影响很小但以后加花纹索引、渐变方向等字段in 传值的复制开销和内存占用会一并上涨。标记 readonly 后 in 传值才真正传引用这也是翻这类 C# 源码项目时值得留意的细节。不用 class 是因为存档和切换场景时还要考虑引用生命周期不直接存 Color 是因为它是线性空间的 RGBA调色相插值时容易偏灰。存档时只要存 h/s/v 三个浮点JSON 体积也更小。4.2 钻饰布局用二维位图C# 数组的序列化替代方案每个指甲盖有一个 8×8 的格阵格子里有没有钻、有没有贴纸最适合用位图表达。如果直接用bool[8,8]JsonUtility 序列化后会展开成 64 个字段而一个 ulong 就能装下全部格子还天然支持行镜像[Serializable] public readonly struct AccessoryLayout { public readonly ulong bits; public bool this[int x, int y] { get { int index x * 8 y; return (bits (1UL index)) ! 0; } } }摆放饰品时的遍历逻辑for (int i 0; i 64; i) { int x i 3; int y i 7; if ((layout.bits (1UL i)) ! 0) SpawnAccessory(x, y, accessoryId); }i 3相当于除以 8 取行号i 7相当于取列号两个位运算写起来比乘除稳定。美术配置时一般不在代码里维护 bits而是在编辑器窗口里画 8×8 的 Toggle 矩阵点完换算成整数写入 ScriptableObject 配表。4.3 存档读写JsonUtility 与 persistentDataPath 的最小实现存档结构要与玩法配平甲型索引、颜色 HSV、饰品位图、工序覆盖率都要保存。JsonUtility 不支持 Dictionary所以配饰列表用位图整数展开已经是最好的序列化形态[Serializable] public class NailSaveData { public int version 1; public int shapeIndex; public float[] colorHsv new float[3]; public ulong accessoryBits; public float coverage; public long saveTime; }读写函数public static void SaveNail(string slot, NailSaveData data) { data.saveTime DateTimeOffset.UtcNow.ToUnixTimeSeconds(); string json JsonUtility.ToJson(data, true); string path Path.Combine(Application.persistentDataPath, slot .sav); File.WriteAllText(path, json); } public static NailSaveData LoadNail(string slot) { string path Path.Combine(Application.persistentDataPath, slot .sav); if (!File.Exists(path)) return null; string json File.ReadAllText(path); NailSaveData data JsonUtility.FromJsonNailSaveData(json); return data.version 1 ? data : null; }JsonUtility 要求目标类标记 [Serializable]只有公有字段会被序列化属性不会。float[] 存 HSV、ulong 存位图这两个字段是为了躲开 Dictionary 和二维数组的限制。version 字段用于旧档兼容以后加了“贴纸旋角”就升到 version 2在 LoadNail 里做一次字段迁移。WebGL 发布时Application.persistentDataPath实际落在 IndexedDB也就是 IDBFS 文件系统上。常见问题是写入后立刻刷新页面最后一步落盘可能因为文件系统 API 的异步提交还没完成而丢失。我一般维护一个写队列每次 SaveNail 后静默等待一小段 IO 完成时间或在 OnApplicationPause 时强制统一写盘不要在 Update 里反复写文件。5. 上线前收尾合批方案与成品图导出的两个技巧5.1 用 Texture2DArray 替代“换主贴图”花纹选择界面如果每选一个花纹就 SetTexture 一次同屏指甲盖只要出现两种花纹动态合批就会断开。常见做法是把几十个花纹图合成 Texture2DArray用 MaterialPropertyBlock 传 indexTexture2DArray patternArray new Texture2DArray(256, 256, patternCount, TextureFormat.RGBA32, true); for (int i 0; i patternCount; i) Graphics.CopyTexture(patternTex[i], 0, 0, patternArray, i, 0);注意 Texture2DArray 一旦分配大小和 mipmap 开关就固定了。美术后续新增花纹要控制尺寸一致否则 CopyTexture 会报 region 错误。Shader 侧把 _PatternTex 换成 TEXTURE2D_ARRAY再用 _PatternIndex 作为数组层号采样即可。5.2 展示页导出高像素 PNG 的 C# 流程装扮完成后要生成一张“作品图”给玩家保存。直接截屏会带 UI我通常另开一个相机只渲染指甲与手部输出到 RenderTexture再用 ReadPixels 读回编码RenderTexture rt RenderTexture.GetTemporary(1024, 1024, 0, RenderTextureFormat.ARGB32); Camera cam showcaseCamera; cam.targetTexture rt; cam.Render(); RenderTexture.active rt; var snapshot new Texture2D(1024, 1024, TextureFormat.RGBA32, false); snapshot.ReadPixels(new Rect(0, 0, 1024, 1024), 0, 0); snapshot.Apply(); RenderTexture.active null; cam.targetTexture null; byte[] png snapshot.EncodeToPNG(); RenderTexture.ReleaseTemporary(rt); Destroy(snapshot);ReadPixels 前必须保证 RenderTexture.active 指向 rt否则读出来的纹理是全黑的相机的 targetTexture 用完要还原不然下次进入游戏主相机还会继续往离屏纹理上渲染。1024×1024 在移动端做分享图清晰度足够内存峰值也只有 4MB 左右编码出的 byte 数组可以直接作为社交分享接口的入参。本文还有配套的精品资源点击获取