65.9K
CodeProject 正在变化。 阅读更多。
Home

Win2D 的精灵表库和编辑器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (2投票s)

2021 年 2 月 8 日

CPOL

20分钟阅读

viewsIcon

8890

Win2D 提供了一个简洁的 API 接口,但如何为您的游戏渲染复杂的精灵呢?我提供了一个库和编辑器,让这个过程更加 streamlined。

免责声明

这个库仍然在很大程度上是一个进行中的项目,因为我继续为自己使用而开发它;然而,我相信它现在已经达到了一个可用且足够稳定的程度,以至于其他需要类似库的人可以很好地利用它。

引言

在通用 Windows 平台(Universal Windows Platform)的世界里,存在一个名为 Win2D 的 Windows Runtime API,它在 .NET 应用程序中提供了对 Direct2D 的访问。如果您想开发一款游戏,并且不介意在 UWP 相对僵化的要求(例如只能使用特定最低版本的 Windows 10)中摸索构建应用程序的困难,那么您很可能会发现 Win2D 对您的项目很有用。

然而,虽然 Win2D 提供了许多有用的类来执行绘图和变换,但它仍然需要您付出大量工作才能将其与游戏的逻辑引擎结合起来。在本文中,我将详细介绍我编写的库,以帮助您更轻松地从“仅逻辑游戏”过渡到在屏幕上显示您的游戏对象,以及我构建的编辑器,以帮助您从位图精灵表过渡到可用的游戏内图形资源。

项目下载和可侧载的应用可以在 我的 Github 仓库 中找到。

定义

如果您的情况符合以下“仅逻辑游戏”的描述,那么这个库将是最有帮助的:

您已经编写了逻辑引擎,其中包含表示不同游戏内对象的类。您的引擎可能能够解决冲突、执行操作、在服务器和客户端之间同步、从保存状态写入和加载,以及通过外部或内部计时器模拟时间流逝。

重要的是,您的引擎维护着所有活动对象的状态信息,并且可以通过某种方式从引擎外部访问这些状态信息。您可能拥有也可能没有可以帮助指示状态如何在视觉引擎中表示的逻辑。

您的游戏运行不依赖于实时光线追踪或其他图形技巧。这个精灵引擎不支持原生碰撞,但您可以自己添加。

您的引擎与通用 Windows 平台运行时兼容,通过 .NET Standard 2.0+ 或其他转换方式。

值得注意的是,您的游戏不一定必须明确存在于 2D 空间中;但是,该引擎没有内置任何 3D 图形。

项目起源

为了帮助您更好地理解这是一个可以直接使用的库,还是应该对其进行大量修改以适应您的需求,我想花点时间解释一下我为什么开发这个库。

我正在从零开始编写自己的游戏。这已经是一次长达十年的尝试,从我几乎不懂类(Classes)和模块(Modules)(在 VB.NET 中)的区别开始,直到现在。随着时间的推移,我发现我过于专注于让游戏在视觉上变得可玩,导致游戏逻辑错误和大量死胡同。随着 2020 年大流行的全面爆发,我抓住了机会(又一次!)重新开始,并构建一个完整、彻底的引擎来首先运行游戏。

我的游戏“仅逻辑”引擎的结构如下:

我有一个类作为所有游戏内对象的基类,无论是基于网格的地砖、植被和环境,还是玩家。唯一的子类描述了重要的对象区分和规格。所有对象都可以在同一级别(在一定程度上)访问,即在一个“Region”(区域)内,而无需通过嵌套子项进行导航(尽管通常更倾向于这样)。一个“Region”包含从个位数到六位数的瓦片,每个瓦片包含理论上无限数量的子对象。每个子对象都被分配了一个无符号长整型作为其唯一标识符。

在有了运行世界的逻辑之后,我需要一种方法来开始显示我的对象状态。将基类对象扩展为包含绘制逻辑是不可行的;我想同时将此逻辑引擎用于服务器和客户端,而所有额外的开销都是立即不合格的。由于我先从逻辑开始而不是图形,所以我无法使用 Unity 的方法,即让视觉对象“拥有”逻辑状态,例如一个带有包含字段和成员的脚本的预制件。我需要一个精灵引擎,它可以加载一堆精灵及其元数据,这样我就不必在不同的文件名、图像位置等中进行硬编码。

SpriteAsset 引擎

该引擎仅由三个工作部分组成:SpriteBundle、Tokens 和 Sprites。

后台还有更多工作,尤其是在编辑器方面,但我们稍后会讨论。

SpriteBundle 包含选择多个帧中的一个以最好地描述对象状态所需的逻辑。无论您的精灵是动画帧系列、从不同角度看到的同一对象,还是只是一个静态图像,SpriteBundle 都会在内部解决您请求的状态并为您提供正确的图像。这意味着您不必将图像保存在复杂的文件名下或通过长字符串引用它们——您只需使用 Tokens。

从每个 SpriteBundle,您可以生成 Tokens。每个 Token 包含关于应该显示哪个图像的状态信息,并且应该与要显示的任何游戏内对象相关联。对于动画精灵,Token 维护着时序和帧选择;同样,对于定向和覆盖精灵,Token 包含指示 Bundle 中哪个帧应该显示的信息。通过这种方式,纹理/精灵/图像仅加载一次到内存中,每个游戏内对象仅在需要时引用它们。

为了维护 Token 和它们的游戏对象之间的链接,引入了一个抽象的 Sprite 类。您可以通过两种方式实现它:要么创建自己的派生 Sprite 类,其中包含将对象状态与 Token 匹配的逻辑,要么通过继承 Sprite 来扩展您的基类游戏对象,以便直接访问状态和状态更改。

对于我的游戏,我创建了一个派生类,其中包含一个字段,该字段包含从中提取状态的“Source”游戏内对象,以及包含要绘制的图像的 Token。在每次帧刷新之前,派生 Sprite 会检查 Source 是否发生了显著的状态更改,并相应地修改 Token,然后再返回正确的图像。

在我提供的示例中,我包含了一些展示我如何实现此功能的代码,您应该可以从中改编以适应您的引擎的结构。

Win2D 的 Canvas

虽然 Win2D 在 其 github.io 页面上有大量文档,但作为新手,我发现一开始很难理解 API 的整体结构。有很多类需要处理,其中一些构造函数的文档不够充分,无法直观地解释如何使用它们。我将尝试在此解释如何将精灵引擎与 Win2D 控件关联起来,以缓解其中一些担忧。

您需要使用的基本控件是 Microsoft.Graphics.Canvas.UI.Xaml.CanvasControl。它提供即时模式 2D 渲染,是一个很好的起点。该控件会发出几个对我们有用的事件,即 CreateResources 事件和 Draw 事件。我已将 CreateResource 事件挂接到我的精灵加载代码,该代码读取保存的精灵并将它们加载到内存中。另一方面,Draw 是您开始在画布上放置位图以供显示的地方。我将在本文稍后介绍精灵文件。

您会注意到 abstract Sprite 类提供了四个有用的属性供您使用;TokenIntendedRectImageClickable。根据您想让绘图过程变得多复杂,您可以任意实现这些属性——最简单的方法是让 IntendedRect 返回一个等同于您的逻辑引擎状态位置的矩形,并让 Image 轮询 Token 的 Bundle(见下文)。Clickable 让您可以定义精灵响应单击的区域——它的实现应该相当直接。

例如,这是我的 ESprite(没有游戏内对象等效项)和 EOSprite(与游戏内 EObject 关联)实现的摘要版本:

Public Class ESprite
    Inherits Sprite

    Public Overrides Property Image As CanvasBitmap
    Public Overrides Property Clickable As Clickable
    Public Property Token As Token

    Private _Intended As Rect

    Protected Sub New()
    End Sub

    Public Sub New(sprite As Token, unit As Integer, Optional click As Clickable = Nothing)
        Token = sprite
        Image = sprite.Source.ApplyToken(sprite)
        ' modify click bounds
        If click IsNot Nothing Then
            click.ClickBounds = IntendedRect(unit)
            Clickable = click
        End If
    End Sub

    Public Overridable Sub SetRect(intended As Rect)
        _Intended = intended
    End Sub

    Public Overrides ReadOnly Property IntendedRect(unit As Integer) As Rect
        Get
            Dim ret = _Intended
            If Clickable IsNot Nothing Then Clickable.ClickBounds = ret
            Return ret
        End Get
    End Property

    Public Overrides Sub Invalidate()
        Image = Token.Source.ApplyToken(Token)
    End Sub
End Class

Public Class EOSprite
    Inherits ESprite

    Public ReadOnly Source As EObject

    Public Sub New(source As EObject, sprite As Token, unit As Integer,
                   Optional click As Clickable = Nothing)
        ' uses the protected sub new() with no parameters to prevent accessing 
        'Source before assignment.
        MyBase.New()
        If source Is Nothing Then
            Throw New Exception("EOSprite cannot have a null source. Use ESprite instead.")
        End If
        Me.Source = source
        Token = sprite
        Image = sprite.Source.ApplyToken(sprite)
        ' modify click bounds
        If click IsNot Nothing Then
            click.ClickBounds = IntendedRect(unit)
            Clickable = click
        End If
    End Sub

    ''' <param name="unit">The pixels that represent one in-game Address unit.</param>
    Public Overrides ReadOnly Property IntendedRect(unit As Integer) As Rect
        Get
            Dim ret = New Rect(Source.Address(False).X * unit,
                            Source.Address(False).Y * unit,
                            Image.Bounds.Width, Image.Bounds.Height)
            If Clickable IsNot Nothing Then Clickable.ClickBounds = ret
            Return ret
        End Get
    End Property
End Class

您会注意到,从 SpriteBundle 中检索图像的方法很简单,就像执行 Token.Source.ApplyToken(Token) 一样。之所以需要此调用,是因为 SpriteBundle 不维护它创建的所有 Token 的集合,以防止内存泄漏。尝试在不是由“该 Bundle 的” CreateToken() 创建的 SpriteBundle 上应用 token 将会抛出异常,所以不要试图那样偷偷地交换。

ApplyToken() 的返回值是 CanvasBitmap,它与您的 CanvasControl 完全兼容。要将 CanvasBitmap 绘制到您的控件上,请挂接到控件的 Draw 事件。

Public Sub Canvas_Draw(sender As CanvasControl, args As CanvasDrawEventArgs)

您可以通过使用 DispatcherTimer 来反复调用 CanvasControl.Invalidate() 来强制刷新内容,从而触发新的 Draw 事件。

第二个参数 CanvasDrawEventArgs 包含一个名为 CanvasDrawEventArgs.DrawingSessionCanvasDrawingSession。您可以在此会话中访问 DrawImage() 方法,以直接将图像绘制到画布上,并具有几种不同的选项。请注意,您可以提供的参数之一是 destinationRectangle——如果您正确实现了它,您可以从 Sprite.IntendedRect(units) 获取它;否则,您可能需要对 IntendedRect 进行一些数学运算才能正确移动和缩放它。

IntendedRect(没有双关语!——等一下!)的意图是让 Sprite 自动提供关于它期望在其 Image 绘制在画布上的位置的信息。如果您的游戏使用坐标系,单位通常应该是您的精灵的大小。如果您想缩放所有精灵的大小,请改用 CanvasDrawingSession.Transform 属性。

库中还包含 SceneSession 类。如果您的屏幕上只有几个精灵在跳舞,就像 Pong(两个挡板和一个球,可能还有一些文本)一样,您可能不需要使用 SceneSession。但是,如果您的游戏像我的游戏一样,有成千上万个精灵同时活动,您可能需要以其他方式组织这些精灵,尤其是当您需要考虑图层时。这时 SceneSession 就派上用场了。

SceneSession

SceneSession 类中,有一个内部的排序字典,维护着许多 SingleLayer 对象。每个 SingleLayer 正如其名,是一个单一图层。虽然图层内的精灵可能根据它们的添加顺序相互重叠,但可以将它们分组以绘制在其他图层的上方或下方。

每个图层都维护自己的内部画布,首先绘制到该画布,然后将整个 canvas 作为 CanvasRenderTarget(与 CanvasDrawingSession.DrawImage() 兼容)提供,以便绘制到主 canvas 上。

要使用 SingleLayer,您可以使用自己的集合在 SingleLayerSession 属性上进行绘制,或者扩展该类以拥有自己的 List/Dictionary 来保存您的精灵。实例化 SingleLayer 后,将其添加到 SceneSession 中,它将自动以正确的顺序绘制——之后,您可以简单地修改精灵的状态来更改其外观。如果想要使用我提供的 click 功能,也应该将精灵的 Clickable 添加到 SingleLayer 中。

最后,在 Draw 事件处理程序方法中,只需调用 SceneSessionStart() 方法,并将上述 CanvasControlDrawingSession 传递进去。

Public Sub Canvas_Draw(sender As CanvasControl, args As CanvasDrawEventArgs)
    Session.Start(args.DrawingSession, New Rect(0, 0, Canvas.ActualWidth, Canvas.ActualHeight))
    ...
End Sub

这会告诉 Session 按顺序将每个图层倾倒到 CanvasDrawingSession 上。如果您创建了一个扩展的 SingleLayer 类,请确保您覆盖了它的 Reset() 方法,以便将您的精灵绘制到 LayerSession 上。

最后,您会注意到 SceneSessionSprite 都实现了 ITakesTime 接口。这个接口只有一个方法,Step(),用于告知每个精灵在滴答之间已经过去了一定的时间,从而允许时间传播。如果您的精灵有一个动画序列,例如每帧 50 毫秒,您需要提供这个增量时间来告知 Token 时间已经过去。

由此产生的逻辑-图形结构应该看起来像这样。您的逻辑引擎将执行滴答,跟踪经过的时间量,并将该信息传递到您的 Sprite 实现中——无论是通过直接的 Step() 方法调用,还是通过扩展的 SingleLayer(即,一个新的图层类,它维护自己的精灵集合)——来注册动画和移动。对于任何应该有视觉变化的精灵,您将其 Token 应用于 Token 的源 SpriteBundle 来检索更新的图像。然后,在当前帧中,该图像被绘制到 CanvasDrawingSession 上。

编辑器

现在,我们如何创建游戏的精灵呢?

第一步是创建精灵表。对于像我这样的稀有程序员兼艺术家来说,这不算什么大事——但即使您没有绘画经验,像素艺术也不会太难。首先,您需要确定您的像素尺寸——例如,16x16(尽可能保持方形)。使用 Paint 或 Photoshop 等软件,或 Krita 或 Paint.NET 等免费软件,您可以放大到极致,绘制您的小小帧。您可以按照自己喜欢的方式组织精灵表——我构建的编辑器可以处理它。

在您的精灵表上有了几个图像后,您可以通过 **Load Sprite** 打开图像到 SpriteEditor 的 Bounds Editor(边界编辑器)视图。精灵表显示在左侧,而“Bounds”(边界)列在右侧。您可以通过右侧的菜单添加 Bounds,确保点击“Modify Bounds”(修改边界)按钮来提交更改,或者您可以简单地双击图像来开始一个 Bounds。执行此操作以标记出您所有的单个帧。您执行此操作的顺序对编辑器无关紧要——只需按照您觉得舒适的方式进行即可。

标记完所有 Bounds 后,您可以切换到 **Sprite Editor**(精灵编辑器)视图。

您将在左侧看到一个空的 Sprites(精灵)列表,您之前高亮的相同 Bounds,以及一个空的 Assignments-Properties(分配-属性)框。

通过点击 **Add Sprite**(添加精灵)来创建一个精灵。然后,选择一个您想包含在精灵中的 Bounds,然后点击 **Insert Selected**(插入选定)。

Bounds 现在已作为精灵内的已知图像添加。

有四种(截至撰写时)“MultiSprite”(多精灵)类型;Single(单个)、Directional(定向)、Neighbor(邻居)和 Animated(动画)。Description(描述)框解释了如何为精灵内的每个 Bounds 填充 Assignment/Property(分配/属性)框——请仔细阅读这些内容,因为错误的精灵会抛出异常,而编辑器(尚)不会自动执行这些检查。

  • Single(单个)精灵是与 Class Identifier(类标识符)关联的单个静态图像。添加额外的 Bounds 不会影响该精灵的行为。
  • Directional(定向)精灵利用 SpriteAsset.DirectionalSpriteDirections 枚举来选择最能代表精灵的几个图像之一。如果您在游戏中使用自己的“方向”类,则需要进行转换。使用此功能的一个限制是它仅支持 8 个方向;如果您需要更多,则必须实现自己的适配。

  • Neighbor(邻居)精灵根据附近的事物为基础精灵添加叠加层;例如,如果左侧是草地,则可以通过草地颗粒叠加鹅卵石路径的图像。这有助于更好地融合环境。

  • Animated(动画)精灵包含多个子动画,基于它们的分配名称。因此,它们可用于表达具有多个状态的对象,通过命名精灵中的每个状态并分配任何任意持续时间。但主要用途是显示一个看起来像动画的精灵。

当您对设置满意后,您可以将精灵保存到 .cmplx 文件中。这是一个 JSON 格式的文档,其中包含您指定的所有详细信息以及图像的字节数据。如果您更新了精灵表,可以使用 **Swap Image**(替换图像)按钮加载更新或新的精灵表,而无需重新绘制所有 Bounds 和 Sprites。

Token 选择

那么,如何设置 Token 的状态以获取正确的图像呢?

Tokens 包含几个方法和一些字段,所有这些都始终可见。为了保持相对简单,我选择将所有字段保留在一个 Token 类下,而不是创建更多的类。提供的方法是我称之为“Selector”(选择器)的方法。

每个 Selector 方法都以其多类型对应项的字母作为前缀;动画类型的 SpriteBundle 应使用 A_Select()A_Time(),邻居类型使用 N_Select(),依此类推。如果您不小心使用了不正确的选择器方法,Token 将抛出异常。因此,建议为每种多类型创建唯一的 Sprite 派生类,或者至少确保您将代码与 ComplexSheet 进行交叉引用。

名称和变体

顺便说一句,我想快速解释一下有两个名称的合理性。

考虑一个 Tree(树)对象,它在 TREE 精灵中具有复杂的动画。然后,您在 Tree 类中添加一个字段来确定它有多高;例如,一个枚举 Short(矮)、Medium(中)和 Tall(高)。而不是进入 TREE 精灵并在像“waving_tall”或“waving_short”这样复杂的分配名称下添加所有动画帧,您只需应用变体名称即可获得 TREE:TALL 精灵。这样,当您在内部尝试通过 Token 调用动画时,可以保留旧的字符串常量。

这与(使用理论函数 FindBundle())之间的区别在于:

Token = FindBundle("TREE")
Token.A_Select(“waving” + 
    If(Height = TreeHeight.Tall, “_tall”, 
        If(Height = TreeHeight.Short, “_short”, “”)))

Token = FindBundle("TREE", TreeHeight.ToString().ToUpper())
Token.A_Select("waving")

当然,您可能喜欢 If… 结构的外观(如果那样的话,您也可以这样做!),但否则,变体选项通过减少精灵中包含的 Bounds 数量来帮助简化一些事情。

使用变体的另一个好处是,如果找不到变体,您可以简单地回退到原始版本。否则,根据您设置的多精灵类型,如果选择了无效的分配名称,SpriteBundle 可能会抛出异常。

文件和类

ComplexSheet 包含您选择的图像 Bounds 以及精灵列表。但是,在此阶段,精灵并未实际序列化为精灵。相反,您在 Sprite Editor(精灵编辑器)阶段指定的数据存储在一个名为 MultiSpriteData 的包中。

每个 MultiSpriteData 对象包含您为每个精灵选择的 Bounds、分配和属性以及名称。这些都在保存 ComplexSheet 时编译在一起。结果是一个 JSON 文档,然后可以反序列化回 ComplexSheet

要在游戏中检索精灵,您首先需要通过将文件加载到您的应用程序中,将文件加载到 ComplexSheet 中,将整个文件读取为字符串以将其解析回 JSON,然后将解析后的 JSON 对象传递给 ComplexSheet 的构造函数。要做到这一点,您必须导入 Newtonsoft 的 JSON 库——如果您有偏好的库,可以修改反序列化代码以适应您的项目。

Public Async Function LoadFromFiles(fromDir as String) As Task(Of Boolean)
    Dim appFolder = Windows.ApplicationModel.Package.Current.InstalledLocation
    ' note that fromDir must be below the InstalledLocation by UWP limitation.
    Dim assetsFolder = Await appFolder.GetFolderAsync(fromDir)
    Dim assets = Await assetsFolder.GetFilesAsync()
    For Each file In assets
        If file.FileType <> ".cmplx" Then
            Continue For ' skip unknown file
        End If
        Dim data = Await Windows.Storage.FileIO.ReadTextAsync(file)
        ' Newtonsoft.JSON.Linq.JObject
        Dim json = JObject.Parse(data)

        Dim sheet As New ComplexSheet(json)

        For Each msdata In sheet.Sprites
            Dim bundle As New SpriteBundle(msdata, sheet)
            ... ' handle the bundle however you need to.
        Next

    Next
    Return True   
End Function

实例化 ComplexSheet 后,您可以从 ComplexSheetSprites 字段中提取 SpriteBundles,并将它们传递给 SpriteBundle 的构造函数,如上所示。

给定 MultiSpriteDataSpriteBundle 根据您选择的 MultiSprite 类型进行构建,通过检查 ComplexSheet 中的 Image 来填充图像集合,现在已准备好生成有用的 Tokens。

从那里,您可以决定如何组织您的 SpriteBundles。我已将其包装在一个 SpriteHandler 类中,该类在找不到特定精灵时提供回退选项。

您也可以加载多个 ComplexSprites 而没有问题——只需执行与上面相同的提取过程,并确保您可以组织所有不同的 SpriteBundles

根据您的逻辑引擎的组织方式,您可能可以完全依赖类名来查找特定的精灵;我的 Player 类在资源加载期间,与名为 PLAYER 的精灵关联。您可以使用任何您想要的字符串来匹配精灵,但请注意,精灵的类名和变体名称始终大写。

考虑因素

除了“Definitions”(定义)部分提到的几点之外,还有一些我想要提出的注意事项。

据我所知,该引擎的性能是可以接受的。在 40x40 瓦片(1600 个空间)的区域上放置十几个 16x16 的精灵,在我的调试版本中消耗约 200MB 内存,包括我的游戏引擎。Win2D 在降低绘制时间方面做得很好,我在 NVidia 960M 上达到了约 4ms/帧。如果您需要极高的性能,您可能需要调整 TokenSpriteBundle 类以尽可能减小其占用空间——就目前而言,我已经尽我所能地进行了清理,但我确信您可能会发现一些我忽略的地方。

编辑器将导入的位图的副本存储在保存的 .cmplx 文件中。它通过将整个图像的原始位图字节(未压缩)转换为字符串来做到这一点。一张上面有十几个 16x16 图像的 1024x1024 位图的 .png 文件成本约为 6KB,而无压缩成本为 20KB。编辑器的 .cmplx 文件成本超过 5MB。这样一个大小的位图可以存储 1024 个独立的 16x16 图像,JSON 文档中的 Bounds/MultiSpriteData 与之相比不会增加大量数据——但如果您的精灵表比这大得多,您可能需要处理非常慢的 JSON 解析。

Clickable 类目前仅支持鼠标左键/右键单击。

最后,我编写这个编辑器和精灵引擎是为了满足我游戏的需求。它可能无法提供您为自己的游戏所需的所有功能,但我希望,如果您仍处于草拟或原型设计阶段,它能帮助您加速开发。

最终总结

在本文中,我描述了一个用于 Win2D 的精灵表引擎,它可以帮助组织您的图像资源,以及用于从位图文件创建复杂精灵包的 SpriteEditor。我概述了将引擎实现到您的游戏中的结构,以及您应该遵循的一些设计选择和模式。

我将继续致力于完善编辑器和库,但希望我已经建立了足够的基础,以至于未来的开发和添加不会破坏您的代码,如果您决定尝试的话。

您可以在 我的 Github 仓库 中找到库和编辑器项目文件以及可侧载的应用。

附加提示

在使用 UWP 和 Win2D 时,我遇到了一些奇怪的情况,这些情况在线上没有解释或足够的文档。我将在此提供它们,以防您觉得有用。

  • UWP 的 FilePicker 类,除非至少有一个文件类型过滤器,否则无法显示。如果您不添加至少一个过滤器,就会抛出一个奇怪的 COM 异常。
  • CanvasBitmap 无法从“低于”可执行文件目录的文件名加载,所以加载一个 StorageFile 并使用其流代替。
  • 为了使 SoftwareBitmap 可以用作创建 CanvasBitmap 的源,您必须在实例化 SoftwareBitmap 时将 alpha 模式参数指定为 Premultiplied。如果您不这样做,CanvasBitmap 将抛出格式不支持的异常。在 文档中还有一个支持的像素格式列表;我建议使用 BGRA8 以获得最佳兼容性。

历史

  • 2021 年 2 月 8 日:初始版本
© . All rights reserved.