喵的Unity游戏开发之路 - 玩家控制下的球的滑动

        很多童鞋没有系统的Unity3D游戏开发基础,也不知道从何开始学。为此我们精选了一套国外优秀的Unity3D游戏开发教程,翻译整理后放送给大家,教您从零开始一步一步掌握Unity3D游戏开发。 本文不是广告,不是推广,是免费的纯干货!本文全名:喵的Unity游戏开发之路 - 移动 - 滑动球体 - 玩家控制下的球的滑动


  • 将带有轨迹的球体放在平面上。

  • 根据玩家输入来定位球体。

  • 控制速度和加速度。

  • 限制球体的位置,使其从边缘反弹。



  • 这是有关控制角色移动的教程系列的第一部分。具体来说,我们将根据玩家的输入滑动一个球体。

    本教程使用Unity 2019.2.9f1制作。假定您已经先阅读了基础教程。

    最终效果之一





    球体卡在平面上。





    控制位置


    许多游戏都是关于一个角色,角色必须四处移动以实现某些目标。玩家的任务是引导角色。动作游戏通常通过按下键或转动操纵杆来操纵角色,从而使您可以直接控制游戏。点击游戏可让您指示目标位置,角色会自动移动到该位置。编程游戏可让您编写角色执行的指令。等等。


    在本教程系列中,我们将重点介绍如何在3D动作游戏中控制角色。我们从在一个小的平面矩形上滑动一个球体开始简单。一旦我们牢牢掌握了这一点,将来我们就可以使其变得更加复杂。




    设置场景


    从一个新的默认3D项目开始。尽管您可以使用自己选择的渲染管道,但此时包管理器不需要任何东西。


    我一直使用线性色彩空间,您可以通过“Edit / Project Settings / Player / Other Settings在项目设置中进行配置。



    默认的SampleScene场景有一个摄像头和一个定向灯,我们将保留它们。创建一个代表地面的平面以及一个球体,两者均位于原点。默认球体的半径为0.5,因此将其Y坐标设置为0.5,使其看起来像位于地平面的顶部。



    我们将自己限制为在地面上进行2D运动,因此让我们将摄像机向下放置在平面上方,以便在游戏窗口中清晰地看到游戏区域。还将Projection 模式设置为“Orthographic。这摆脱了透视,使我们能够看到2D运动而不会变形。



    剩下的唯一使我们困惑的是球体的阴影。通过将灯光的“ Shadow Type阴影类型 ”设置为“ None ”或“ No Shadows无阴影”来消除它,具体取决于Unity版本。



    为地面和球体创建材质,并根据需要进行配置。我将球体设为黑色,将地面暗淡的颜色设为灰色。我们还将通过轨迹可视化运动,因此也要为此创建材质。我将使用一种淡红色的材质。最后,我们需要一个MovingSphere脚本来实现运动。



    该脚本可以以MonoBehaviour的空扩展名开头。


  • using UnityEngine;
    public class MovingSphere : MonoBehaviour { }



    将一个TrailRenderer和我们的MovingSphere组件都添加到球体中。保持其他一切不变。



    将跟踪材质分配给组件的“ 材质”数组的第一个也是唯一的元素TrailRenderer。它并不需要投下阴影,尽管这并不是必须的,因为我们还是禁用了那些阴影。除此之外,将“ 宽度”从1.0 减小到更合理的值(例如0.1),这将生成细线。



    尽管我们尚未编码任何运动,但可以通过进入播放模式并在场景窗口中移动球体来预览其外观。






    读取玩家输入


    要移动球体,我们必须阅读玩家的输入命令。我们使用MovingSphereUpdate方法来做到这一点。播放器输入为2D,因此我们可以将其存储在Vector2变量中。最初,我们将其X和Y分量都设置为零,然后使用它们将球体放置在XZ平面中。因此,输入的Y分量成为位置的Z分量。Y位置保持零。


    
    

  • using UnityEngine;
    public class MovingSphere : MonoBehaviour {
    void Update () { Vector2 playerInput; playerInput.x = 0f; playerInput.y = 0f; transform.localPosition = new Vector3(playerInput.x, 0f, playerInput.y); }}


    从播放器检索方向输入的最简单方法是调用Input.GetAxis轴名称。默认情况下,Unity 定义了水平垂直输入轴,您可以在项目设置的“ 输入”部分中进行检查。我们将水平值用于X,将垂直值用于Y。

  •     playerInput.x =Input.GetAxis("Horizontal");    playerInput.y =Input.GetAxis("Vertical");


    默认设置将这些轴链接到箭头和WASD键。输入值也经过调整,因此按键的行为有点像操纵杆。您可以根据需要调整这些设置,但我保留默认设置。



    使用箭头或WASD键。


    两个轴都有第二个定义,将它们链接到操纵杆或左操纵杆的输入。这样可以使输入更加流畅,但是我将使用除下一个动画之外的所有动画的关键帧。



    使用控制棒。








    归一化输入向量


    轴在静止时返回零,而在极限时返回-1或1。当我们使用输入来设置球体的位置时,它被约束为具有相同范围的矩形。至少,键输入就是这种情况,因为键是独立的。如果是棍子,则尺寸是相互关联的,通常我们在任何方向上都被限制为距原点的最大距离为1,从而将位置限制在一个圆内。


    控制器输入的优点是,无论方向如何,输入向量的最大长度始终为1。因此,各个方向的移动速度都可以一样快。按键不是这种情况,单个按键的最大值为1,而同时按下两个按键的最大值为√2,这意味着对角线移动最快。


    由于勾股定理,键的最大值为√2。轴值定义直角三角形两侧的长度,组合的矢量为斜边。因此,输入向量的大小为x2+y2" role="presentation"> sqrt(x ^ 2 + y ^ 2)。


    通过将输入矢量除以其大小,可以确保矢量的长度永远不会超过1。结果始终是单位长度向量,除非其初始长度为零,在这种情况下,结果不确定。此过程称为标准化向量。我们可以通过调用Normalize向量来做到这一点,向量将自行缩放并在结果不确定时变为零向量。

  •     playerInput.x = Input.GetAxis("Horizontal");    playerInput.y = Input.GetAxis("Vertical");    playerInput.Normalize();



    标准化的按键输入。





    约束输入向量


    始终对输入向量进行归一化会将位置限制为始终位于圆上,除非输入是中性的,在这种情况下,我们最终会到达原点。原点和圆之间的线表示一个框架,其中圆从中心跳到圆或向后跳。


    这种全有或全无的输入可能是理想的,但让我们也使圆内的所有位置也有效。我们仅通过调整输入矢量的大小(如果其大小超过1)来做到这一点。一种方便的方法是调用静态Vector2.ClampMagnitude方法而不是Normalize,使用向量(最大为1)作为参数。结果是一个相同或缩小到所提供最大值的向量。



  •     //playerInput.Normalize();    playerInput = Vector2.ClampMagnitude(playerInput, 1f);



    按键输入受限。






    控制速度


    到目前为止,我们一直在直接使用输入来设置球体的位置。这意味着,当输入向量“ i”改变时,球体的位置“ p”立即改变为相同值。因此,“ p = i”。这不是适当的运动,是隐形传态。一种更自然的控制球体的方法是通过将位移矢量d添加到其旧位置p_0来确定其下一个位置p_1,因此p_1 = p_0 + d。




    相对运动


    通过使用d = i而不是p = i,我们使输入和位置之间的关系不太直接。这样就消除了位置上的约束,因为它现在相对于自身而不是第一次更新后的原点。因此,该位置由无限迭代序列“ p_(n + 1)= p_n + d”描述,其中“ p_0”定义为起始位置。

  •     Vector3 displacement = new Vector3(playerInput.x, 0f, playerInput.y);    transform.localPosition+= displacement;






    速度

    我们的球体确实可以移动到任何地方,但是它是如此之快以至于难以控制。这是每次更新都添加输入向量的结果。帧速率越高,速度越快。为了获得一致的结果,我们不希望帧频影响我们的输入。如果我们使用恒定的输入,则无论帧速率是否可能波动,我们都需要恒定的位移。

    为了我们的目的,一个帧代表一个持续时间:从上一帧的开始到当前帧之间经过了t时间,我们可以通过访问Time.deltaTime。因此,我们的位移实际上是“ d = it”,我们错误地认为“ t”是常数。

    位移以Unity单位测量,假定代表一米。但是我们将输入乘以持续时间,以秒表示。为了达到米,输入必须以米/秒为单位。因此,输入矢量表示速度:“ v = i”和“ d = vt”。

  •     Vector3 velocity = new Vector3(playerInput.x, 0f, playerInput.y);    Vector3 displacement =velocity * Time.deltaTime;    transform.localPosition += displacement;





    速率

    我们的最大输入向量的大小为1,表示每秒一米的速度,等于每小时3.6公里,大约每小时2.24英里。那不是很快。


    我们可以通过缩放输入向量来提高最大速度。比例因子表示最大速度,即没有方向的速度。添加一个具有SerializeField属性的字段maxSpeed(默认值为10),并为其赋予Range属性(例如1–100)。


    
    

  •   [SerializeField, Range(0f, 100f)]  float maxSpeed = 10f;


    SerializeField是做什么的?

    它告诉Unity对字段进行序列化,这意味着它已保存并在Unity编辑器中公开,因此可以通过检查器进行调整。我们也可以创建该public字段,但是通过这种方式,该字段仍然不受MovingSphere类外部代码的影响。


    将输入向量和最大速度相乘以找到所需的速度。

  •     Vector3 velocity =      new Vector3(playerInput.x, 0f, playerInput.y)* maxSpeed;



    最高速度设置为10。





    加速

    由于我们可以直接控制速度,因此可以立即进行更改。仅输入系统应用的过滤会稍微减慢更改的速度。实际上,速度不能立即改变。更改职位需要一定的精力和时间,就像更改职位一样。速度的变化率称为加速度“ a”,导致“ v_(n + 1)= v_n + at”,而“ v_0”为零向量。减速只是与当前速度相反的加速度,因此不需要特殊处理。

    让我们看看如果使用输入矢量直接控制加速度而不是速度来控制时会发生什么。这需要我们跟踪当前速度,因此将其存储在一个字段中。

  •   Vector3 velocity;


    现在,输入向量在Update中定义了加速度,但让我们暂时将其乘以maxSpeed,暂时将其重新解释为最大加速度。然后将其添加到速度,然后计算位移。

  •     Vector3 acceleration=      new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;    velocity += acceleration * Time.deltaTime;    Vector3 displacement = velocity * Time.deltaTime;






    所需速度


    控制加速度而不是速度会产生更平滑的运动,但同时也会削弱我们对球体的控制。就像我们开车而不是步行。在大多数游戏中,需要对速度进行更直接的控制,因此让我们回到这种方法。但是,施加加速度确实会产生更平滑的运动。



    我们可以通过直接控制目标速度并将加速度应用于实际速度,直到与所需速度相匹配,来结合这两种方法。然后,我们可以通过调整球的最大加速度来调整球的响应速度。为此添加一个可序列化的字段。

  •   [SerializeField, Range(0f, 100f)]  float maxAcceleration = 10f;


    现在,Update我们使用输入矢量来定义所需的速度,而不再用旧的方式调整速度。

  •     Vector3 desiredVelocity=      new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;    //velocity += acceleration * Time.deltaTime;

    	


    相反,我们首先通过将最大加速度乘以t来找到最大速度变化。这就是我们能够更改此更新速度的程度。

  •     Vector3 desiredVelocity =      new Vector3(playerInput.x, 0f, playerInput.y) * maxSpeed;    float maxSpeedChange = maxAcceleration * Time.deltaTime;


    首先,我们仅考虑速度的X分量。如果小于期望值,则添加最大更改。

  •     float maxSpeedChange = maxAcceleration * Time.deltaTime;    if (velocity.x < desiredVelocity.x) {      velocity.x += maxSpeedChange;    }


    这可能会导致过冲,我们可以通过选择增加值和期望值中的最小值来防止。我们可以在这里使用一种Mathf.Min方法。

  •     if (velocity.x < desiredVelocity.x) {      //velocity.x += maxSpeedChange;      velocity.x =        Mathf.Min(velocity.x + maxSpeedChange, desiredVelocity.x);    }


    或者,速度可能大于所需速度。在那种情况下,我们减去最大变化,并通过Mathf.Max获取最大值和所需值。

  •     if (velocity.x < desiredVelocity.x) {      velocity.x =        Mathf.Min(velocity.x + maxSpeedChange, desiredVelocity.x);    }    else if (velocity.x > desiredVelocity.x) {      velocity.x =        Mathf.Max(velocity.x - maxSpeedChange, desiredVelocity.x);    }


    我们还可以通过便捷的Mathf.MoveTowards方法来完成所有这些工作,将当前和期望值以及允许的最大变化值传递给它。分别对X和Z组件执行此操作。

  •     float maxSpeedChange = maxAcceleration * Time.deltaTime;    //if (velocity.x < desiredVelocity.x) {    //  velocity.x =    //    Mathf.Min(velocity.x + maxSpeedChange, desiredVelocity.x);    //}    //else if (velocity.x > desiredVelocity.x) {    //  velocity.x =    //    Mathf.Max(velocity.x - maxSpeedChange, desiredVelocity.x);    //}    velocity.x =      Mathf.MoveTowards(velocity.x, desiredVelocity.x, maxSpeedChange);    velocity.z =      Mathf.MoveTowards(velocity.z, desiredVelocity.z, maxSpeedChange);



    最大速度和加速度均设置为10。


    现在,我们可以调整最大加速度,以在平滑运动和响应性之间达成所需的权衡。






    约束位置


    除了控制角色的速度之外,游戏的很大一部分还限制了角色的前进方向。我们的简单场景包含一个代表地面的平面。让我们做这个,使球体必须保留在平面上。




    留在方格内


    与其使用平面本身,不如简单地使允许区域成为球体的可序列化字段。我们可以Rect为此使用结构值。通过调用其构造函数方法(其前两个参数为-5和后两个参数为10),为其提供与默认平面匹配的默认值。这些定义了其左下角和大小。

  •   [SerializeField]  Rect allowedArea = new Rect(-5f, -5f, 10f, 10f);


    在将新位置分配给之前,我们通过约束新位置来约束球体。因此,首先将其存储在transform.localPosition变量中。

  •     //transform.localPosition += displacement;    Vector3 newPosition = transform.localPosition + displacement;    transform.localPosition = newPosition;


    我们可以在允许区域上调用Contains以检查点是否位于其内部或其边缘。如果新位置不是这种情况,那么我们将其设置为当前位置,并在此更新期间取消运动。

  •     Vector3 newPosition = transform.localPosition + displacement;    if (!allowedArea.Contains(newPosition)) {      newPosition = transform.localPosition;    }    transform.localPosition = newPosition;


    当我们将Vector3传递给Contains它时,将检查XY坐标,这在我们的情况下是不正确的。因此,将其Vector2与XZ坐标一起传递给新对象。

  •     if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {      newPosition = transform.localPosition;    }



    我们的球体再也无法逃脱,它试图停止时就停下来。结果很生涩,因为在某些帧中运动被忽略了,但是我们很快会处理。在此之前,球体可以一直移动直到它位于平面边缘的顶部。那是因为我们限制了它的位置并且没有考虑它的半径。如果整个球体保持在允许区域内,则看起来会更好。我们可以更改代码以考虑半径,但是另一种方法是简单地缩小允许区域。这对于我们简单的场景就足够了。


    在两个维度上,将区域的角向上移动0.5,并将其大小减小1。






    确切的位置


    我们可以通过将新位置钳位到允许的区域而不是忽略它来摆脱剧烈的运动。我们可以通过调用Mathf.Clamp一个值及其允许的最小值和最大值来做到这一点。为X使用区域的xMinxMaxX属性,为Z使用yMinyMax属性。

  •     if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {      //newPosition = transform.localPosition;      newPosition.x =        Mathf.Clamp(newPosition.x, allowedArea.xMin, allowedArea.xMax);      newPosition.z =        Mathf.Clamp(newPosition.z, allowedArea.yMin, allowedArea.yMax);    }



    坚持边缘。





    消除速度


    现在,球体似乎粘在边缘上。到达边缘后,我们沿着边缘滑动,但是要过一段时间才能离开边缘。发生这种情况是因为球体的速度仍然指向边缘。我们必须通过远离边缘的加速度来改变方向,这需要一段时间,具体取决于最大加速度。


    如果我们的球体是一个球,而该区域的边缘是一堵墙,那么如果它碰到墙,则应该停止。确实发生了。但是,如果墙壁突然消失,球将无法恢复之前的速度。动量消失了,它的能量在碰撞过程中转移了,这可能已经造成了破坏。因此,当碰到边缘时,我们必须摆脱速度。但是仍然可以沿着边缘滑动,因此仅应消除指向该边缘方向的速度分量。


    为了将适当的速度分量设置为零,我们必须检查两个维度的两个方向是否超出范围。此时,我们最好自己定位位置,因为我们正在执行与Mathf.Clamp和Contains相同的检查。

  •     //if (!allowedArea.Contains(new Vector2(newPosition.x, newPosition.z))) {      //newPosition.x =      //  Mathf.Clamp(newPosition.x, allowedArea.xMin, allowedArea.xMax);      //newPosition.z =      //  Mathf.Clamp(newPosition.z, allowedArea.yMin, allowedArea.yMax);    //}    if (newPosition.x < allowedArea.xMin) {      newPosition.x = allowedArea.xMin;      velocity.x = 0f;    }    else if (newPosition.x > allowedArea.xMax) {      newPosition.x = allowedArea.xMax;      velocity.x = 0f;    }    if (newPosition.z < allowedArea.yMin) {      newPosition.z = allowedArea.yMin;      velocity.z = 0f;    }    else if (newPosition.z > allowedArea.yMax) {      newPosition.z = allowedArea.yMax;      velocity.z = 0f;    }



    不再粘在边缘。




    弹性

    在碰撞过程中并非总是消除速度。如果我们的球体是一个完美的弹跳球,它将在相关尺寸上反转方向。让我们尝试一下。

  •     if (newPosition.x < allowedArea.xMin) {      newPosition.x = allowedArea.xMin;      velocity.x =-velocity.x;    }    else if (newPosition.x > allowedArea.xMax) {      newPosition.x = allowedArea.xMax;      velocity.x =-velocity.x;    }    if (newPosition.z < allowedArea.yMin) {      newPosition.z = allowedArea.yMin;      velocity.z =-velocity.z;    }    else if (newPosition.z > allowedArea.yMax) {      newPosition.z = allowedArea.yMax;      velocity.z =-velocity.z;    }



    弹起边缘。


    现在,球体保持其动量,当它碰到墙时,它只是改变方向。它确实会放慢一点,因为弹跳后其速度将不再与所需速度匹配。为了获得最佳反弹,玩家必须立即调整其输入。




    反弹力

    反转时不需要保留整个速度。有些事情比其他事情反弹更多。因此,让我们通过添加一个bounciness字段来使其可配置,默认情况下将其设置为0.5,范围为0–1。这使我们能够使球体完全弹力或完全不弹跳,或介于两者之间。

  •   [SerializeField, Range(0f, 1f)]  float bounciness = 0.5f;


    碰到边缘时,将跳动因素分解为新的速度值。

  •     if (newPosition.x < allowedArea.xMin) {      newPosition.x = allowedArea.xMin;      velocity.x = -velocity.x *bounciness;    }    else if (newPosition.x > allowedArea.xMax) {      newPosition.x = allowedArea.xMax;      velocity.x = -velocity.x *bounciness;    }    if (newPosition.z < allowedArea.yMin) {      newPosition.z = allowedArea.yMin;      velocity.z = -velocity.z *bounciness;    }    else if (newPosition.z > allowedArea.yMax) {      newPosition.z = allowedArea.yMax;      velocity.z = -velocity.z *bounciness;    }



    弹性设置为0.5。


    这并不代表现实的物理,这要复杂得多。但是它开始看起来像它,对于大多数游戏来说已经足够了。另外,我们的动作也不是很精确。我们的计算仅在帧中运动结束时恰好到达边缘时才是正确的。事实并非如此,这意味着我们应该立即将球体移离边缘一点点。首先计算剩余时间,然后将其与相关维中的新速度一起使用。但是,这可能会导致第二次弹跳,使事情变得更加复杂。幸运的是,我们不需要如此精确的精度就能呈现出令人信服的球体反弹的错觉。


    下一个教程是物理


    Repository

    https://bitbucket.org/catlikecodingunitytutorials/movement-01-sliding-a-sphere/

    往期精选

    Unity3D游戏开发中100+效果的实现和源码大全 - 收藏起来肯定用得着

    Shader学习应该如何切入?

    UE4 开发从入门到入土

    声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。

    原作者:Jasper Flick

    原文:

    https://catlikecoding.com/unity/tutorials/movement/sliding-a-sphere/

    翻译、编辑、整理:MarsZhou

    More:【微信公众号】 u3dnotes

  • ©著作权归作者所有,转载或内容合作请联系作者
    • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
      沈念sama阅读 204,530评论 6 478
    • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
      沈念sama阅读 86,403评论 2 381
    • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
      开封第一讲书人阅读 151,120评论 0 337
    • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
      开封第一讲书人阅读 54,770评论 1 277
    • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
      茶点故事阅读 63,758评论 5 367
    • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
      开封第一讲书人阅读 48,649评论 1 281
    • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
      沈念sama阅读 38,021评论 3 398
    • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
      开封第一讲书人阅读 36,675评论 0 258
    • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
      沈念sama阅读 40,931评论 1 299
    • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
      茶点故事阅读 35,659评论 2 321
    • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
      茶点故事阅读 37,751评论 1 330
    • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
      沈念sama阅读 33,410评论 4 321
    • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
      茶点故事阅读 39,004评论 3 307
    • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
      开封第一讲书人阅读 29,969评论 0 19
    • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
      开封第一讲书人阅读 31,203评论 1 260
    • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
      沈念sama阅读 45,042评论 2 350
    • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
      茶点故事阅读 42,493评论 2 343