欢迎来到我的博客小站。  交流请加我微信好友: studyjava。  也欢迎关注同名公众号:Java学习之道

[转] Unity实战之象棋游戏

  |   0 评论   |   0 浏览

并不是所有成功的游戏都包括打外星人或拯救世界。棋盘游戏,尤其是国际象棋,有着数千年的历史。它们不仅玩起来很有趣,而且将它们从现实生活中转变成视频游戏也很有趣。

在本教程中,你将用 Unity 编写一个 3D 象棋游戏。在这个过程中,你将学习:

  • 选择要移动的棋子
  • 判断移动是否合法
  • 切换玩家
  • 判断输赢

当您完成本教程时,你将创建一个富有功能的棋类游戏,你可以将它作为其他棋盘游戏的起点。

注意:你应该具有 Unity 和 C# 语言基础。

开始

请下载本教程的开始项目。你可以在本文顶部或底部找到下载链接。用 Unity 打开开始项目。

象棋通常会做成简单的 2D 游戏。但是,本 3D 版本模拟了你和朋友坐在桌子旁边下棋。此外…… 3D 比较棒了。

打开 Scenes 文件夹中的 Main 场景。你会看到一个表示棋盘的 Board 对象和一个 GameManager 对象。这些对象都已经绑定了脚本。

  • Prefabs: 包含了棋盘、 棋子、和移动过程中的指示方块。
  • Materials: 包含了棋盘、棋子和瓦片的材质。
  • Scripts: 包含了在结构视图中已经绑定到对象中的组件。
  • Board: 记录棋子的可视化状态。这个组件还会处理每颗棋子的高亮状态。
  • Geometry.cs: 工具类,负责处理行列转换和 Vector3 点。
  • Player.cs: 记录玩家的棋子,玩家手执的棋子。保存玩家棋子移动的方向,比如小兵。
  • Piece.cs: 一个基类,定义了所有实例化的棋子对象的枚举。它还包含确定游戏中有效移动的逻辑。
  • GameManager.cs: 存储游戏逻辑,比如允许的移动,游戏一开始时棋子的位置等。它是一个单例对象,所以其他类很容易调用它。

GameManager 的 pieces 保存了一个 2D 数组,记录了棋子在棋盘上的位置。可以看一下 AddPiece、PieceAtGrid 和 GridForPiece 的逻辑。

进入试玩模式,你会看到一个棋盘,棋子准备好后就可以下棋了。

移动棋子

首先需要找出要移动哪枚棋子。

射线查找可用于找出用户鼠标正在经过哪一块瓦片。如果你不熟悉 Unity 的射线查找,请查看:Unity 脚本教程入门(https://www.raywenderlich.com/127672/introduction-unity-scripting)或者炸弹超人(https://www.raywenderlich.com/167052/bomberman-tutorial-fun-blowing-friends)。

一旦玩家选择了一个棋子,你需要在棋子可以移动的地方生成有效的瓦片。然后,你选择其中一个瓦片。你将添加两个新脚本来实现这个功能。TileSelector 用于将选择移动的棋子,MoveSelector 用于选择目的地。

两个组件的基本方法是类似的:

  • Start: 进行一次性的设置。
  • EnterState: 进行本次动作的设置。
  • Update: 当鼠标移动时,执行射线查找。
  • ExitState: 清除本次状态,调用下一状态的 EnterState。

这实现了一个基本的状态机。如果你有更多状态,可以写得更规范点,当然代码也会更复杂。

选择瓦片

在结构视图中选择棋盘。然后在检视器窗口,点击 Add Component 按钮。然后在文本框中输入 TileSelector 并点击 New Script。最后,点击 Create and Add,绑定脚本。

注:创建新脚本的时候,记得将它们移动到正确的目录。保持 Assets 文件夹的合理有序。

高亮选中瓦片

双击 TileSelector.cs,打开文件,添加变量:

public GameObject tileHighlightPrefab;
private GameObject tileHighlight;

这两个变量构成了一个透明遮罩,用于凸显你所选中的瓦片。预制件将在编辑模式下被赋值,而组件负责跟踪和移动高亮状态。

然后在 Start 方法中添加下列代码:

Vector2Int gridPoint = Geometry.GridPoint(0, 0);
Vector3 point = Geometry.PointFromGrid(gridPoint);
tileHighlight = Instantiate(tileHighlightPrefab, point, Quaternion.identity, gameObject.transform);
tileHighlight.SetActive(false);

Start 方法初始化高亮瓦片的行和列,将其转换为 point,并通过预制件创建一个游戏对象。这个对象一开始是未激活的,所以在需要时才会显示。

注:以行列引用坐标是很有用的,它是一个 Vector2Int 类型,即一个 GridPoint。Vector2Int 有两个整数值:x和y。当你需要在游戏场景中放入一个对象时,你需要用 Vector3 坐标。Vector3 坐标包含三个浮点值:x、y 和 z。

在 Geometry.cs 有进行两者间转换的实用方法:

  • GridPoint(int col, int row): gives you a GridPoint for a given column and row.
  • PointFromGrid(Vector2Int gridPoint): turns a GridPoint into a Vector3 actual point in the scene.
  • GridFromPoint(Vector3 point): gives the GridPoint for the x and z value of that 3D point, and the y value is ignored.

然后是 EnterState 方法:

public void EnterState()
{
    enabled = true;
}

当选择另一颗棋子时,重新启用组件。

然后是 Update 方法:

Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition)

RaycastHit hit
if (Physics.Raycast(ray, out hit))
{
    Vector3 point = hit.point
    Vector2Int gridPoint = Geometry.GridFromPoint(point)
    tileHighlight.SetActive(true)
    tileHighlight.transform.position = Geometry.PointFromGrid(gridPoint)
}
else
{
    tileHighlight.SetActive(false)
}

这里,你从镜头中创建了一束射线,经过鼠标点,射向无限远!

Physics.Raycast 会检测射线是否和系统中的物理碰撞体发生相交。由于棋盘是唯一拥有碰撞体的对象,你不必担心棋子互相遮挡。

如果射线和碰撞体相交,RaycastHit 中会包含交点数据。你将交点转换成 GridPoint(使用助手方法),然后设置高亮瓦片的位置。

由于鼠标指针位于棋盘上方,你可以激活高光瓦片,以便让它显示。

最后,在结构视图中选择 Board 然后在项目窗口中单击 Prefabs。然后,将 Selection-Yellow 预制件拖到棋盘的 Tile Selector 组件的 Tile Hightlight Prefab 方框中。

进入游戏试玩模式,当鼠标指针移动时会有一个黄色的高亮瓦片跟随。

选择棋子

要选中某颗棋子,你需要判断按下的鼠标按钮是哪一颗。在激活完瓦片的 hightlight之后,用一个 if 语句中添加一个判断:

if (Input.GetMouseButtonDown(0))
{
    GameObject selectedPiece = GameManager.instance.PieceAtGrid(gridPoint)
    if(GameManager.instance.DoesPieceBelongToCurrentPlayer(selectedPiece))
    {
        GameManager.instance.SelectPiece(selectedPiece)
    // Reference Point 1: add ExitState call here later
    }
}

如果鼠标按钮被按下,GameManager 就会为你获取该位置的棋子。你还必须确保这个棋子是属于当前玩家的,因为玩家不允许移动对手的棋子。

注:在一个复杂的游戏中,最好为组件清晰地划分职责。棋盘负责显示和凸显棋子。GameManager 负责保棋子的 GridPoint。助手方法负责告诉棋子在哪里以及它们属于哪个玩家。

进入试玩模式,选择一枚棋子。

现在你手上已经有棋子了,把它移动到别的地方吧。

选择移动目标

现在,TileSelector 已经完。接下来是另一个组件:MoveSelector。

这个组件和 TileSelector 类似。和之前一样,在结构视图中选中 Board 对象,添加一个新组件,名为 MoveSelector。

传递控制

第一件事情是将控制从 TileSelector 传递给 MoveSelector。这样我们就需要用到 ExitState 了。在 TileSelector.cs 中,添加一个方法:

private void ExitState(GameObject movingPiece)
{
    this.enabled = false;
    tileHighlight.SetActive(false);
    MoveSelector move = GetComponent<MoveSelector>();
    move.EnterState(movingPiece);
}

这里我们隐藏 overlay 瓦片,然后禁用 TileSelector 组件。在 Unity 中,在禁用组件上你无法调用 Update 方法。因为你想调用另外一个组件的 Update 方法,所以就禁用原组件,防止干扰。

在 Update 方法的 Referenct point 1 之后调用这个方法。

ExitState(selectedPiece);

然后打开 MoveSelector 添加一个实例变量:

public GameObject moveLocationPrefab;
public GameObject tileHighlightPrefab;
public GameObject attackLocationPrefab;
private GameObject tileHighlight;
private GameObject movingPiece;

这些变量用于保存鼠标高亮、移动点和攻击点的瓦片图层,以及原先的高亮瓦片以及前面选中的棋子。

然后,在 Start 方法中加入:

this.enabled = false
tileHighlight = Instantiate(tileHighlightPrefab, Geometry.PointFromGrid(new Vector2Int(0, 0)),
    Quaternion.identity, gameObject.transform)
tileHighlight.SetActive(false)1234

这个组件一开始必须 disabled,因为首先要执行 TitleSelector。然后,加载高亮图层。

移动棋子

然后添加 EnterState 方法:

public void EnterState(GameObject piece)
{
    movingPiece = piece;
    this.enabled = true;
}

当这个方法调用时,它会保存被移动的棋子,然后禁用组件自身。

在 MoveSelector 的 Update 方法中,添加代码:

Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition)

RaycastHit hit
if (Physics.Raycast(ray, out hit))
{
    Vector3 point = hit.point
    Vector2Int gridPoint = Geometry.GridFromPoint(point)

    tileHighlight.SetActive(true)
    tileHighlight.transform.position = Geometry.PointFromGrid(gridPoint)
    if (Input.GetMouseButtonDown(0))
    {
        // Reference Point 2: check for valid move location
        if (GameManager.instance.PieceAtGrid(gridPoint) == null)
        {
            GameManager.instance.Move(movingPiece, gridPoint)
        }
        // Reference Point 3: capture enemy piece here later
        ExitState()
    }
}
else
{
    tileHighlight.SetActive(false)
}

这个 Update 方法和 TileSelector 的 Update 方法类似,同样使用射线检查鼠标正在点击那个瓦片。当鼠标键被按下,调用 GameManager 移动棋子到新瓦片上。

最后是 ExitState 方法,做一些清理工作并为下次移动做好准备:

private void ExitState()
{
    this.enabled = false;
    tileHighlight.SetActive(false);
    GameManager.instance.DeselectPiece(movingPiece);
    movingPiece = null;
    TileSelector selector = GetComponent<TileSelector>();
    selector.EnterState();
}

这里禁用了组件,将高亮瓦片隐藏。因为棋子已经移动完成,你可以清空它,让 Gamemanager 取消棋子的高亮状态。然后,调用 TileSelector 的 EnterState,以便再次开始整个过程。

回到编辑器,选中 Board 对象,从 prefab 文件夹将 tile overlay 预制件拖到 MoveSelector 的这些地方:

  • Move Location Prefab 的值应该是 Selection-Blue
  • Tile Highlight Prefab 的值应该是 Selection-Yellow.
  • Attack Location Prefab 的值应该是 Selection-Red

你可以通过修改材质来调整它们的颜色。

开启试玩模式,移动棋子试试。

你会发现,你可以将棋子移动到任何空格子上。这在象棋中完全是不合理的。接下来应该让棋子的移动符合游戏规则。

算出合法的移动

在国际象棋中,每个棋子能够做出的动作是不一样的。有的棋子能够往任意方向移动,有的棋子可以移动任意个空格,有的棋子只能在某个方向上进行移动。你如何记住这些规则?

一种方法是用一个抽象的基类来表示所有棋子,然后由子类来实现具体的方法,以计算移动的位置。

另一个问题是:这些动作要在哪里生成?

一个选择是在 MoveSelector 的 EnterStat 方法中。这是你将棋子可以移动的位置显示给用户的方法,因此这是一个合理的选择 。

计算有效目标的集合

常见的策略是选中一颗棋子,然后让 GameMananger 返回一个有效目标(比如移动)的集合。GameManager 使用该棋子的子类计算可能的目标集合。然后,过滤掉其中已经被占的或者已经离开棋盘的位置。

过滤后的集合传回给 MoveSelector,将合理的移动高亮显示,等待玩家做出选择。

小兵的移动最为简单,因此我们从它开始。

打开 Pieces 下面的 Pawn.cs,修改 MoveLocation 方法:

public override List MoveLocations(Vector2Int gridPoint) 
{
    var locations = new List<Vector2Int>()

    int forwardDirection = GameManager.instance.currentPlayer.forward
    Vector2Int forward = new Vector2Int(gridPoint.x, gridPoint.y + forwardDirection)
    if (GameManager.instance.PieceAtGrid(forward) == false)
    {
        locations.Add(forward)
    }

    Vector2Int forwardRight = new Vector2Int(gridPoint.x + 1, gridPoint.y + forwardDirection)
    if (GameManager.instance.PieceAtGrid(forwardRight))
    {
        locations.Add(forwardRight)
    }

    Vector2Int forwardLeft = new Vector2Int(gridPoint.x - 1, gridPoint.y + forwardDirection)
    if (GameManager.instance.PieceAtGrid(forwardLeft))
    {
        locations.Add(forwardLeft)
    }

    return locations
}

这个方法做了以下事情:

  1. 这段代码首先创建一个空的 list 用于保存位置。然后,创建了一个 location 用于表示前方的一个空格。
  2. 因为白子和黑子移动方向相反,玩家对象有一个值表示了小兵可以移动的方向。对于第一个玩家这个值是 +1,第二个玩家这个值是 -1。
  3. 小兵有一个特殊的移动方式和几点特殊规则。它虽然可以往前移动一步,但它却不能吃那个格子中的对方棋子,而是吃前方对角线上的棋子。在把前面这个格子标记为有效移动位置之前,首先要判断这个地方有没有被其它棋子占据。如果没有,你可以将这个瓦片放到 list 中。
  4. 对于吃子,你必须检查那个位置是否有棋子。如果有,才能吃子。

现在还不需要关心需要判断是自己的棋子还是对方的棋子——这个稍后再说。

在 GameManager.cs 中,在 Move 方法后添加一个方法:

public List MovesForPiece(GameObject pieceObject)
{
    Piece piece = pieceObject.GetComponent()
    Vector2Int gridPoint = GridForPiece(pieceObject)
    var locations = piece.MoveLocations(gridPoint)

    // filter out offboard locations
    locations.RemoveAll(tile => tile.x < 0 || tile.x > 7
        || tile.y < 0 || tile.y > 7)

    // filter out locations with friendly piece
    locations.RemoveAll(tile => FriendlyPieceAt(tile))

    return locations
}

这里,你从 GameOject 中获取一个 Piece 组件,以及它的当前位置。

然后,要求 GameManager 返回一个该棋子的 location 集合,并过滤掉其中无效的值。

RemoveAll 方法使用一个回调 lamda 表达式作为参数。这个方法遍历 list 中所有值,把它传给表达式中的 tile 变量。如果表达式返回 ture,这个值就会从 list 中移除。

第一个表达式移除所有 x、y 值超出棋盘以外的 location。第二个表达式移除所有己方棋子的位置。

在 MoveSelector.cs 中,添加一个实例变量:

private List<Vector2Int> moveLocations;
private List<GameObject> locationHighlights;

第一个变量保存了一个移动位置的 GridPoint 数组;第二个变量保存了玩家是否可以移动到那个地方的 overlay 瓦片数组。

在 EnterState 方法最后添加:

moveLocations = GameManager.instance.MovesForPiece(movingPiece)
locationHighlights = new List<GameObject>()

foreach (Vector2Int loc in moveLocations)
{
    GameObject highlight
    if (GameManager.instance.PieceAtGrid(loc))
    {
        highlight = Instantiate(attackLocationPrefab, Geometry.PointFromGrid(loc),
            Quaternion.identity, gameObject.transform)
    } 
    else 
    {
        highlight = Instantiate(moveLocationPrefab, Geometry.PointFromGrid(loc),
            Quaternion.identity, gameObject.transform)
    }
    locationHighlights.Add(highlight)
}

这段代码做了这几件事情:

首先,它从 GameManager 获取有效 location 数组,构造一个空数组用于保存 overlay 瓦片对象。然后对 lcoation 数组进行遍历,如果在某个位置已经有棋子,那么必定是对方棋子,因为己方棋子已经被过滤掉了。

对方棋子加上攻击蒙层,而其它棋子则添加移动蒙层。

执行动作

在 Reference Point 2 处添加代码,就在判断鼠标按钮的 if 语句中:

if (!moveLocations.Contains(gridPoint))
{
    return;
}

如果玩家玩家点击到无效的瓦片,退出函数。

最后,在 MoveSelector.cs 的 ExitState 中添加代码:

foreach (GameObject highlight in locationHighlights)
{
    Destroy(highlight);
}

这时,玩家已经选择了一个落点,你可以移除 overlay 对象了。

哇!改了这么多代码,仅仅是让小兵动一步而已。现在你已经完成了最艰巨的工作,其它棋子的移动就简单了。

下一个玩家

只有一边可以动的游戏并不多见。该解决下这个问题了!

为了让两边都能玩,你必须知道如何切换玩家以及在哪里添加代码。

因为 GameManager 负责所有游戏规则,切换玩家的代码应该放在这里。

实际上,切换玩家十分简单。在 GameManager 中定义有当前玩家和其它玩家的变量,你只需要交换二者即可。

更麻烦一点的问题是:在哪里调用切换玩家的方法?

当玩家移动了一颗棋子后,这个玩家的回合就结束了。MoveSelector 的 ExitState 方法在棋子被移动之后都会调用,因此这里就是进行切换的好地方。

在 GameManager.cs 最后添加这个方法:

public void NextPlayer()
{
    Player tempPlayer = currentPlayer;
    currentPlayer = otherPlayer;
    otherPlayer = tempPlayer;
}

交换需要使用临时变量;否则在拷贝一个值之前会导致原来的值被覆盖。

回到 MoveSelector.cs,在 ExitState 方法中,调用 EnterState 之前添加代码:

GameManager.instance.NextPlayer();

这就可以了!ExitState 和 EnterState 会进行清理工作。

进入试玩模式,你可以移动双方棋子了。距离真正的游戏不远喽!

吃子

吃子是棋类游戏中的重要内容。俗话说得好,“在真正失去骑士之前,一切都不过是游戏”。

因为游戏规则由 GameManager 负责,所以打开它增加一个方法:

public void CapturePieceAt(Vector2Int gridPoint)
{
    GameObject pieceToCapture = PieceAtGrid(gridPoint);
    currentPlayer.capturedPieces.Add(pieceToCapture);
    pieces[gridPoint.x, gridPoint.y] = null;
    Destroy(pieceToCapture);
}

这里,GameManager 查找位于目标位置的棋子。这颗棋子被添加到当前玩家的“吃掉的棋子”数组。然后,从 GameManager 的棋盘贴片中删除该棋子的记录,然后销毁 GameObject,导致从场景中移除该棋子。

要吃掉一颗棋子,你需要移动到其位置并点击。因此应当在 MoveSelector.cs 中调用这个方法。

在 Update 方法中,找到注释 Reference Point 3 的地方,编写代码:

else
{
    GameManager.instance.CapturePieceAt(gridPoint)
    GameManager.instance.Move(movingPiece, gridPoint)
}

之前 if 语句是判断目标位置是否有棋子。因为之前的移动已经过滤掉了己方棋子,那么如果有棋子则肯定是敌方棋子。

将敌方棋子移除后,就可以将手持的棋子放进去了。

点击 play,移动小兵,吃掉一颗棋子。

结束游戏

当玩家杀死对方的王之后,游戏就结束了。在你吃子时,需要判断对方是否是王。如果是,游戏结束。

当怎样才能结束游戏呢?一种方法是删除棋盘上的 TileSelector 和 MoveSelector 脚本。

在 GameManager.cs 的 CapturePieceAt 中,在销毁被杀死的棋子之前,添加代码:

if (pieceToCapture.GetComponent<Piece>().type == PieceType.King)
{
    Debug.Log(currentPlayer.name + " wins!")
    Destroy(board.GetComponent<TileSelector>())
    Destroy(board.GetComponent<MoveSelector>())
}

光是禁用这些组件还不够。因为下一次调用 ExitState 和 EnterState 时会重新 enable 它们,那样游戏又可以玩了。

Destroy 方法不仅仅可用于 GameObject 类,还可以删除这个对象上绑定的组件。

点击 play。移动小兵,吃掉对方的王。你会看到 Unity 控制台打印出“胜利”的字样。

你可以挑战一下自己,添加显示 Game Over 和跳转回主菜单画面的 UI。

接下来祭出我们的大杀器,移动威力更强的棋子!

特殊移动

Piece 及其子类是封装特殊移动的好地方。

你可以使用和小兵一样的方法为其它棋子添加特殊移动。能够向不同方向移动一个空格的棋子,比如国王和骑士,都可以用同样的方式创建。试试看你能不能实现这些移动规则。

如果需要帮助,请阅读最终的项目代码。

多格移动

能够向某一方向移动多格的棋子要难一点。比如象、车和王后。为了简便起见,我们以象为例。

打开 Bishop.cs, 将 MoveLocations 替换为:

public override List<Vector2Int> MoveLocations(Vector2Int gridPoint)
{
    List<Vector2Int> locations = new List<Vector2Int>();

    foreach (Vector2Int dir in BishopDirections)
    {
        for (int i = 1; i < 8; i++)
        {
            Vector2Int nextGridPoint = new Vector2Int(gridPoint.x + i * dir.x, gridPoint.y + i * dir.y);
            locations.Add(nextGridPoint);
            if (GameManager.instance.PieceAtGrid(nextGridPoint))
            {
                break;
            }
        }
    }

    return locations;
}

foreach 循环每个方向。对于每一个方向,再次对棋子可以移动的位置进行循环。因为棋盘以外的位置会被过滤,所以你只需保证格子足够多不会遗漏任何瓦片即可。

在每一步里,创建一个 GridPoint 网点并添加到 list 里。然后判断当前位置是否有棋子。如果有,中断内层循环进入下一个方向。

因为如果已经有棋子的话会阻断棋子的移动,因此必须 break。同时,在后面会过滤掉己方棋子的位置,所以在这里你不需要关心这个问题。

注:如果你需要区分前后的方向,或者左右方向,那么你需要考虑黑白棋子在移动方向上的区别。

对于国际象棋,只有小兵才需要考虑这个问题,但如果是其它游戏则也可能需要进行这种区别。

好了!点击 play 试玩一下。

移动王后

王后是最强大的棋子,因此把它放到最后。

王后的移动是象和车的结合;在基类中,有一个数组用来保存每个棋子的移动方向。你可以用这个数组将两者结合。

将 Queen.cs 的 MoveLocations 修改为:

public override List<Vector2Int> MoveLocations(Vector2Int gridPoint)
{
    List<Vector2Int> locations = new List<Vector2Int>();
    List<Vector2Int> directions = new List<Vector2Int>(BishopDirections);
    directions.AddRange(RookDirections);

    foreach (Vector2Int dir in directions)
    {
        for (int i = 1; i < 8; i++)
        {
            Vector2Int nextGridPoint = new Vector2Int(gridPoint.x + i * dir.x, gridPoint.y + i * dir.y);
            locations.Add(nextGridPoint);
            if (GameManager.instance.PieceAtGrid(nextGridPoint))
            {
                break;
            }
        }
    }

    return locations;
}

唯一不同的地方是,你把方向数组转变成 List。

List 的特点是可以将其他数组中的方向添加进来,把所有方向都添加到这个 List。该方法的其余部分和 Bishop 类相同。

点击 play,把小兵移开,检查一下效果是否实现。

接下来去哪里?

还有一些内容需要你完成,比如实现王、骑士和车的移动。如果做不错来,请参考下载下来的项目资源代码。

还有一些特殊规则有待实现,比如允许兵第一步可以移动两格而不是一格,王车易位等。

一般的模式是向 GameManager 添加变量和方法,以记录这些情况,并检查它们在移动时是否可用。如果可用,则在 MoveLocations 中添加相应的位置。

还可以在视觉方面进行改进。例如,棋子平滑移动到目标位置,或者可以旋转镜头以表示其它玩家在进行回合时的视图。

有任何问题和建议,或者想秀一下你的 3D 象棋游戏,下方讨论区等你哟~。

原文:How to Make a Chess Game with Unity
作者:Brian Broom
译者:kmyhy


标题:[转] Unity实战之象棋游戏
作者:shirln
地址:https://mmzsblog.cn/articles/2020/09/24/1600911203442.html
-----------------------------
如未加特殊说明,此网站文章均为原创。
转载须在文章起始位置标注作者及原文连接,否则保留追究法律责任的权利。

个人微信公众号 ↓↓↓                 

微信搜一搜爱上游戏开发