查看: 2773|回复: 0
收起左侧

[已翻译] 2D平台游戏的简单物理引擎(二)

[复制链接]

[已翻译] 2D平台游戏的简单物理引擎(二)[复制链接]

初末 发表于 2017-12-25 14:46:07 [显示全部楼层] |只看大图 回帖奖励 |倒序浏览 |阅读模式 回复:  0 浏览:  2773
翻译:张华栋(wcby)      审校:陈敬凤(nunu)

关卡几何
构建平台游戏的关卡有两种基本方法。其中一种是使用网格,并且在格子里面放置合适的瓦片,另外一种方法更加自由一些,你可以在任意你想放置几何体的地方摆放几何体。
两种方法各有优缺点。我们将使用网格方法,所以让我们看下这种方法相比较其他方法有什么优点:
·         性能更好-大部分情况下,与网格进行动态碰撞检测的消耗比松散放置的物体的动态碰撞的消耗低。
·         处理寻路的时候更加简单。
·         瓦片比松散放置的物体更加准确和可预测,特别是在需要考虑地面可破坏的情况下。
建立地图类
让我们从创建地图类Map开始。它会包含所有地图相关的特定数据。
1
2
3
public class Map
{
}
现在我们需要定义地图包含的所有瓦片信息,但是在做这个之前,我们需要知道我们的游戏里都有哪些类型的瓦片。目前我们就计划三种类型:空的瓦片、放置了物体的瓦片和单向平台。
1
2
3
4
5
6
public enum TileType
{
    Empty,
    Block,
    OneWay
}
在这个demo中,瓦片的类型直接对应着瓦片所需的碰撞类型,但是在一个真实游戏中往往不是如此。因为你可能有很多外观不同的瓦片,最好是添加一个新的类型,比如GrassBlock、GrassOneWay等等,来让TileType这个枚举不仅定义了碰撞类型还定义了瓦片的外观。
现在我们可以在地图类里面加一个瓦片数组。
1
2
3
4
public class Map
{
    private TileType[,] mTiles;
}
当然,只是一个瓦片地图看上去并没有什么用,所以我们需要精灵来包装瓦片数据。在Unity里,每个瓦片对应一个单独的物体非常的没有效率,但是我们只是使用瓦片地图来测试我们的物理引擎,所以在demo中这么做还可以。
1
private SpriteRenderer[,] mTilesSprites;
地图类也需要世界空间的一个位置,这样当我们不止一个地图实例的时候,我们可以用位置变量来把地图实例分开。
1
public Vector3 mPosition;
瓦片的宽度和高度。
1
2
public int mWidth = 80;
public int mHeight = 60;
瓦片的数目:在这个demo中,我们的地图相当小,只有16*16。
1
public const int cTileSize = 16;
地图类的成员变量就这些了,我们还需要一些帮助函数来帮助我们更容易获取地图的数据。首先来写一个把世界坐标转换成地图瓦片坐标的函数。
1
2
3
public Vector2i GetMapTileAtPoint(Vector2 point)
{
}
正如你所见,这个函数接收一个Vector2 类型的值作为参数,返回一个Vector2i类型的值,Vector2i代表的是整数类型的2D向量,而Vector2代表的是浮点数类型的2D向量。
把世界空间中的位置转成地图位置非常的直观,我们只要对point进行mPosition大小的偏移,然后就得地图中瓦片的坐标,然后除以瓦片大小就得到了结果。
1
2
3
4
5
public Vector2i GetMapTileAtPoint(Vector2 point)
{
    return new Vector2i((int)((point.x - mPosition.x + cTileSize / 2.0f) / (float)(cTileSize)),
                (int)((point.y - mPosition.y + cTileSize / 2.0f) / (float)(cTileSize)));
}
请注意我们必须对点的位置进行额外的cTileSize / 2.0f的偏移,这是因为瓦片的锚点在瓦片的中心。让我们再创建一个额外的函数来返回地图空间中这个点的x和y分量。这个函数在后面会有用。
1
2
3
4
5
6
7
8
9
public int GetMapTileYAtPoint(float y)
{
    return (int)((y - mPosition.y + cTileSize / 2.0f) / (float)(cTileSize));
}
public int GetMapTileXAtPoint(float x)
{
    return (int)((x - mPosition.x + cTileSize / 2.0f) / (float)(cTileSize));
}
我们还需要创建一个配套函数:给定一个瓦片,能够返回它在世界空间中的位置。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public Vector2 GetMapTilePosition(int tileIndexX, int tileIndexY)
{
    return new Vector2(
            (float)(tileIndexX * cTileSize) + mPosition.x,
            (float)(tileIndexY * cTileSize) + mPosition.y
        );
}
public Vector2 GetMapTilePosition(Vector2i tileCoords)
{
    return new Vector2(
        (float)(tileCoords.x * cTileSize) + mPosition.x,
        (float)(tileCoords.y * cTileSize) + mPosition.y
        );
}
除了变换位置,我们还需要一些函数来判断一个给定位置的瓦片的类型是什么,是空瓦片还是放置了物体的瓦片或者是单向平台。让我们从一个非常通用的GetTile函数开始,它将返回一个特定瓦片的类型。
1
2
3
4
5
6
7
8
public TileType GetTile(int x, int y)
{
    if (x < 0 || x >= mWidth
        || y < 0 || y >= mHeight)
        return TileType.Block;
    return mTiles[x, y];
}
正如你看到的那样,在我们返回瓦片类型之前,我们先判断这个给定的位置是否超出边界。如果超出了边界,我们将把它当作放置了物体的瓦片来对待,如果没有超出边界,我们将返回一个真正的类型。
实现的下一个函数是用来判断一个瓦片是否是一个障碍物。
1
2
3
4
5
6
7
8
public bool IsObstacle(int x, int y)
{
    if (x < 0 || x >= mWidth
        || y < 0 || y >= mHeight)
        return true;
    return  (mTiles[x, y] == TileType.Block);
}
跟前面的方法一样,我们先判断这个瓦片是否超出边界,如果超出边界的话,我们返回为真,所以任何超出边界的瓦片都被当作是一个障碍物。
现在让我们判断一个瓦片是否是一个地面瓦片。角色既可以站立在一个阻挡上也可以站在一个单向平台上,所以当瓦片是这两种类型的时候,我们要返回为真。
1
2
3
4
5
6
7
8
public bool IsGround(int x, int y)
{
    if (x < 0 || x >= mWidth
       || y < 0 || y >= mHeight)
        return false;
    return (mTiles[x, y] == TileType.OneWay || mTiles[x, y] == TileType.Block);
}
最后,让我们用相同的方法来添加IsOneWayPlatform和 IsEmpty这两个函数。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public bool IsOneWayPlatform(int x, int y)
{
    if (x < 0 || x >= mWidth
        || y < 0 || y >= mHeight)
        return false;
    return (mTiles[x, y] == TileType.OneWay);
}
public bool IsEmpty(int x, int y)
{
    if (x < 0 || x >= mWidth
        || y < 0 || y >= mHeight)
        return false;
    return (mTiles[x, y] == TileType.Empty);
}
这就是我们在地图类内要做的事情。现在我们可以更进一步了,去实现角色与瓦片地图的碰撞了。
角色与地面的碰撞
让我们回到MovingObject类。我们需要创建一系列函数来检测角色是否与瓦片地图发生了碰撞。
我们用来判断角色是否与瓦片相碰撞的函数是非常简单的。我们将检测所有的瓦片是否都在移动物体的AABB的外面。
DSC0000.png
黄色盒子代表了角色的AABB,我们将检测沿着红线的那些瓦片,如果他们中的某个与角色发生了重叠,我们将把对应的碰撞变量设为真(比如mOnGround,mPushesLeftWall,mAtCeiling或者mPushesRightWall)。
我们先从创建HasGround函数开始,这个函数将用来判断角色是否与一个地面瓦片相碰撞。
1
2
3
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
}
如果角色与某个底部的瓦片相重叠,函数将返回真。它接收旧位置、当前位置和当前速度作为参数,返回与角色发生碰撞的瓦片顶点的Y坐标以及碰撞的瓦片是否是一个单向平台。
我们要做的第一件事是计算AABB的中心点。
1
2
3
4
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var center = position + mAABBOffset;
}
现在我们已经计算出来了AABB的中心点,对于底部检测我们需要计算底部检测线的开始和结束位置。检测线比AABB的底部轮廓只低了一个像素。
1
2
3
4
5
6
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var center = position + mAABBOffset;
    var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
    var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
}
bottomLeft和bottomRight代表传感器的两端。现在我们既然已经有了这些变量,我们可以计算哪些瓦片是需要检测的了。让我们创建一个循环,依次从左到右遍历瓦片。
1
2
3
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
}
请注意不存在什么条件可以退出该循环,只有等到循环结束才能退出。
在循环中我们应该做的第一件事是确保checkedTile.x的值不超过传感线的右端。因为我们移动检测点是按照瓦片大小的倍数来移动的,所以这个事情是有意义的。比如角色是1.5个瓦片宽,我们先是检查检测线左边的瓦片,然后是检测线右边的瓦片,然后是离检测线右边1.5瓦片的位置,而不是2个瓦片的位置。
1
2
3
4
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
    checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
}

现在我们需要得到瓦片在地图空间的坐标以便检测瓦片的类型。
1
2
3
4
5
6
7
8
9
int tileIndexX, tileIndexY;
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
    checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
     
    tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
    tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y);
}
首先,让我们来计算瓦片的顶点位置。
01
02
03
04
05
06
07
08
09
10
11
int tileIndexX, tileIndexY;
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
    checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
    tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
    tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y);
     
    groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y;
}
现在,如果正在检测的瓦片是一个障碍物,我们可以直接返回真。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
int tileIndexX, tileIndexY;
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
    checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
     
    tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
    tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y);
     
    groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y;
     
    if (mMap.IsObstacle(tileIndexX, tileIndexY))
        return true;
}
最后,让我们检查下看看我们是否已经遍历了所有与传感线相交的瓦片。如果已经遍历了所有瓦片,我们就可以安全地退出循环。如果我们退出了循环还没有发现一个相交的瓦片,我们需要返回假来让调用者知道物体下面并没有地面。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
int tileIndexX, tileIndexY;
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
    checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
     
    tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
    tileIndexY = mMap.GetMapTileYAtPoint(checkedTile.y);
     
    groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y;
     
    if (mMap.IsObstacle(tileIndexX, tileIndexY))
        return true;
         
    if (checkedTile.x >= bottomRight.x)
        break;
}
return false;
这是检测的最基础版本,现在让我们回到这里修正它。找到UpdatePhysics函数内对应的部分,我们旧的地面检测看起来是这样的:
1
2
3
4
5
6
7
if (mPosition.y <= 0.0f)
{
    mPosition.y = 0.0f;
    mOnGround = true;
}
else
    mOnGround = false;
让我们用一个新创建的方法来代替它。如果角色正在下落同时我们发现了一个障碍物正在下落的路径上,我们需要让角色在障碍物上停住,并且设置mOnGround为真。让我们从条件判断开始。
1
2
3
4
5
float groundY = 0;
if (mSpeed.y <= 0.0f
    && HasGround(mOldPosition, mPosition, mSpeed, out groundY))
{
}
如果条件满足,我们需要把角色定位在与角色碰撞的瓦片的上方。
1
2
3
4
5
6
float groundY = 0;
if (mSpeed.y <= 0.0f
    && HasGround(mOldPosition, mPosition, mSpeed, out groundY))
{
    mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y;
}
如你看到的那样,这非常的简单,因为函数返回的是与物体平行的关卡地面。做完这个处理以后,我们只需要设置垂直速度的值为0以及设置mOnGround为真。
1
2
3
4
5
6
7
8
float groundY = 0;
if (mSpeed.y <= 0.0f
    && HasGround(mOldPosition, mPosition, mSpeed, out groundY))
{
    mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y;
    mSpeed.y = 0.0f;
    mOnGround = true;
}
如果角色的垂直速度大于0或者角色没有碰到任何地面,我们需要设置mOnGround为假。
01
02
03
04
05
06
07
08
09
10
float groundY = 0;
if (mSpeed.y <= 0.0f
    && HasGround(mOldPosition, mPosition, mSpeed, out groundY))
{
    mPosition.y = groundY + mAABB.halfSize.y - mAABBOffset.y;
    mSpeed.y = 0.0f;
    mOnGround = true;
}
else
    mOnGround = false;
现在来让我们看看它工作的效果怎么样。
DSC0001.png
如你看到的那样,它工作的非常顺利!与两侧墙的碰撞检测以及角色顶部的碰撞检测现在还没有实现,但是角色每次在碰到地面的时候都停住了。我们还需要在碰撞检测里面加些东西以便它能更加健壮。
其中一个我们必须要解决的问题是角色在帧与帧之间的位移太大以至于没有能发现碰撞。这个情况如下图所示。
DSC0002.png
这个情形目前没有发生的原因是因为我们限制了最大下落速度在一个合理的范围内,并且以60帧每秒的速度来更新物理部分,所以帧与帧之间的位置差别很小。让我们来看下如果以每秒30次的速度来更新物理部分会发生什么。
DSC0003.png
如你所看到的那样,在这个场景中我们的地面碰撞检测失败了。为了修正这个问题,我们不能简单检测物体现在的位置是否在地面上,而是需要判断两帧之间的移动轨迹上是否存在障碍物。
让我们回到HasGround函数。在这里除了计算中心点位置以外,我们还需要计算之前帧的中心点。
1
2
3
4
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var oldCenter = oldPosition + mAABBOffset;
    var center = position + mAABBOffset;
我们还需要得到之前帧检测线的位置。
1
2
3
4
5
6
7
8
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var oldCenter = oldPosition + mAABBOffset;
    var center = position + mAABBOffset;
     
    var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
    var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
    var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
现在我们需要计算在哪个瓦片将开始进行碰撞检测,以及在哪个瓦片我们将停止碰撞检测。
01
02
03
04
05
06
07
08
09
10
11
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var oldCenter = oldPosition + mAABBOffset;
    var center = position + mAABBOffset;
     
    var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
    var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
    var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
     
    int endY = mMap.GetMapTileYAtPoint(bottomLeft.y);
    int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
我们从前一帧检测线所在位置的瓦片开始遍历,到当前帧检测线所在位置的瓦片结束遍历。这是因为当我们检测与地面的碰撞时,我们假设角色正在下落,这意味着角色正从一个更高的位置向一个低的位置移动。
最后,我们需要另外一个遍历循环。在我们给外部循环填充代码之前,让我们考虑下面这个情形。
DSC0004.png
在上面的图中你可以看到一个箭头在快速移动。这个例子说明我们不仅需要遍历所有垂直经过的瓦片,我们还要对之前帧的位置和现在帧的位置进行插值来找到近似的路径以便找到经过的每个瓦片。如果我们只是使用当前物体的位置,在上面的例子中会检测到一个碰撞,而实际上并没有碰撞发生。
让我们重命名bottomLeft、bottomRight、 newBottomLeft和 newBottomRight,以便我们知道这是新一帧检测线的位置。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var oldCenter = oldPosition + mAABBOffset;
    var center = position + mAABBOffset;
     
    var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
    var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
    var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f,newBottomLeft.y);
     
    int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y);
    int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
     
    int tileIndexX;
    for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY)
    {
    }
     
    return false;
}
现在在新的循环里,让我们对检测线的位置进行插值,这样在循环开始的时候,我们假设检测线的位置在前一帧的位置,而结束的时候检测线的位置在当前帧的位置。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var oldCenter = oldPosition + mAABBOffset;
    var center = position + mAABBOffset;
     
    var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
    var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
    var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y);
     
    int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y);
    int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
    int dist = Mathf.Max(Mathf.Abs(endY - begY), 1);
     
    int tileIndexX;
    for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY)
    {
        var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endY - tileIndexY) / dist);
        var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
    }
     
    return false;
}
请注意我们是用Y轴上瓦片位置差来对向量进行插值。当旧位置与新位置在同一个瓦片中时,垂直距离差为0,在这种情况下,我们没有办法进行除法运算。为了解决这个问题,我们需要距离最少为1。所以当这样的情形出现的时候(它将经常出现),我们将在碰撞检测中使用新位置。
最后,对于每一次迭代,我们需要执行与地面检测中判断物体宽度相同的代码。
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
    var oldCenter = oldPosition + mAABBOffset;
    var center = position + mAABBOffset;
     
    var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
    var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
    var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y);
     
    int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y);
    int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
    int dist = Mathf.Max(Mathf.Abs(endY - begY), 1);
     
    int tileIndexX;
    for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY)
    {
        var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endY - tileIndexY) / dist);
        var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
     
        for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
        {
            checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
            
            tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
            
            groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y;
            
            if (mMap.IsObstacle(tileIndexX, tileIndexY))
                return true;
                 
            if (checkedTile.x >= bottomRight.x)
                break;
        }
    }
     
    return false;
}
要检查的内容太多了。你可以想象的到,如果游戏物体移动的非常快,碰撞检测的方法会有一点过于昂贵,但它可以保证没有奇怪的故障,比如物体穿墙而过。
总结
唷,实现这个功能所需的代码比我们想象的要多一些,对吧?如果你发现任何错误或者更加简单的方法,请在评论中描述下以便让我和其他人知道!碰撞检测应该足够健壮以便我们不需担心可能会发生莫名其妙的物体从瓦片地图滑落的事情。
我们写了大量的代码来保证没有物体会以非常大的速度来经过瓦片,但是如果某些特定的游戏不需要这个功能,我们可以安全的移除额外的代码来提高性能。给那些快速移动的物体加一个标签也是一个好主意,以便这些物体会使用一个比较消耗性能的碰撞检测。
我们还有很多东西没有涉及,但是我们已经成功的实现了一个可靠的地面碰撞检测,这个可以很容易的映射到其他三个方向,我们将在下一部分做这个事情。
版权声明:原文作者未做权利声明,视为共享知识产权进入公共领域,根据原站点版权声明自动获得授权。

+1
2765°C
沙发哦 ^ ^ 马上
因分享而快乐,学习以自强!
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

VR/AR版块|Unity3d|Unreal4|新手报道|小黑屋|站点地图|沪ICP备14023207号-9|【泰斗社区】-专注互联网游戏和应用的开发者平台 ( 沪ICP备14023207号-9 )|网站地图

© 2001-2013 Comsenz Inc.  Powered by Discuz! X3.4

1
QQ