从化妆跑者源码看Unity动画状态机、无限轨道与UGUI换肤实践

发布时间:2026/9/15 16:14:42
从化妆跑者源码看Unity动画状态机、无限轨道与UGUI换肤实践 简介一份基于C#的Unity数字少女化妆跑酷项目源码面向Unity开发者与休闲游戏创作者适合快速搭建“换装跑酷”玩法并学习完整商业项目结构。项目支持Unity 2019.4.3f1及以上版本同时适配Android与iOS内置无限关卡、2D UI换肤、广告接入等模块便于整体运行调试与二次开发。压缩包共2000个文件约115.61MB主要包含138个C#脚本、50个FBX模型与13个动画文件、61个材质和20个着色器、21个预制体及300余张PNG美术资源能够覆盖从角色控制、关卡生成到UI表现和广告调用的完整链路目录按动画、材质、预制体、脚本、贴图等分类便于定位与修改。化妆刷、睫毛膏、口红等道具动画和得分特效也都包含在内适合做皮肤替换、关卡变体的教学范例整个工程功能拆分完整也可作为模板快速改造成其他题材跑酷游戏。已有77人学习下载适合希望从真实项目中理解移动端跑酷游戏架构、化妆主题资源组织与跨平台发布的开发者。1. 化妆跑者的玩法拆解为什么一个跑酷游戏要带两套状态机拿到这份makeUp runner 化妆跑者源码时我第一反应是去看它的 Animator 资源列表。brush.anim、lipstick.anim、mascara.anim、SwapAnimation.anim这些文件名摆在项目里已经暗示了它不是普通的跑酷玩家在奔跑过程中要切换化妆工具给角色上妆同时还要躲避障碍。这套设计意味着项目里必须同时存在两套状态机——一套管跑酷角色运动一套管化妆工具的替换动画。如果你正在学 Unity 动画状态机、换肤系统或者无限跑酷关卡生成这个源码是很好的样本。支持 Unity 2019.4.3f1 及以上带 2D UI 换肤设计同时打 Android 和 iOS 包适合作为中型 Unity 项目的参考实现。2. 动画资产与 Animator 状态机从 brush.anim 到 SwapAnimation.anim 的切换逻辑2.1 动画资产清单与复用思路项目正文里列出的动画片段就是化妆动作的表现层。我拆解一下各自的定位动画文件推测用途关键点brush.anim拿着刷子上下涂扫循环播放需要和角色手臂骨骼匹配lipstick.anim涂口红动作单次播放后接 idle 或跑步循环mascara.anim刷睫毛单次播放带轻微头部跟随SwapAnimation.anim切换化妆工具短过渡通常 0.1~0.2 秒HitAnimation.anim撞到障碍受击表现会打断当前化妆plus_25.anim/plus_25_idle.anim得分飘字25 分的 UI 弹跳效果由脚本控制播放WobbleSpark.anim化妆完成时的星光屏幕空间特效也可以绑在角色手上ShakingAnimation.anim剧烈抖动可能用于失败或连击反馈hand_anim.anim手部抓握工具作为基础手部循环叠加其他动画2.2 Animator Controller 的组织方式跑酷角色身上通常有两个 Animator一个挂在角色骨架上管全身动作另一个挂在 Canvas 下的 UI 上管飘字和特效。如果是单 Animator那么化妆工具的切换必须复用同一个状态机里的子状态机。常见做法是使用AnimatorOverrideController把同一套状态机里的动画片段替换成不同的化妆工具动画。public class MakeupAnimator : MonoBehaviour { public Animator playerAnimator; public AnimatorOverrideController brushOverride; public AnimatorOverrideController lipstickOverride; public void SwitchToBrush() { // 先用 override controller 替换原 controller再触发触发条件 playerAnimator.runtimeAnimatorController brushOverride; playerAnimator.SetTrigger(Swap); playerAnimator.SetBool(IsMakeup, true); } public void FinishMakeup() { playerAnimator.SetBool(IsMakeup, false); playerAnimator.SetTrigger(Run); } }逻辑说明AnimatorOverrideController的好处是你不必给每个化妆工具单独建一个完整状态机只需要在编辑器里创建多个 override controller把brush.anim、lipstick.anim等分别填进对应的动画槽位。运行时切换 controller 后SetTrigger(Swap)让播放器进入 SwapAnimation 状态再根据当前工具播放后续动画。这段代码里的SetBool(IsMakeup)控制角色是在化妆状态还是奔跑状态状态机的过渡条件就依赖这两个参数。参数设置提醒在 Animator 窗口里状态过渡的Has Exit Time要勾掉否则动画会等当前片段播完才响应切换导致化妆动作明显卡顿。对于brush.anim这种循环动画过渡时要把Transition Duration调成 0.050.1 秒保证动作连续如果是HitAnimation.anim这种一闪而过的受击过渡时长设 0 也没问题。2.3 代码控制动画切换的三条路径除了 override controller这个项目里还可以看到另外两种常用做法。第一种是直接播放剪辑名playerAnimator.Play(brush, 0, 0f);这是最快但最不灵活的方式一旦状态机里没有同名状态就会报错而且无法利用过渡参数做平滑切换只适合调试时验证动画资源是否正常。第二种是使用SetTrigger配合状态机参数这是正式项目里最稳定的方式。在 Animator Controller 里建一个MakeupType整数参数不同的化妆工具对应 0、1、2、3然后在状态转移上判断MakeupType的值。playerAnimator.SetInteger(MakeupType, makeupIndex); playerAnimator.SetTrigger(Swap);第三种是直接把AnimationClip作为资源传给PlayableGraph适合需要混合多个动画的情况比如手部持刷的同时身体还在跑步。var playableGraph PlayableGraph.Create(); var mixer AnimationMixerPlayable.Create(playableGraph, 2); mixer.ConnectInput(0, AnimationClipPlayable.Create(playableGraph, runClip), 0); mixer.ConnectInput(1, AnimationClipPlayable.Create(playableGraph, brushClip), 0);.ConnectInput的第三个参数是权重运行时动态调整权重就能让两种动作融合。不过 PlayableGraph 需要自己维护生命周期在跑酷这种高频切换场景里推荐还是用 AnimatorOverrideController性能和可维护性都更平衡。3. 跑酷核心无限轨道生成、玩家控制与碰撞判定3.1 轨道分段复用与对象池无限跑酷不能真的无限生成 GameObject。这个项目里每段轨道大约 30 米由地板、障碍物、化妆道具、装饰物组成。运行时会维护一个轨道池角色跑过一段后把尾部轨道移动到前方。public class TrackPool : MonoBehaviour { public GameObject[] trackPrefabs; public int poolSize 8; private QueueGameObject pool new QueueGameObject(); void Start() { for (int i 0; i poolSize; i) { int index i % trackPrefabs.Length; GameObject track Instantiate(trackPrefabs[index], transform); track.SetActive(false); pool.Enqueue(track); } } public GameObject GetTrack(Vector3 position) { GameObject track pool.Dequeue(); track.transform.position position; track.SetActive(true); pool.Enqueue(track); return track; } }用队列的原因Dequeue取出最老的一段轨道放到玩家前方后立刻Enqueue回队尾这样每帧只移动一段轨道不需要遍历所有轨道。poolSize设成 8是因为屏幕内最多同时可见 4 段多缓存一倍可以避免边界处出现空白。如果感觉 GC 压力大可以把Instantiate放到Awake里统一做运行时只做坐标搬运。3.2 玩家移动左中右三车道还是自由滑动这个项目的跑酷手感偏向无尽跑酷经典三车道。输入方式用键盘方向键、触摸滑动和鼠标拖动三套接口抽象方便同时测试 Android 和 iOS。public class LaneController : MonoBehaviour { public float laneWidth 2f; public float lerpSpeed 10f; private int currentLane 1; // 0左, 1中, 2右 private Vector3 targetPosition; void Update() { if (Input.GetKeyDown(KeyCode.LeftArrow)) ChangeLane(-1); if (Input.GetKeyDown(KeyCode.RightArrow)) ChangeLane(1); if (Input.touchCount 0) { Touch touch Input.GetTouch(0); if (touch.phase TouchPhase.Began) { float halfScreen Screen.width * 0.5f; int dir touch.position.x halfScreen ? -1 : 1; ChangeLane(dir); } } } void ChangeLane(int direction) { currentLane Mathf.Clamp(currentLane direction, 0, 2); targetPosition new Vector3((currentLane - 1) * laneWidth, transform.position.y, transform.position.z); } void FixedUpdate() { transform.position Vector3.Lerp(transform.position, targetPosition, Time.fixedDeltaTime * lerpSpeed); } }移动不直接用transform.position targetPosition而是用Vector3.Lerp做插值这样玩家能看到角色身体向左或向右倾斜的过程。lerpSpeed控制换道速度10 是比较合适的手感数值太高角色像瞬移太低玩家会觉得转向粘手。三车道的宽度laneWidth要根据摄像机视野调整正交相机时一般设 22.5透视相机则需要配合角色的 Z 轴距离重新计算屏幕宽度。3.3 碰撞体设计与化妆道具拾取碰撞检测建议全部放在子物体上用带有OnTriggerEnter的 Trigger 处理拾取用实体 Box 处理障碍物。角色主碰撞体用 CapsuleCollider避免跑步上下坡时误触地面。道具拾取脚本长这样private void OnTriggerEnter(Collider other) { if (other.CompareTag(Brush)) { makeupSystem.EquipTool(MakeupType.Brush); other.gameObject.SetActive(false); } else if (other.CompareTag(Lipstick)) { makeupSystem.EquipTool(MakeupType.Lipstick); other.gameObject.SetActive(false); } else if (other.CompareTag(Obstacle)) { animator.SetTrigger(Hit); healthSystem.LoseOneHeart(); } }这里有两个细节。第一拾取道具用SetActive(false)而不是Destroy配合前面的轨道池一起复用避免频繁实例化产生 GC。第二障碍物碰撞必须放在子碰撞体上并且父物体关掉Kinematic的 Rigidbody 只保留一个否则多个障碍物碰撞体各自挂刚体时物理引擎会在碰撞瞬间产生大量冗余计算。MakeupType枚举最好用整数存储序列化时不要直接暴露枚举数组否则在多人协作时改枚举顺序会导致存档错乱。4. 换肤与 UI 框架2D UI 如何做到易换肤4.1 UGUI 换肤的核心Sprite 引用拆层项目特点里写了“2D UI 设计易于换肤”。所谓换肤在 UGUI 里就是替换 Image 的 Source Image。但直接把整张图片换掉是最笨的办法。这个项目拆成了三层背景层、人物立绘层、按钮图标层。每一层都对应一张独立的 Sprite运行时只需要替换对应层级的 Image.sprite。为了把“换皮肤”做成数据驱动我一般会在项目里放一个SkinConfig的 ScriptableObject[CreateAssetMenu(fileName SkinConfig, menuName MakeupRunner/SkinConfig)] public class SkinConfig : ScriptableObject { public string skinName; public Sprite background; public Sprite character; public Sprite buttonNormal; public Sprite buttonPressed; public Color themeColor Color.white; }4.2 运行时换肤的 C# 实现换肤系统需要扫整个 UI 树里所有带 Image 的节点按节点名字映射到对应槽位。为了避免递归查找造成的性能损失可以用字典缓存引用。public class SkinManager : MonoBehaviour { public Image[] images; private Dictionarystring, Image imageMap; void Awake() { imageMap new Dictionarystring, Image(); foreach (Image img in images) { imageMap[img.gameObject.name] img; } } public void ApplySkin(SkinConfig config) { imageMap[BgImage].sprite config.background; imageMap[CharacterImage].sprite config.character; imageMap[BtnNormal].sprite config.buttonNormal; imageMap[BtnPressed].sprite config.buttonPressed; // 主题色只改带特定脚本的节点避免全部 Image 变色 foreach (var item in imageMap) { if (item.Value.GetComponentThemeColorTag() ! null) { item.Value.color config.themeColor; } } } }ThemeColorTag是自定义的标记组件用来标识哪些 Image 参与主题色变化。这样做的好处是换肤时不会误改头像、血条之类不该变色的图片。很多 Unity 开发者会忽略一点Image.color是整体叠加色如果 Sprite 本身是白色图颜色叠加没问题如果 Sprite 已经有颜色叠加后会出现偏色。因此换肤素材尽量要求美术输出白色底图由程序在运行时着色。4.3 一个容易被忽略的点扩大 UGUI 点击范围热词里频繁出现“unity world ui 无遮挡”和“unity 如何扩大按钮的点击范围”。跑酷项目里暂停按钮、换肤按钮往往做得比较小在手机上很难点准。常见方案是给按钮加一层透明的Image作为点击热区替换targetGraphic。但更干净的做法是写一个ButtonClickScaler在 OnRectTransformDimensionsChange 里调整碰撞区域[RequireComponent(typeof(Button))] public class ButtonClickScaler : MonoBehaviour { public float expandSize 20f; private RectTransform rect; void Awake() { rect GetComponentRectTransform(); Vector2 size rect.sizeDelta; rect.sizeDelta new Vector2(size.x expandSize, size.y expandSize); } }这个脚本在 Awake 里把按钮的实际点击区域扩大 20 像素视觉上按钮图形不变但手指触碰范围变大。注意它只对 UGUI 的 Button 有效因为 Button 的点击射线检测依赖于 RectTransform 的尺寸。如果你用 EventSystem 里的PhysicsRaycaster做 3D 物体点击就需要扩大的是 Collider 的尺寸。4.4 广告接入与平台差异项目特征里写了 Unity 广告和双平台支持。广告封装上要注意 Android 和 iOS 的初始化时机差异。Android 上可以等应用启动后再初始化广告iOS 上最好在DidFinishLaunching之后直接调用初始化避免部分广告 SDK 因为主线程阻塞导致初始化失败。插屏广告的显示时机建议放在“死亡后点击复活”的按钮事件里而不是直接放在死亡回调中否则玩家会感觉被广告打断。激励视频回调里要记得用MainThreadDispatcher切回主线程再恢复游戏计时器。5. 从源码到可运行的 APKUnity 版本兼容与构建排错5.1 在 2019.4.3f1 上打开项目的标准流程项目支持 2019.4.3f1 及以上。第一次打开时大概率会遇到两个问题一是 Unity 版本不匹配导致脚本编译失败二是包管理器里缺少模块。推荐流程如下# 先确认本地 Unity 版本 Unity -version # 用命令行打开项目这样能看到完整编译日志 Unity -projectPath ./makeUpRunner -logFile - 21 | grep error打开后在Player Settings里确认两项Scripting Runtime Version设为.NET 4.x EquivalentApi Compatibility Level设为.NET Standard 2.0。2019.4 默认就是这两个值但如果之前用更高版本工具打开过项目可能会被改成更新配置需要手动切回来。5.2 常见报错与处理构建 Android 或 iOS 时常见的报错是 Gradle 版本不兼容。Unity 2019.4 自带的 Gradle 是 5.x如果你本地 Android SDK 环境变量指向了新版 Gradle需要到Assets/Plugins/Android/mainTemplate.gradle里检查buildToolsVersion是否匹配。这里给一个可用的模板片段android { compileSdkVersion 28 buildToolsVersion 28.0.3 defaultConfig { minSdkVersion 19 targetSdkVersion 28 } }如果遇到gameassembly.dll相关的报错那是你在 Unity 2020 以上版本打开过项目导致 Il2Cpp 生成文件残留。解决办法是删除Library/Bee目录再重新导入项目。gameassembly.dll是 Il2Cpp 编译后的程序集2019.4 默认用的是 Mono不需要这个文件直接清掉缓存即可。5.3 验证动画与手感的调试技巧要快速验证动画切换是否正常不要反复按 Play 看效果。在编辑器里用AnimationWindow播一遍SwapAnimation.anim确认它的曲线只影响手部骨骼而不是整个角色。如果发现整个角色都跟着抖说明动画文件里录制了根骨骼的关键帧需要把 Root Transform Position Rotation 的Bake Into Pose勾上。跑酷手感调试时我习惯把Time.timeScale在 Inspector 里手动调成 0.3。此时角色移动变慢可以清楚看到 LaneController 的 Lerp 是否出现抖动也能看到障碍物碰撞触发时机是否合理。如果timeScale 0.3时碰撞箱明显早于视觉模型碰到障碍说明碰撞体放在了错误子物体上需要调整 Collider 中心偏移量。另外注意OnTriggerEnter只在刚体处于运动状态时可靠如果角色 Z 轴移动是在 Update 里用Translate做的要确保 Rigidbody 没有勾选Interpolate否则会出现穿模漏检。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询