随笔
主页
  • Unity
  • 前端文章

    • JavaScript
    • Vue
  • 学习笔记

    • 《JavaScript教程》笔记
    • 《JavaScript高级程序设计》笔记
    • 《ES6 教程》笔记
  • 数据库

    • Redis
  • Html 文档
  • CSS 文档
  • Vue 文档
  • TypeScript
  • Golang
  • 版本控制

    • Git 文档
    • Svn 文档
  • 技术文档

    • Markdown语法
    • GitHub技巧
    • Nodejs
  • 分类
  • 标签
  • 时间线
  • 目录结构
  • 配置和约定
  • 主题配置
  • 首页配置
  • front matter
  • 目录页配置
  • 摘要
  • 主题颜色
  • 评论栏
  • Markdown扩展
GitHub
主页
  • Unity
  • 前端文章

    • JavaScript
    • Vue
  • 学习笔记

    • 《JavaScript教程》笔记
    • 《JavaScript高级程序设计》笔记
    • 《ES6 教程》笔记
  • 数据库

    • Redis
  • Html 文档
  • CSS 文档
  • Vue 文档
  • TypeScript
  • Golang
  • 版本控制

    • Git 文档
    • Svn 文档
  • 技术文档

    • Markdown语法
    • GitHub技巧
    • Nodejs
  • 分类
  • 标签
  • 时间线
  • 目录结构
  • 配置和约定
  • 主题配置
  • 首页配置
  • front matter
  • 目录页配置
  • 摘要
  • 主题颜色
  • 评论栏
  • Markdown扩展
GitHub
  • 基础

  • 资源导入

  • 2D

  • Physical2D

  • UGUI

  • Animation

  • Animator

    • Animator 概述
    • Animator组件
    • Animation Clips
    • Animator Controllers
    • Animator窗口
    • 动画状态机
      • 状态机概念
      • 状态机
      • 动画参数
      • 状态机过渡
      • 状态机行为
      • 子状态机
        • 外部过渡
      • 动画层
        • 图层属性设置
      • 动画层同步
      • Solo 和 Mute 功能
      • 目标匹配
      • 反向动力学
      • 根运动 - 工作原理
        • 身体变换
        • 根变换
        • 动画剪辑检视面板 (Animation Clip Inspector)
        • 教程:为“原地就位”的人形动画编写根运动脚本
    • Blend Tree
    • Animator Override Controller
  • Unity
  • Animator
dong
2021-01-16

动画状态机

# StateMachine 动画状态机

角色或其他动画游戏对象通常具有若干不同的动画,这些动画对应于该角色或对象可在游戏中执行的不同动作。例如,角色可以在空闲时轻微呼吸或摇摆,在得到指令时行走,以及从平台上跌落时恐慌地抬起手臂。一扇门可能具有打开、关闭、卡住和撞开动作的动画。Mecanim 使用类似于流程图的可视化布局系统来表示状态机,从而控制需要在角色或对象上使用的动画剪辑并对这些动画剪辑排序。本部分将提供有关 Mecanim 状态机的更多详细信息,并说明如何使用这些状态机。

# 状态机概念

状态机的基本思想是使角色在某一给定时刻进行一个特定的动作。动作类型可因游戏类型的不同而不同,常用的动作包括空闲、行走、跑步、跳跃等,其中每一个动作被称为一种状态。在某种意义上,角色处于行走、空闲或者其它的“状态”中。一般来说,角色从一个状态立即切换到另一个状态是需要一定的限制条件的。比如角色只能从跑步状态切换到跑跳状态,而不能直接由静止状态切换到跑跳状态。角色从当前状态进入下一个状态的选项被称为--状态过渡条件。状态集合、状态过渡条件以及记录当前状态的变量放在一起,形成了一个状态机__。

状态及其过渡条件可以通过图形来表达,其中的节点表示状态,而弧线(节点间的箭头)表示状态过渡。您可以将当前状态视为放置在节点之一上的标记或亮点,然后只能沿箭头之一跳转到另一个节点。

状态机对于动画的重要意义在于用户可以通过很少的代码对状态机进行设计和升级。每一个状态有一个与之关联的运动,只要状态机处于此状态,就会播放此运动。从而让动画师或设计师方便地定义动作顺序,而不必去关心底层代码的实现。

# 状态机

Unity的动画状态机提供了一种纵览角色所有动画剪辑的方法,并且允许通过游戏中的各种事件(如用户输入)来触发不同的动画效果。

动画状态机可以通过 Animator Controller 窗口来创建,这些状态机如下所示:

动画状态机包括动画状态、动画过渡和动画事件,而复杂的状态机还可以含有简单的子状态机。

# 动画参数

动画参数是在 Animator Controller 中定义的变量,可从脚本访问这些变量并向其赋值。这是脚本控制或影响状态机流程的方法。

例如: 可通过动画曲线更新参数的值,然后从脚本访问参数以便可改变音效的音高(就像它是一段动画一样)。同样,脚本可设置被 Mecanim 拾取的参数值。例如,脚本可设置参数来控制混合树。

可使用 Animator 窗口的 Parameters 部分来设置默认参数值(可在 Animator 窗口的右上角进行选择)。这些参数可分为四个基本类型:

  • Float: 浮点型的数字

  • Int: 整型

  • Bool: true或false(由复选框表示)

  • Trigger: 当被过渡使用时, 由控制重置的布尔值参数(以原型按钮表示)

可使用以下 Animator 类中的函数,从脚本为参数赋值

using UnityEngine;
using System.Collections;

public class SimplePlayer : MonoBehaviour {
	
	Animator animator;
    
	// 使用此函数进行初始化
	void Start () {
		animator = GetComponent<Animator>();
	}
	
	// 每帧调用一次 Update
	void Update () {
		float h = Input.GetAxis("Horizontal");
		float v = Input.GetAxis("Vertical");
		bool fire = Input.GetButtonDown("Fire1");

		animator.SetFloat("Forward",v);
		animator.SetFloat("Strafe",h);
		animator.SetBool("Fire", fire);
	}

	void OnCollisionEnter(Collision col) {
		if (col.gameObject.CompareTag("Enemy"))
		{
			animator.SetTrigger("Die");
		}
	}
}

# 状态机过渡

状态机过渡可帮助您简化大型或复杂的状态机。允许对状态机逻辑进行更高级的抽象化。

Animator 窗口中的每个视图都有一个进入 (Entry) 和退出 (Exit) 节点。在状态机过渡期间使用这些节点。

过渡到状态机时使用进入节点。进入节点将接受评估,并根据设置的条件分支到目标状态。通过此方式,进入节点可以通过在状态机启动时评估参数的状态来控制状态机的初始状态。

因为状态机始终具有默认状态,所以始终会有从进入节点分支到默认状态的默认过渡。

随后可添加从进入节点到其他状态的其他过渡来控制状态机是否应以其他状态开始。

退出(Exit)节点用于指示状态机应退出

状态机 中的每个子状态都被视为一个独立且完整的状态机,因此通过使用这些进入和退出节点,可以更简练地控制从顶级状态机到其子状态机的流程。

可将状态机过渡与常规状态过渡混合,因此可在状态之间过渡、从状态过渡到状态机以及从一个状态机直接过渡到另一个状态机。

# 状态机行为

状态机行为是一类特殊脚本。与将常规 Unity 脚本 (MonoBehaviour) 附加到单个游戏对象类似,您可以将 StateMachineBehaviour 脚本附加到状态机中的单个状态。因此可编写一些将在状态机进入、退出或保持在特定状态时执行的代码。这意味着您不必编写自己的逻辑来测试和检测状态的变化。

此功能的一些用例可能包括:

  • 在进入或退出状态时播放声音

  • 仅在相应状态下执行某些测试(例如,地面检测)

  • 激活和控制与特定状态相关的特效

创建状态机行为并将其添加到状态的方式与创建脚本并将其添加到游戏对象的方式非常类似。在状态机中选择状态,然后在检视面板中使用 Add Behaviour 按钮来选择现有的 StateMachineBehaviour(详情查看StateMachineBehaviour脚本API) 或创建新行为。

状态机行为脚本可访问在 Animator 进入、更新和退出不同状态(或子状态机)时调用的许多事件。此外,还有一些事件允许您处理根运动和反向运动学调用。

# 子状态机

角色通常具有包含若干阶段的复杂动作。合理的做法是识别单独阶段并将单独状态用于每个阶段,而不是用单个状态来处理整个动作。例如,角色可能会有一个名为“Trickshot”(花式射击)的动作;在此动作中,角色会蹲下来稳定瞄准,射击,然后再站起来。

虽然这对于控制目的很有用,但缺点是随着添加更多的此类复杂动作,状态机将变得庞大而笨拙。在编辑器中用空白空间在视觉上对状态组进行分隔,可略微化简一下。但是,Mecanim 比这更进一步,允许您将一组状态折叠为状态机图中的单个指定项。这些折叠的状态组称为子状态机。

若要创建子状态机,可右键单击 Animator Controller 窗口中的空白空间,并从上下文菜单中选择 Create Sub-State Machine。子状态机在编辑器中用细长六边形表示以区别于正常状态。

双击六边形时将清理编辑器,让您编辑子状态机,好像它本身就是一个完全独立的状态机。窗口顶栏会显示“示踪导航路径”以指示当前正在编辑的子状态机(注意,可在其他子状态机内创建子状态机,以此类推)。单击跟踪路径中的某项将使编辑器聚焦于该特定子状态机。

# 外部过渡

上所述,子状态机只是一种在编辑器中直观地折叠一组状态的方式,因此在过渡到子状态机时,必须选择要连接到子状态机的哪个状态。

创建过渡之后松手会弹出改面板

当连接到子状态机中之后的状态显示:

Up 状态表示“外部世界”,这是在视图中包含子状态机的状态机。如果添加从子状态机中的状态到 Up 状态的过渡,系统将提示您选择要连接到闭包状态机的哪个状态。

# 动画层

Unity 使用动画层来管理不同身体部位的复杂状态机。相应的示例为,您有一个用于行走/跳跃的下身层,还有一个用于投掷物体/射击的上身层。

可以从 Animator Controller 左上角的 Layers 小部件管理动画层。

单击窗口右侧的齿轮可显示该层的设置。可以通过按小部件上方的 + 来添加新层。

# 图层属性设置

  • Weight: 权重

  • Mask: 遮罩, 指定此层上使用的遮罩

  • Blending: 正在混合

  • Oberride: 覆盖, 表示将忽略其他层的信息

  • Additive 附加/添加, 表示将在先前层之上添加动画。

  • Sync: 异步

  • Timing: 同步

  • IK Pass: IK处理

Layers 侧边栏中显示“M”符号,表示该层已应用遮罩。

# 动画层同步

有时,能够在不同层中复用同一状态机是很有用的。例如,如果想要模拟“受伤”行为,并生成“受伤”状态下的行走/奔跑/跳跃动画,而不是“健康”状态下的动画,您可以单击其中一个层上的 Sync 复选框,然后选择要同步的层。随后状态机的结构便会相同,但状态使用的实际动画剪辑不同。

这意味着同步的层根本没有自己的状态机定义,而是同步层状态机的一个实例。在同步层视图中对状态机的布局或结构所做的任何更改(例如,添加/删除状态或过渡)都是针对同步层的源进行的。同步层的唯一独特更改是每个状态内使用的选定动画。

通过 Timing 复选框,Animator 可调整同步层中每个动画所需的时间(由权重决定)。如果取消选中 Timing,则会调整同步层上的动画。该调整会将动画的长度拉伸到与原始层上的一致。如果选中该选项,则动画长度将在两个动画之间平衡(基于权重)。在两种情况下(选中和不选中),Animator 都将调整动画的长度。如果不选中,则原始层将是唯一母版。如果选中,则采用折中方案。

Layers 侧边栏中显示“S”符号,表示该层是同步层。

# Solo 和 Mute 功能

在复杂状态机中,分别预览状态机某些部分的运行情况是很有用的做法。为此,您可以使用 Mute(静音)/**Solo(独奏)**功能。Mute 表示将禁用过渡。而 Solo 功能将启用过渡,并与源自同一状态的其他过渡有关。您可以从 Transition Inspector 或 State Inspector(推荐)窗口中设置静音和独奏状态(在此窗口中可查看源自该状态的所有过渡)。

独奏的过渡将以绿色显示,而静音的过渡将以红色显示,如下所示:
\

在上述示例中,如果您处于 State 0,只能过渡到 State A 和 State B。

  • 基本的经验法则是,如果勾选一个 Solo,源自该状态的其余过渡将静音。

  • 如果同时勾选 Solo 和 Mute,则 Mute 将优先执行。

已知问题: 控制器图当前并非始终反映引擎的内部静音状态。

# 目标匹配

通常在游戏中可能出现以下情况:角色必须以某种方式移动,使得手或脚在某个时间落在某个地方。例如,角色可能需要跳过踏脚石或跳跃并抓住顶梁。

可以使用 Animator.MatchTarget 函数来处理此类情况。例如,想象一下,您想安排一个角色跳到一个平台的情况,并对这种情况已经有名为 Jump Up 的动画剪辑。首先,您需要在动画剪辑中找到角色开始离地的位置,注意在本示例中,此位置是动画剪辑中标准化时间的 14.1% 或 0.141:

您还需要在动画剪辑中找到角色即将落地的位置,在本示例中,此位置为 78.0% 或 0.78。

凭借此信息,即可创建调用 MatchTarget 的脚本,并可将其附加到模型:

using UnityEngine;
using System;

[RequireComponent(typeof(Animator))] 
public class TargetCtrl : MonoBehaviour {

	protected Animator animator;	
	
	//场景中的平台对象
	public Transform jumpTarget = null; 
	void Start () {
		animator = GetComponent<Animator>();
	}
	
	void Update () {
		if(animator) {
			if(Input.GetButton("Fire1"))		 
				animator.MatchTarget(jumpTarget.position, jumpTarget.rotation, AvatarTarget.LeftFoot, 
                                                       new MatchTargetWeightMask(Vector3.one, 1f), 0.141f, 0.78f);
		}		
	}
}

该脚本将移动角色,使其从当前位置跳跃并以左脚落在目标上。请记住,使用 MatchTarget 函数的结果通常仅在游戏的正确点调用该函数时才有意义。

# 反向动力学

大多数动画是通过将骨架中的关节角度旋转到预定值来生成的。子关节的位置根据父关节的旋转而改变,因此可从父关节包含的各个关节的角度和相对位置来确定关节链的终点。这种构建骨架的方法被称为正向动力学。

然而,从相反视角看待构建关节的任务通常很有用:在空间中选择一个位置后,向后找到一种有效的关节定位方法,使终点落在该位置。如果您希望角色触摸位于用户选定位置的对象或让角色的双脚牢牢扎入不平坦的表面,这种方法可能很有用。此方法称为反向动力学 (IK),可在 Mecanim 中用于_已正确配置的任何人形Avatar骨骼。

要为角色设置 IK,通常要在场景周围放置与角色互动的对象,然后通过脚本(尤其是,诸如SetIKPositionWeight、SetIKRotationWeight、SetIKPosition、SetIKRotation、SetLookAtPosition、bodyPosition、bodyRotation之类的 Animator 函数)来设置 IK

例如实现呢上图抓住一根柱子的实现, 我们从拥有有效的 Avatar角色开始。

下一步创建 Animator Controller,使其包含该角色的至少一个动画。然后,在 Animator 窗口的 Layers 面板中,单击层的齿轮设置图标,并选中弹出框中的 IK Pass 复选框。

接下来,为其附加一个实际处理 IK 的脚本,将此脚本命名为 IKControl。此脚本为角色的右手设置 IK 目标,并设置角色的观察位置以使其观看所持物体:

using UnityEngine;
using System;
using System.Collections;

[RequireComponent(typeof(Animator))] 

public class IKControl : MonoBehaviour {
	
	protected Animator animator;
	
	public bool ikActive = false;
	public Transform rightHandObj = null;
	public Transform lookObj = null;

	void Start () 
	{
		animator = GetComponent<Animator>();
	}
	
	// 用于计算 IK 的回调
	void OnAnimatorIK()
	{
		if(animator) {
			
			// 如果 IK 处于活动状态,请将位置和旋转直接设置为目标。
			if(ikActive) {

				// 设置观察目标位置(如果已分配)
				if(lookObj != null) {
					animator.SetLookAtWeight(1);
					animator.SetLookAtPosition(lookObj.position);
				}    

				// 设置右手目标位置和旋转(如果已分配)
				if(rightHandObj != null) {
					animator.SetIKPositionWeight(AvatarIKGoal.RightHand,1);
					animator.SetIKRotationWeight(AvatarIKGoal.RightHand,1);  
					animator.SetIKPosition(AvatarIKGoal.RightHand,rightHandObj.position);
					animator.SetIKRotation(AvatarIKGoal.RightHand,rightHandObj.rotation);
				}        
				
			}
			
			// 如果 IK 未处于活动状态,请将手和头部的位置和旋转设置回原始位置
			else {          
				animator.SetIKPositionWeight(AvatarIKGoal.RightHand,0);
				animator.SetIKRotationWeight(AvatarIKGoal.RightHand,0); 
				animator.SetLookAtWeight(0);
			}
		}
	}    
}

因为我们不打算让角色的手伸到物体内部中心(圆柱的轴心点),所以放置一个空的子对象(在此情况下,命名为“Cylinder Grab Handle”(圆柱抓握把手)),确保手应该放在圆柱上,并将其相应旋转。然后,这只手瞄准此子对象。

然后,应将此“抓握把手”游戏对象分配为 IKControl 脚本的“Right Hand Obj”属性

在此示例中,我们把观察目标设置为圆柱本身,因此即使把手靠近底部,角色也会直接看向物体的中心。

进入播放模式,然后应该会看到 IK 变为现实。单击 IKActive 复选框时,观察角色抓取和放开物体,并尝试在播放模式中四处移动圆柱以观察手臂和手跟随物体移动的情况。

# 根运动 - 工作原理

# 身体变换

身体变换是角色的质心。它用于 Mecanim 的重定向引擎,并提供最稳定的移位模型。身体方向是相对于 Avatar T 形姿势的下身和上身方向的平均值。

身体变换和方向存储在动画剪辑中(使用 Avatar 中设置的肌肉定义)。它们是动画剪辑中存储的唯一世界空间曲线。所有其他:肌肉曲线和 IK(反向动力学),目标(手和脚)都是相对于身体变换进行存储的。

# 根变换

根变换是身体变换在 Y 平面上的投影,并在运行时计算。在每一帧都会计算根变换的变化。变换的此变化随后应用于游戏对象以使其移动。

# 动画剪辑检视面板 (Animation Clip Inspector)

动画剪辑编辑器设置 - Root Transform Rotation、Root Transform Position (Y) 和Root Transform Position (XZ) - 可让您从身体变换控制根变换的投影。根据这些设置,身体变换的某些部分可能会转移到根变换。例如,您可以决定是否希望运动 Y 位置成为根运动(轨迹)的一部分或姿势(身体变换)的一部分(称为 Baked into Pose)。

  • Root Transform Rotation

    • Bake into Pose:方向将保持在身体变换(或姿势)上。根方向将是常量,增量方向将是标识。这意味着游戏对象根本不会被动画剪辑 (AnimationClip) 旋转。

      只有具有相似的开始和停止根方向的动画剪辑才应使用此选项。您将在 UI 上看到绿色指示灯,表示动画剪辑是合理的候选项。合适候选项将是直走或奔跑。

    • Based Upon:此选项用于设置剪辑的方向。使用 Body Orientation 会将剪辑定向以跟随身体的向前矢量。此默认设置适用于大多数动作捕捉 (Mocap) 数据(如行走、奔跑和跳跃),但是对于诸如扫射一样的运动(此类情况下,运动垂直于身体的向前矢量),此设置将会失败。在这些情况下,可使用 Offset 设置来手动调整方向。最后还可选择 Original 设置,此设置会自动添加位于导入的剪辑中的创作偏移量。此设置通常与关键帧数据一起使用以遵循美术师设定的方向。

    • Offset:为 Based Upon 选择该选项时用于输入偏移量。

  • Root Transform Position (Y): 此部分涉及的概念与 Root Transform Rotation 部分所述的概念相同。

    • Bake Into Pose:运动的 Y 分量将保留在身体变换(姿势)上。根变换的Y分量将是常量,增量根位置 Y 将为 0。这意味着此剪辑不会更改游戏对象高度。您再次看到绿色指示灯,表示剪辑是将 Y运动烘焙到姿势中的合理候选项。

      大多数动画剪辑将启用此设置。只有会改变游戏对象高度的剪辑才应该将此设置关闭,比如向上跳或向下跳。

    • 注意:Animator.gravityWeight 通过 Bake Into Pose 位置 Y 驱动。启用时,如果 disabled = 0,则 gravityWeight = 1。在状态之间过渡时会为剪辑混合 gravityWeight。

    • Based Upon:与 Root Transform Rotation 的情况相似,可选择 Original 或 Mass Center (Body)。此外还有一个 Feet 选项对于会改变高度的动画剪辑(禁用 Bake Into Pose)而言非常方便。使用 Feet 时,对于所有帧,Root Transform Position Y 将与位置最低的脚 Y 匹配。因此,混合点始终保持在脚周围,从而防止在混合或过渡时发生浮动问题。

    • Offset:与 Root Transform Rotation 的情况相似,可利用 Offset 设置来手动调整动画剪辑高度。

  • Root Transform Position (XZ): 同样,此部分涉及的概念与 Root Transform Rotation 和 Root Motion Position (Y)部分所述的概念相同。

    • Bake Into Pose 通常用于“空闲”状态,此情况您会希望将增量位置 (XZ) 强制设置为 0。此选项将阻止多次评估后小增量漂移发生积累。对于 Based Upon 设置为 Original 的关键帧剪辑,此选项还可强制使用美术师设置的创作位置。

    • Loop Pose: 循环姿势 ,(类似于混合树或过渡中的姿势混合)发生在根变换的引用中。计算根变换后,姿势变为相对于根变换。计算开始帧和停止帧之间的相对姿势差,并分布于 0-100% 的剪辑范围内。

    • 通用根运动和循环姿势: 工作原理与人形根运动基本相同,但不使用身体变换来计算/投射根变换,而是使用根节点中设置的变换。姿势(在根运动骨骼下变换的所有骨骼)都是相对于根变换创建的。

# 教程:为“原地就位”的人形动画编写根运动脚本

有时,动画表现为“原地就位”,这意味着如果您将此动画放入场景,它不会移动所依附的角色。换言之,该动画没有包含“根运动”。为此,我们可通过脚本修改根运动。为了将这一切融合到一起,请遵循以下步骤(注意,实现相同结果有很多不同方法,这只是一种方案)。

  • 打开包含“原地就位”动画的 FBX 文件的检视面板,然后选择 Animation 选项卡

  • 确保 Muscle Definition 设置为要控制的 Avatar(假设此 Avatar 称为 Dude,并且已将其添加到 Hierarchy 视图)。

  • 从可用剪辑中选择动画剪辑

  • 确保 Loop Pose 正确设置(旁边的指示灯为绿色),并已选中 Loop Pose 的复选框

  • 在动画查看器中预览动画以确保动画的开头和结尾顺利衔接,并且角色正在“原地就位”移动

  • 在动画剪辑上,创建曲线来控制角色的速度(可通过 Animation Import Inspector Curves > + 添加曲线)

  • 为该曲线提供有意义的名称,例如“Runspeed”

  • 新建 Animator Controller(将其命名为 RootMotionController)

  • 将所需的动画剪辑放入其中,此时应该会创建一个具有动画名称的状态(例如 Run)

  • 将参数添加到与曲线同名的控制器(在本示例中为“Runspeed”)

  • 在 Hierarchy 中选择角色 Dude(其检视面板应该已经具有 Animator 组件)。

  • 将 RootMotionController 拖到 Animator 的 Controller 属性上

如果现在按 Play,应该会看到“Dude”在原地奔跑

最后,要控制运动,我们需要创建脚本 (RootMotionScript.cs) 来实现 OnAnimatorMove 回调:

using UnityEngine;
using System.Collections;

[RequireComponent(typeof(Animator))]
	
public class RootMotionScript : MonoBehaviour {
			
	void OnAnimatorMove()
	{
            Animator animator = GetComponent<Animator>(); 
                              
            if (animator)
            {
	 Vector3 newPosition = transform.position;
               newPosition.z += animator.GetFloat("Runspeed") * Time.deltaTime; 
	 transform.position = newPosition;
            }
	}
}

应将 RootMotionScript.cs 连接到“Dude”对象。进行此操作时,Animator 组件将检测到脚本有 OnAnimatorMove 函数并将 Apply Root Motion 属性显示为 Handled by Script

Animator窗口
Blend Tree

← Animator窗口 Blend Tree→

Theme by Vdoing | Copyright © 2021-2023 Evan Dong MIT License | 粤ICP备2021052092号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
×