Unity强化篇(六) —— 使用Unity和Photon进行多人游戏简介(一)

版本记录

版本号 时间
V1.0 2019.07.16 星期二

前言

Unity是由Unity Technologies开发的一个让玩家轻松创建诸如三维视频游戏、建筑可视化、实时三维动画等类型互动内容的多平台的综合型游戏开发工具,是一个全面整合的专业游戏引擎。Unity类似于Director,Blender game engine, Virtools 或 Torque Game Builder等利用交互的图型化开发环境为首要方式的软件。其编辑器运行在Windows 和Mac OS X下,可发布游戏至WindowsMacWiiiPhoneWebGL(需要HTML5)、Windows phone 8和Android平台。也可以利用Unity web player插件发布网页游戏,支持Mac和Windows的网页浏览。它的网页播放器也被Mac 所支持。网页游戏 坦克英雄和手机游戏王者荣耀都是基于它的开发。感兴趣的看下面几篇文章。
1. Unity强化篇(一) —— 如何使用Vuforia制作AR游戏(一)
2. Unity强化篇(二) —— 适用于Unity的HTC Vive教程(一)
3. Unity强化篇(三) —— 适用于Unity的HTC Vive教程(二)
4. Unity强化篇(四) —— Unity 和 Ethereum(一)
5. Unity强化篇(五) —— Unity 和 Ethereum(二)

Photon vs Unity — Which is better?

首先看下写作内容

了解如何使用UnityPhoton Unity Networking(PUN)库制作自己的多人游戏。

接着看下写作环境

C# 6, Unity 2018.3, Unity

如果你已经用Unity制作游戏已经有一段时间了,你知道创造游戏需要付出很多努力。诸如关卡设计,游戏机制和进展之类的事情需要经过大量的试验和错误以及精心设计才能做到正确。即便如此,只有少数人能够完成一场完整的比赛。

与此相比,FortnitePUBG等多人游戏风靡全球。这些易于学习但难以掌握的游戏随着破纪录的人数不断变得越来越受欢迎。有些人甚至将这些游戏作为职业生涯,这对于单人游戏非常困难。

在本教程中,您将学习如何使用UnityPhoton Unity Networking库(简称PUN)制作自己的多人游戏。

具体来说,你将学到:

  • Unity NetworkingPUN之间的主要区别。
  • 如何创建玩家可以加入的大厅(Lobby)场景。
  • 如何加载游戏场景并同步玩家的变换(Transform)值。

让我们开始吧。

在开始学习本教程之前,让我们先看看Unity NetworkingPhoton Unity Networking之间的主要区别。

1. Exploring the Architecture

UnityPUN都有类似的底层API。 但是这些库使用这些API所需的架构是它们之间的关键区别因素。

参考 https://www.youtube.com/watch?v=xLECRl1eyGk

上图描述了如何在UnityPUN中的网络中的节点之间传输消息。

Unity Networking支持服务器/客户端(server/client)体系结构。所有消息都必须通过主机客户端(Host client),不能直接在节点之间发送。例如,基于上图,消息使用以下路径从客户端B(client B)传输到客户端C(client C)Client B ▸ Relay Server ▸ Host A ▸ Relay Server ▸ Client C

如您所见,该消息从源到目的地总共需要4个跃点。除此之外,如果主机Host与网络断开连接,游戏将停止。

PUN具有类似的服务器/客户端(server/client)体系结构,但也支持消息的对等(peer-to-peer)发送。例如,基于上图,消息使用以下路径从Client B传输到Client CClient B ▸ Relay Server ▸ Client C

对于两个节点之间相同的消息传输,与Unity中的4个相比,总共有2个跃点。除此之外,PUN可能完全绕过Relay ServerClient B可以直接与Client C通信,从而将跳数减少到1。

因此,PUNUnity快。

2. Pricing

UnityPUN之间的另一个关键区别是定价模型(Pricing model)

Unity为每个许可提供免费数量的Concurrent Users (CCU)

  • Personal:20个并发用户
  • Plus:50个并发用户
  • Professional:200个并发用户

如果您需要增加游戏支持的CCU数量,则必须为您使用的额外带宽付费。您将通过Unity infrastructure(Matchmaker and Relay Server)收取0.49美元/ GB的流量。

来源 - https://support.unity3d.com/hc/en-us/articles/209604483-How-much-does-Unity-Multiplayer-cost-

PUN还提供每个房间最多20个CCU,8000个月活跃和500个免费消息。除了免费计划,它提供了一个非常好的95美元一次性付款选项60个月,其中包括100个CCU,每月40k活跃,每个房间500条消息。此选项非常适合预算有限的小型独立开发人员。

相对更快的性能,精心编写的教程和文档以及健康的定价计划选择使PUN成为开发人员构建多人游戏的一个非常好的选择。

定价 - https://www.photonengine.com/en-US/PUN/pricing


开始

您需要Unity版本2018.3或更高版本才能成功加载启动项目。 如果您没有在系统上安装它,可以从unity3d.com下载。

1. Project Overview

看一下Project窗口中的文件夹结构:

这是每个文件夹包含的内容:

  • LogViewerLogViewer资源所需的文件。
  • Materials:本教程所需的材料。
  • Models:本教程所需的模型。
  • PhotonPhoton Library所需的文件。
  • PhysicsMaterial:项目中使用的Physics材料。
  • Prefabs:本教程的预制件。
  • Resources:必须由Photon同步的预制件。
  • Scenes:游戏的主菜单和竞技场场景。
  • Scripts:项目所需的脚本。

Assets / RW / Scenes打开Launcher场景。

如果您之前玩过多人游戏,您会知道在您和您的朋友一起开始玩游戏之前,您首先需要创建或加入大厅(或游戏室),然后您将连接所有游戏,然后大厅领导者启动游戏。

在这个场景中,您将使用Photon Unity Networking制作一个大厅。您将创建一个具有特定名称的房间,然后您的朋友可以通过在他们的游戏实例中输入相同的房间“name”来加入您的大厅。

一旦您和您的朋友加入同一个房间,大厅领导者就可以加载MainArena场景,您可以在这里一起玩游戏。


Creating a Photon Account

在开始构建大厅之前,您需要通过访问 https://dashboard.photonengine.com/en-us/account/SignUpPhoton Engine的官方网站上创建一个帐户。

  • 1) 注册成功后,您将被重定向到您的帐户信息中心。
  • 2) 在同一页面上,单击Create a new app按钮。 输入应用程序的名称,例如“SampleApp”,然后单击表单底部的Create按钮。
  • 3) 最后,在Dashboard页面上,您将看到一个包含“SampleApp”详细信息的框。 复制AppId并将其存储在某个地方,稍后您将在教程中使用它进行测试。

注意:Photon Unity Networking库已经存在于您在本教程开头下载的入门项目中,但您也可以通过从Asset store中下载unitypackage在现有项目中使用它。

返回Unity编辑器,通过选择Window ▸ Photon Unity Networking ▸ PUN Wizard打开PUN Wizard

PUN Wizard窗口中,单击Setup Project,然后输入在上一节中设置光子引擎帐户时保存的AppId。单击Setup Project按钮。

现在您已经设置了Photon,让我们开始构建大厅。

1. Creating the Lobby

以下是Launcher.cs脚本将按顺序进行的总结:

  • 1) 连接到Photon Network
  • 2) 连接后,从用户处获取两个输入:他们想要使用的Player Name,以及他们想要创建或加入的Room Name
  • 3) 如果输入名称的房间不存在,请创建一个具有该名称的房间,并将当前玩家器设为Lobby Leader。如果房间存在,玩家将加入房间。
  • 4) 一旦两个玩家都连接到同一个房间,Lobby Leader就可以加载MainArena场景。

Assets / RW / Scripts中打开Launcher.cs脚本。

在注释// Start Method之后,在Launcher.cs中添加以下代码行。

添加代码时不要担心中间错误。所有必要的代码将在下面的章节中解释。

// Start Method
void Start() 
{
    //1
    PlayerPrefs.DeleteAll(); 

    Debug.Log("Connecting to Photon Network");

    //2
    roomJoinUI.SetActive(false);
    buttonLoadArena.SetActive(false);

    //3
    ConnectToPhoton();
}

void Awake()
{
    //4 
    PhotonNetwork.AutomaticallySyncScene = true;
}

这是代码的简要说明。

  • 1) 连接到服务器时,PUN ping所有可用服务器,并将具有最低ping的服务器的IP地址存储为PlayerPrefs键值对。 这可能会在连接阶段导致意外行为。 为避免任何异常,Launcher场景启动时会调用DeleteAll
  • 2) UI元素默认隐藏,并在建立与Photon服务器的连接后激活。
  • 3) 调用ConnectToPhoton连接到Photon网络。
  • 4) AutomaticallySyncScene的值设置为true。 这用于在一个房间中的所有连接的玩家之间同步场景。

2. Loading the MainArena Scene

要从TextField UI元素获取Input,您需要一个公共方法来将值存储在TextField中。 在// Helper Methods方法之后添加以下代码:

// Helper Methods
public void SetPlayerName(string name)
{
    playerName = name;
}

public void SetRoomName(string name)
{
    roomName = name;
}

在注释// Tutorial Methods之后添加如下代码:

// Tutorial Methods
void ConnectToPhoton()
{
    connectionStatus.text = "Connecting...";
    PhotonNetwork.GameVersion = gameVersion; //1
    PhotonNetwork.ConnectUsingSettings(); //2
}

这段代码的工作原理:

  • 1) GameVersion参数已设置。 这是构建的版本字符串,可用于分隔不兼容的客户端。 对于本教程,它将设置为1(在声明gameVersion字段时设置)。
  • 2) 调用ConnectUsingSettings,用于连接到编辑器中配置的Photon。 您可以在文档the docs中阅读更多内容。

接下来,添加以下代码行:

public void JoinRoom()
{
    if (PhotonNetwork.IsConnected)
    {
        PhotonNetwork.LocalPlayer.NickName = playerName; //1
        Debug.Log("PhotonNetwork.IsConnected! | Trying to Create/Join Room " + 
            roomNameField.text);
        RoomOptions roomOptions = new RoomOptions(); //2
        TypedLobby typedLobby = new TypedLobby(roomName, LobbyType.Default); //3
        PhotonNetwork.JoinOrCreateRoom(roomName, roomOptions, typedLobby); //4
    }
}

public void LoadArena()
{
    // 5
    if (PhotonNetwork.CurrentRoom.PlayerCount > 1)
    {
        PhotonNetwork.LoadLevel("MainArena");
    }
    else
    {
        playerStatus.text = "Minimum 2 Players required to Load Arena!";
    }
}

查看每行代码注释评论:

  • 1) LocalPlayerNickName参数是从私有变量playerName设置的。这是您在Photon网络上玩的所有人都可以使用的名称,并用作唯一标识符。
  • 2) 声明了RoomOptions类的对象。这包含了创建房间时所需的常用房间属性。它可以用来让用户控制房间的各种特征,例如可以加入的最大玩家数量,PlayerTtl(玩家生存时间)等等。(Docs
  • 3) 声明了类TypedLobby的对象。这是指Photon服务器上的特定大厅类型。名称和大厅类型用作唯一标识符。 Room名称由私有变量roomName设置,Lobby类型设置为Default。 (Docs
  • 4) 最后,使用前面设置的参数 - roomName,roomOptions和typedLobby调用PhotonNetwork类的JoinOrCreateRoom方法。如果具有新房间名称但尚不存在的新用户调用该方法,则会创建一个房间并将该用户设置为Lobby Leader。否则,其他玩家就加入了房间。
  • 5) 一旦Lobby Leader创建并加入了一个房间,LoadArena按钮将被设置为活动状态。在加载Arena之前设置一个检查,以确保仅在两个玩家都加入房间时才加载MainArena场景。

3. PUN Callback Methods

现在您已经添加了JoiningCreating a Room的基本构建块,剩下要做的就是添加PUN Callback方法来处理异常处理。

在注释// Photon Methods之后添加以下代码:

// Photon Methods
public override void OnConnected()
{
    // 1
    base.OnConnected();
    // 2
    connectionStatus.text = "Connected to Photon!";
    connectionStatus.color = Color.green;
    roomJoinUI.SetActive(true);
    buttonLoadArena.SetActive(false);
}

public override void OnDisconnected(DisconnectCause cause)
{
    // 3
    isConnecting = false;
    controlPanel.SetActive(true);
    Debug.LogError("Disconnected. Please check your Internet connection.");
}

public override void OnJoinedRoom()
{
    // 4
    if (PhotonNetwork.IsMasterClient)
    {
        buttonLoadArena.SetActive(true);
        buttonJoinRoom.SetActive(false);
        playerStatus.text = "You are Lobby Leader";
    }
    else
    {
        playerStatus.text = "Connected to Lobby";
    }
}

让我们看看每段代码的作用:

  • 1) 顾名思义,当用户连接到Photon Network时,会调用OnConnected。这里,该方法调用基本方法onConnected()。需要执行的任何其他代码都是在此方法调用之后写入的。
  • 2) 这些方法向用户提供反馈。当用户成功连接到Photon Network时,将设置UI Text connectionStatus,并将roomJoinUI GameObject设置为visible
  • 3) 如果用户从Photon Network断开连接,则会调用OnDisconnected。在这种情况下,controlPanel GameObject设置为false,并将错误类型消息记录到Unity
  • 4) 最后,当用户加入房间时调用OnJoinedRoom。在这里,它检查用户是否是Master Client(第一个加入房间的用户)。如果是这样,则将用户设置为lobby leader并且显示消息以指示这一点。lobby leader有能力加载MainArena场景,这是在大多数流行的多人游戏中创建房间的常用方式。否则,如果用户不是第一个加入房间,则会显示一条消息,告诉该用户他们已成功加入房间。

保存Launcher.cs脚本并返回Launcher场景,然后单击Play

如您所见,当场景开始时,ConnectToPhoton被调用,连接状态UI文本显示Connecting…。 成功连接后,文本将更改为“Connected”,并且roomJoinUI GameObject的可见性将设置为true

接下来,用户可以通过单击Join Room按钮输入他们的Name以及他们要创建或加入的房间的名称。

最后,如果用户是Master Client,则playerStatus Text设置为“You are now the Lobby Leader!”并且Load Arena按钮被设置为活动状态。 否则,显示大厅加入成功的指示。

此时,您可以通过选择File ▸ Build and Run来构建操作系统项目的可执行文件来测试加入房间。 您应该能够使用新构建的可执行文件和连接到同一房间的Unity Editor加载MainArena场景。

但是,你只能看到没有玩家的空竞技场。在下一节中,您将学习如何为每个客户端添加玩家和球预制件到场景中。您还可以在Photon Network中同步其位置,旋转等。

4. Using the Photon Transform View Component

随着房间加入代码的完成,您需要知道的下一个重要的Photon Unity Networking概念是Photon View Component

使用Photon View Component进行多人游戏时,PUN可以很容易地制作一个预制件,其属性(位置,旋转等)必须在网络中同步。

理解使用PUN的一个重要概念是,应该通过网络实例化的预制件必须位于名为Resources的文件夹中。

Resources文件夹中使用Prefabs的一个重要副作用是您需要查看其名称。您的Assets资源路径下不应该有两个预制件名称相同,因为Unity只会选择它找到的第一个预制件。

有了这个,让我们开始构建游戏管理器。

Assets / RW / Scripts中打开GameManager.cs脚本。

以下是GameManager.cs脚本的概述:

  • 1) 加载MainArena场景时,请检查客户端是否为Master。如果是,则使用PhotonNetwork.Instantiate实例化Car GameObject,更改player1 GameObject的名称,并实例化ball GameObject。否则只需实例化player2 GameObject并更改其名称。
  • 2) 添加PUN回调方法,该方法将根据网络条件和事件处理各种用例。
  • 3) 辅助方法将禁用UI,退出房间等。

在注释// Start Method之后添加以下代码:

// Start Method
void Start()
{
    if (!PhotonNetwork.IsConnected) // 1
    {
        SceneManager.LoadScene("Launcher");
        return;
    }

    if (PlayerManager.LocalPlayerInstance == null) 
    {
        if (PhotonNetwork.IsMasterClient) // 2
        {
            Debug.Log("Instantiating Player 1");
            // 3
            player1 = PhotonNetwork.Instantiate("Car", 
                player1SpawnPosition.transform.position, 
                player1SpawnPosition.transform.rotation, 0);
            // 4
            ball = PhotonNetwork.Instantiate("Ball", 
                ballSpawnTransform.transform.position, 
                ballSpawnTransform.transform.rotation, 0);
            ball.name = "Ball";
        }
        else // 5
        {
            player2 = PhotonNetwork.Instantiate("Car", 
                player2SpawnPosition.transform.position, 
                player2SpawnPosition.transform.rotation, 0);
        }
    }
}

下面是工作原理介绍:

  • 1) 检查客户端是否连接到Photon Network。如果网络存在一些问题,则应加载Launcher场景,以便客户端可以尝试再次连接。
  • 2) 获取对本地玩家(控制客户端的玩家)的引用,并检查它是否是主客户端(Master client)
  • 3) 如果是,则使用PhotonNetwork.InstantiateResources文件夹(您将在下一步中执行)实例化Player GameObject,并在player1 GameObject中保存对它的引用。
  • 4) 类似地,实例化Ball GameObject,使其与连接到当前房间的所有客户端上加载的Ball GameObject相同。
  • 5) 如果客户端不是Master,则从Resources文件夹加载Player GameObject,并在player2 GameObject中保存对它的引用。

保存文件并返回编辑器。

现在您已准备好实例化PlayerBall GameObjects的逻辑,下一步是添加所需的组件,以便可以使用PhotonNetwork.Instantiate方法对它们进行实例化。

Project窗口中,双击Assets / RW / Prefabs中的Car prefab,在Prefab Editing模式下将其打开。

Inspector中,您应该能够看到Car GameObject(Rigidbody,Collider,Movements等)的一些基本组件已经是预制件的一部分。

由于只需要在网络上同步GameObjectTransform属性,因此将Photon Transform View组件添加到Car GameObject

你会注意到还添加了一个Photon View组件,因为Photon Transform View组件从它继承了很多属性。

除了同步GameObject的位置,旋转和缩放之外,Photon Transform View还为您提供了许多不同的选项,使得即使每秒仅接收数据几次,同步值也会显得平滑。

Inspector中,在Photon View Component中将Observe option设置为Unreliable on change。这将确保Car Transform值之间的平滑过渡。

此外,将Car预制件添加到Photon View Component中的Observed Components列表中,以便同步其选定的Transform属性(在Photon View Component组件中视为已选择)。

保存Car预制件。

接下来,在Prefab Editing模式下从Assets / RW / Prefabs打开Ball预制件并重复上述步骤:

  • 1) 添加Photon Transform View component
  • 2) 将Observe Option设置为更改时不可靠Unreliable
  • 3) 在Photon View componentObserved Components list列表中添加Ball prefab

最后,将Car and Ball预制件从Assets / RW / Prefabs移动到Assets / RW / Resources,以便它们可以通过PhotonNetwork.Instantiate方法加载。

是时候测试了!

选择File ▸ Build and Run以构建操作系统的可执行二进制文件。

这次:

  • 在两个客户端中输入不同的玩家名称。
  • 输入相同的房间名称。
  • 单击主客户端Master client中的“Load Arena”按钮。

你应该看到MainArena场景加载了2个玩家(汽车)和一个球。 您可以使用键盘上的WASD或箭头键在竞技场中移动汽车。

请注意,只有1辆车在两个客户端(属于客户的汽车)中移动。

您还可以看到玩家和球的位置在两个客户端都是同步的。

所有工作都由您添加到Car and Ball预制件中的Photon Transform View Component完成。

但是,如果关闭其中一个客户端,则另一个客户端将保持不稳定状态。 要处理这样的情况,您将添加回调方法,以根据客户端的当前情况(例如网络丢失或其他玩家离开)执行必要的操作。

5. Adding PUN Callback Methods

在注释// Update Method方法之后,在GameManager.cs中添加以下代码:

// Update Method
void Update()
{
    if (Input.GetKeyDown(KeyCode.Escape)) //1
    {
        Application.Quit();
    }
}

这非常简单。

在游戏中的任何一点,如果按下Escape按钮,请调用Application.Quit

接下来,在注释// Photon Methods之后添加以下代码:

// Photon Methods
public override void OnPlayerLeftRoom(Player other)
{
    Debug.Log("OnPlayerLeftRoom() " + other.NickName); // seen when other disconnects
    if (PhotonNetwork.IsMasterClient)
    {
        PhotonNetwork.LoadLevel("Launcher");
    }
}

OnPlayerLeftRoom是一种PUN回调方法,只要玩家离开房间,就可以通过关闭客户端或断开与网络的连接来调用它。

最后,在注释// Helper Methods之后添加以下代码:

// Helper Methods
public void QuitRoom()
{
    Application.Quit();
}

单击Canvas / Top Menu Panel / Quit Room按钮时,将调用此方法。

最后将Quit Room ButtonOn Click()事件设置为GameManager.QuitRoom

就是这样!

您可以为您的操作系统构建最终二进制文件并开始玩!

本教程的目的是向您介绍构建多人游戏的基本概念。 您可以使用这些相同的原则将现有的单人游戏变成多人游戏!

您可以在官方网站 the official website上阅读有关Photon Unity Networking库的更多信息。

后记

本篇主要讲述了使用Unity和Photon进行多人游戏简介,感兴趣的给个赞或者关注~~~

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

推荐阅读更多精彩内容