HTML5的实时多人游戏,Node.js Socket.io Canvas/HTML5
发布于 12 年前 作者 gaokai369 23590 次浏览 最后一次编辑是 8 年前

基于浏览器的多人游戏

当你考虑要做多人游戏,有很多方法,创造一个游戏玩家都可以在线玩。 例如,一个纸牌游戏,你玩的朋友同步。匝数,(半)实时和游戏离散的步骤进行信息交换。另一个例子是,国际象棋,可以是异步的。

纸牌游戏和国际象棋两种,通常需要与服务器的通信和与其他玩家沟通,以联机工作。这可能是多人经验的基础 - 很长一段时间,这已经通过HTTP POST和GET一直用于管理​​游戏存在。

这些方法的问题是延迟,发布一条消息,并等待每一个响应时间太慢。它的工作原理的半实时和异步游戏,但需要消息发送和接收的地区在33〜66倍,每秒的东西不是很可能单独与HTTP有时实时游戏。

幸运的是,在现代浏览器中,我们可以采取一个步骤更高,有服务器和客户之间的实时连接。本次讨论的目的是提出一个如何多人游戏概述。我们将着眼于输入预测,滞后补偿,客户插值,更重要的是 - 如何在您的正常浏览器使用的WebSockets。本文将提出一起玩的参数可播放的演示,展示讨论​​的概念。

我们选择的技术和为什么

Socket.io

socket.io是一个强大而灵活的服务器端和客户端组件,使您的浏览器实时联网。它不仅支持网络插座等新技术,但也回落到一个Flash网络层的安全,长轮询的XHR或JSON,甚至一个HTML文件的传输层。关于它最吸引人的也许是它带来了,这是非常有用的服务器和客户端代码写入时的简单性和固有的异步性。

另一种使用socket.io的好处是它无缝Node.js的关系的事实。加上快递时,连接上,它可以为客户端包括游戏文件和数据,整洁,易于集成。一旦你设置它,与客户的第一个连接和通信量之间的代码是令人惊叹。和它的工作在所有浏览器,移动包括在内。

Node.js的

Node.js的是一个易于使用,灵活,跨平台的工具。它更像是一个事件触发的IO。它有一个专用的用户,开发的模块有很多。它支持良好的服务器托管Web应用程序平台的数量,并且很容易安装一个属于自己的专用服务器上,所以寻找一个主机不应该是一个问题。

其中许多为Node.js的这些伟大的模块,是一个web框架,称为快。它涵盖了服务档案,复杂的路由,客户端身份验证和会话,更。它完美地融入栈之间socket.io和我们的客户,其中socket.io通过Express和Express可以为它的文件到客户端可以处理我们的游戏内容。

Canvas/HTML5

本文使用二维的画布上,以证明我们的方法去覆盖,这使得我们可以很容易得出一些文本和框。

在您的浏览器实时多人连接入门

涵盖了所有可能的选择,并为上述技术安装和配置,是小了本文的范围,但上述工具的每个人都有自己的文档,应该用不了多长时间,已成立和工作在一起。为简洁起见,我们将深入到,而不是一个简单的游戏范例。下面的信息是建立一个空的画布,连接的socket.io服务器和接收邮件所需的最低。

开始用一个简单的Web服务器,进入快速

一个简单的Express服务器的代码是真正的短期和简单。它为指定的端口上的文件(在这种情况下,4004),它只会从根文件夹(如文件的index.html),我们从一个特定的’游戏’路径指定(如游戏/)。

/*  Copyright (c) 2012 Sven "FuzzYspo0N" Bergström 
    
    http://underscorediscovery.com
    
    MIT Licensed. See LICENSE for full license.

    Usage : node simplest.app.js
*/

   var 
        gameport        = process.env.PORT || 4004,

        io              = require('socket.io'),
        express         = require('express'),
        UUID            = require('node-uuid'),

        verbose         = false,
        app             = express.createServer();

/* Express server set up. */

//The express server handles passing our content to the browser,
//As well as routing users where they need to go. This example is bare bones
//and will serve any file the user requests from the root of your web server (where you launch the script from)
//so keep this in mind - this is not a production script but a development teaching tool.

        //Tell the server to listen for incoming connections
    app.listen( gameport );

        //Log something so we know that it succeeded.
    console.log('\t :: Express :: Listening on port ' + gameport );

        //By default, we forward the / path to index.html automatically.
    app.get( '/', function( req, res ){ 
        res.sendfile( __dirname + '/simplest.html' );
    });


        //This handler will listen for requests on /*, any file from the root of our server.
        //See expressjs documentation for more info on routing.

    app.get( '/*' , function( req, res, next ) {

            //This is the current file they have requested
        var file = req.params[0]; 

            //For debugging, we can track what files are requested.
        if(verbose) console.log('\t :: Express :: file requested : ' + file);

            //Send the requesting client the file.
        res.sendfile( __dirname + '/' + file );

    }); //app.get *

Socket.io,加入实时组件

现在我们添加socket.io Node.js的服务器部分的代码。当然,在快速代码相同的文件,只是它下面的要点所示。服务器将其自身附加表示请求时,它可能为客户端文件。我们没有专门处理在这个例子中的任何会议,但你可以了解使用本网站者:http://www.danielbaulig.de/socket-ioexpress/的。

/* Socket.IO server set up. */

//Express and socket.io can work together to serve the socket.io client files for you.
//This way, when the client requests '/socket.io/' files, socket.io determines what the client needs.
        
        //Create a socket.io instance using our express server
    var sio = io.listen(app);

        //Configure the socket.io connection settings. 
        //See http://socket.io/
    sio.configure(function (){

        sio.set('log level', 0);

        sio.set('authorization', function (handshakeData, callback) {
          callback(null, true); // error first callback style 
        });

    });

        //Socket.io will call this function when a client connects, 
        //So we can send that client a unique ID we use so we can 
        //maintain the list of players.
    sio.sockets.on('connection', function (client) {
        
            //Generate a new UUID, looks something like 
            //5b2ca132-64bd-4513-99da-90e838ca47d1
            //and store this on their socket/connection
        client.userid = UUID();

            //tell the player they connected, giving them their id
        client.emit('onconnected', { id: client.userid } );

            //Useful to know when someone connects
        console.log('\t socket.io:: player ' + client.userid + ' connected');
        
            //When this client disconnects
        client.on('disconnect', function () {

                //Useful to know when someone disconnects
            console.log('\t socket.io:: client disconnected ' + client.userid );

        }); //client.on disconnect
     
    }); //sio.sockets.on connection

index.html的,客户端连接到服务器

客户端需要很少的代码,能够连接到服务器。我们走得更远,它成为更intwined,但这是所有需要连接到服务器并发送或接收数据。

<!DOCTYPE html>
<html>
  <head>
    <title> Real time multi-player games with HTML5</title>
    <style type="text/css">
      html , body {
        background: #212121; 
        color: #fff; 
        margin: 0; 
        padding: 0;
      }
      #canvas {
        position: absolute;
        left: 0; right: 0; top: 0; bottom: 0; 
        margin: auto;
      }
    </style>
    
      <!-- Notice the URL, this is handled by socket.io on the server automatically, via express -->
    <script type="text/javascript" src="/socket.io/socket.io.js"></script>
    
      <!-- This will create a connection to socket.io, and print the user serverid that we sent from the server side. --> 
    <script type="text/javascript">

        //This is all that needs
      var socket = io.connect('/');

        //Now we can listen for that event
      socket.on('onconnected', function( data ) {

          //Note that the data is the object we sent from the server, as is. So we can assume its id exists. 
          console.log( 'Connected successfully to the socket.io server. My server side ID is ' + data.id );

        });

    </script>

  </head>

  <body>
    <canvas id="canvas"> </canvas>  
  </body>
</html>

获取到的游戏

我们现在需要的是一个简单的这种互动的例子,配合,让我们的脚湿。我们将有两个块左右运行在相同的空间。有大量的代码和逻辑,建立在互联网上顺利运行的方式获得任何游戏相关的代码是不作为这篇文章的有用的东西。相反,我们专注于多人,并用一个简单的例子来说明。

实时游戏开发的一些注意事项

并非所有开发商做游戏。有一些事情,是新开发输入应包括简要的游戏世界。

帧速率的独立性

当一个块在屏幕上移动,它可以是一个简单的代码行。block.position.x + = 1;这1这里,什么单位,衡量吗?其实,这是一个像素-但它每帧移动1像素。每次循环运行时,我们移动一个像素。那就是-在每秒30帧,移动30个像素。在每秒60帧,移动60个像素。这实在是坏的游戏,硬件和性能可以有所不同,因为从一台设备或计算机。甚至在不同浏览器之间,这个问题是一个巨大的差异。一个球员达到墙,其他几乎所有移动。

为了确保该块移动相同的距离,在相同的时间内,时间差的概念。这个值是每帧毫秒(mspf)的,这是更新你的游戏的同时测量。这是一个更新需要多长时间。开始在你的循环,在循环结束时的时间,你可以制定出更新已多久。

在每秒30帧(1/30)三角洲是0.033s左右。一帧,每33.3毫秒。三角洲在每秒60帧(1/60)约0.016或16.66毫秒每帧。因此可以说,球被移动1像素每帧。为了解决的帧频依赖的问题,我们乘由mspf值中的任何位置的变化,平衡时间,距离始终是相同的。

我们的例子计算成为ball.position.x的+ =(1 * deltatime); (帧速率较慢)与大三角洲球移动的像素越多-在同一时间到达目的地,在一个较小的增量(更高的帧速率)。这为我们提供了具体的单位,将采取行动在任何渲染速度相同。这是关键:他们都应该被缩小到了mspf动画,动作和几乎所有的随时间变化的价值。

规划变更

游戏往往是一个动态的东西:他们需要调整和许多价值观的改变,感觉很好。迭代是一个获得这项权利的重要组成部分。这是大多数程序员的常识,但总是尽量和设计,让你有许多价值观和尽可能变量来调整你的代码。然后他们暴露在一个非常容易使用的地方,这样你就可以不断完善游戏的感觉如何,它是如何工作没有太多的挖掘和努力。尝试尽可能带给你的迭代时间。

在本文附带的演示中,我们已经暴露通过我们的价值观Dat.GUI让你可以改变和交互实时演示,你让他们感到变化的影响。

在实时多人游戏

游戏是一件很难的事情做。入门游戏感觉良好,物理光滑,碰撞是正确的,并控制感到紧张 - 所有这些事情需要努力已经。加入了多人组件,使这复杂得多,因为现在涉及服务器。玩家需要被告知其他玩家的行动,但有一个网络延迟。

网络上的一个高层次的,一个简单的大堂

的方式,我们会接触网络游戏的例子是相当简单的。我们在此演示的游戏只能有两个球员,简单。在我们的演示,客户端连接到服务器,然后服务器要么让他们加入现有的游戏,或加入别人创建的游戏。随后的比赛被添加到游戏服务器上的更新列表,客户对他们的最终更新他们的游戏。如下所示,它像一个非常简单的大堂系统。

网络和游戏循环

当它涉及到一个实时游戏,我们要运行服务器和客户端的游戏逻辑本身。这是由于服务器需要在所有次比赛的状态上的权威,但客户需要到本地运行游戏太的事实。输入从网络服务器上的每个帧,将处理和应用的球员,这种变化被发送到其他球员在一个固定利率。在客户端上,输入将收集和发送到服务器,位置可以等待消息来自服务器(客户端预测),而更新。

我们将实施工程的方法如下:

客户按下正确的关键,客户端移动立即权利 服务器接收输入信息,并存储它的下一个更新 未来的服务器更新,玩家输入的应用,移动服务器上的状态,他是对的 在国家所有的改变都发送到所有客户端 客户端收到的消息,立即设置客户端的位置(权威) 客户可以顺​​利从第一步纠正错误的预测 游戏服务器架设

在服务器上,我们有两个更新运行。一个更新运行在高频率,更新和游戏物理状态。我们称之为物理更新循环运行,这是每一个为15ms(每秒约66更新)。第二次更新,我们可以调用服务器上的更新循环,这是在运行速度较慢,每45MS(每秒约22更新)。我们在服务器更新循环发送到所有客户端服务器的状态。最重要的是什么,我们将实现由Valve软件公司的Source引擎的网络中提出的理论基础。

服务器更新循环看起来像这样::

服务器的物理环(15毫秒)

不要让长期物理吓唬你,在我们的例子中,它是非常简单的直线运动。我们需要从客户端的输入,我们根据他们推移动它们。如果他们按离开时,您将留给他们。当我们添加客户端的预测,我们还需要告诉客户的投入,我们最后处理。那么,如何更新我们的服务器物理?

我们从网络中存储的过程输入 工作的方向,他们打算搬到基于存储输入 申请这​​个方向的变化,球员的位置 存储最后的处理输入数 清除任何已储存的投入 服务器更新循环(45MS)

更新循环发送到所有客户端服务器的状态。这个变化每游戏,当然,在我们的例子中包括球员的位置,我们已经处理的球员(最后处理输入数字)输入,本地服务器时间的状态。

你在状态更新发送是你的,而且往往超过一台服务器更新循环可以降低使用的交通量。一个简单的例子,将是一个日/夜循环。如果在低得多的速度比其他一切的变化周期,你可以发送太阳每5秒的状态,而不是每45毫秒。

客户端设置和更新循环

在客户端上,我们还运行多个循环,物理/游戏服务器一样,15ms的再次。二是定期更新的循环,但是,而不是一个运行在60fps的(最好),或尽可能快的客户端可以运行游戏。在HTML5中,我们通常依靠RequestAnimationFrame处理此更新,调用时更新的浏览器窗口。下文详述此更新循环,是相当标准:

清除画布 绘制信息/状态 处理输入(输入消息发送到服务器) 更新位置(使用客户端预测) 移动服务器上的位置为基础的其他客户(插值) 绘制在画布上的玩家 客户端的物理环

有关客户端的物理循环的重要的事情,做与保持同步的客户端与服务器已决定我们的立场是什么位置。这意味着物理匹配时决定移动多远,这就是为什么在一个固定的速度更新物理服务器。同时在服务器和客户端的物理应该得出相同的结论,给出了相同的输入。如果你按下右两次,结果应该是几乎相同的服务器将计算您的位置是什么。这是什么使客户端预测可能企图掩盖在网络和客户的延迟时。

重要的网络概念

客户端预测

我们提到过现在,让我们看看究竟它需要。网络在幼稚的做法,你可以尝试下面的模型:

客户按右键,告诉服务器 邮件到达服务器在将来某个时候(200ms的延迟) 服务器发送回客户端的新位置 邮件到达客户端(200ms的延迟) 客户端更新其位置400ms的+。 这种做法可能会远远超过LAN连接延迟是非常低的工作,但延迟玩家通过因特网服务器连接时,可以在任何地方从30毫秒到800毫秒 - 渲染延迟,因为不能玩的游戏。当你按下一个键的反应是如此严重的延迟,它不会是一场很好的比赛发挥。但是,我们如何解决这个问题?

客户端预测的解决方案,而仅仅意味着立即输入,预测的服务器,以及将计算。我们假设你的结果和服务器的结果(每当他们到达)将是相同的申请输入。当客户端按下键两次,结束了在X = 2,服务器将到达了同样的结论,并告诉你600毫秒后-你仍然是在正确的地方。

这是非常重要的客户端上的即时反馈,和更新,即使是通过一台服务器上运行,客户端的位置应该匹配。

插值其他客户的持仓

现在我们需要更新的是其他客户的立场,因为他们从网络到达。再次,一个天真的做法是简单地设置自己的立场,尽快到达的消息从服务​​器,但是这会导致极为生涩的其他客户端渲染。

解决的办法是存储我们从服务器以及它们之间的插值得到的位置。这意味着,我们借鉴他们的服务器后面的几帧,但它允许所有其他客户端的位置非常顺利的更新。在我们的演示,基于Source引擎的文章,上面列出,我们借鉴其他客户100ms的背后实际的服务器位置。

所有这一切都在演示实施,并在下面代码的形式阐述,但更多的信息和对主题的非常好图,加布里埃尔·甘贝塔做了一个很好的三 部分 系列上所提出的概念-包括客户端预测,插补和原因这些实时的游戏最好的工作。在我们的例子中,最重要的是,我们存储的球员 ​​给我们的每一个输入的输入序列。我们用它来 ​​跟踪服务器在我们的名单输入的是,我们进程重新输入该服务器尚未收到。

了解演示代码

在文章的最后提出的演示代码设有一个工作组讨论的议题,包括一些调试控制,调整和变化,在做法上的差异。演示看起来像这样:

现在,我们已经看到了理论的例子,我们就可以开始看代码是如何走到一起。

代码是如何结构

在演示中的代码包含四个文件,每个的不同部分的例子。该文件包含以下逻辑:

client.js游戏客户端安装在浏览器中的逻辑。 app.js服务器端的节点上运行的应用程序。这处理所有节点/快递/ socket.io设置了和代码。 game.server.js的游戏服务器(大堂)的逻辑。 game.core.js为游戏本身的逻辑,服务器和客户端。 核心游戏代码

代码里面game.core.js是我们的例子中的重要组成部分。Node.js的运行两个服务器和客户端(浏览器中运行)之间的代码共享。这允许代码使用相同的功能和算法过程的输入,同步运动,共享数据结构。

核心游戏类

game.core.js文件举办三类,下面详细介绍。

game_core类

这个类是整场比赛状态的驱动因素。它运行的更新功能,它处理的产出和投入的游戏和管理游戏,它的变化。游戏的核心,可谓是游戏世界。它包含两个球员,一个边界,它运行的世界逻辑。它使确定的物理模拟启动,它使确保他们运行时间和处理的球员 ​​输入的逻辑。

游戏世界是多人发生。我们希望游戏世界中存在三个地方(这个演示)。我们要运行的副本的游戏世界上每一个客户,并在服务器上,每场比赛。这是什么大厅game.server.js -它创造的世界,对每个球员的加入。

所有的代码被命名为根据目的服务。如果函数的名称与开始client_,此代码将永远被称为服务器端。如果函数开学服务器_,同样这段代码将无法运行客户端,但在服务器上只。上的所有其他职能game_core类是直接关系到比赛状态,获取服务器和客户端之间共享。

game_player类

可能是播放器的代码可能比你预期的轻了不少,但播放器类简单地维护它自己的空间属性和知道如何自行绘制(如果需要的话,就像在浏览器客户端)。当然,在服务器上,绘制函数只是永远不会被调用。

一个客户端和服务器之间共享的功能,例如

/*
    Shared between server and client.
    In this example, `item` is always of type game_player.
*/
game_core.prototype.check_collision = function( item ) {

        //Left wall.
    if(item.pos.x <= item.pos_limits.x_min) {
        item.pos.x = item.pos_limits.x_min;
    }

        //Right wall
    if(item.pos.x >= item.pos_limits.x_max ) {
        item.pos.x = item.pos_limits.x_max;
    }
    
        //Roof wall.
    if(item.pos.y <= item.pos_limits.y_min) {
        item.pos.y = item.pos_limits.y_min;
    }

        //Floor wall
    if(item.pos.y >= item.pos_limits.y_max ) {
        item.pos.y = item.pos_limits.y_max;
    }

        //Fixed point helps be more deterministic
    item.pos.x = item.pos.x.fixed(4);
    item.pos.y = item.pos.y.fixed(4);
    
}; //game_core.check_collision

在多人代码的重要职能

某些功能比其他人更重要多人。让我们来看看代码中提出的重要概念,看到流是如何工作的。有时简化为代码示例演示的关键概念。

实体插值(其他客户)

插值/平滑的其他客户。这是在这种方式处理:

从有关的其他客户端的服务器存储网络信息,至少100ms 插之间的最后一个已知的位置,并在新的位置(服务器后面的时间100ms的) 插值位置绘制插值客户。 我们实现这一目标的方式如下:

//We store server messages so that we can interpolate the client positions of other clients
//between a past and less past point. This is offset from the server time by net_offset ms.

client_onserverupdate_recieved = function(data){

//....
        
            //Store the server time (this is offset by the latency in the network, by the time we get it)
        this.server_time = data.t;
            //Update our local offset time from the last server update
        this.client_time = this.server_time - (this.net_offset/1000);

//....

            //Cache the data from the server,
            //and then play the timeline
            //back to the player with a small delay (net_offset), allowing
            //interpolation between the points.
        this.server_updates.push(data);

      //we limit the buffer, roughly in seconds
            // 60fps * buffer seconds = number of samples in the array
        if(this.server_updates.length >= ( 60*this.buffer_size )) {
            this.server_updates.splice(0,1);
        }

//....

} //onserverupdate


//Before we draw the other clients, we interpolate them based on where they are in the timeline (client_time)

client_process_net_updates = function() {

  //First : Find the position in the updates, on the timeline 
  //We call this current_time, then we find the past_pos and the target_pos using this,
  //searching throught the server_updates array for current_time in between 2 other times.
  // Then :  other player position = lerp ( past_pos, target_pos, current_time );

//....

      //The other players positions in the timeline, behind and in front of current_time
    var other_target_pos = target.pos;
    var other_past_pos = previous.pos;

        //this is a simple lerp to the target from the previous point in the server_updates buffer
        //we store the destination position on the ghost first, so we smooth even further if we wanted
    this.ghosts.pos_other.pos = this.v_lerp( other_past_pos, other_target_pos, time_point );

      //If applying additional smoothing,
    if(this.client_smoothing) {

        //Lerp from the existing position to the ghost position, based on a smoothing amount and physics delta time
        this.players.other.pos = this.v_lerp( this.players.other.pos, this.ghosts.pos_other.pos, this._pdt*this.client_smooth);

    } else {

        //No additional smoothing? Just apply the position
        this.players.other.pos = this.pos(this.ghosts.pos_other.pos);

    }

//....
}

客户预测(本地客户端)

预测发生在两个地方举行,接收服务器权威的反应时,在绘制之前,我们处理我们的输入,因为它发生在本地。这个逻辑是:

处理从客户端输入 平滑后的存储输入和输入的时间 存储输入序列 发送到服务器输入和输入序列 在从服务器上已知的输入序列的确认, 删除该服务器已经处理的投入 再涂投入,仍有待确认 下面是简化的代码,以显示输入处理:

client_handle_input = function() {
    
//....

            //Update what sequence we are on now
        this.input_seq += 1;

            //Store the input state as a snapshot of what happened.
        this.players.self.inputs.push({
            inputs : input,
            time : this.local_time.fixed(3),
            seq : this.input_seq
        });

            //Send the packet of information to the server.
            //The input packets are labelled with an 'i' in front.
        var server_packet = 'i.';
            server_packet += input.join('-') + '.';
            server_packet += this.local_time.toFixed(3).replace('.','-') + '.';
            server_packet += this.input_seq;
            
            //Go
        this.socket.send(  server_packet  );

//....

}


//In the update loop and when we recieve a message from the server
//we immediately set the client position, as the server has final say,
//but then we apply any input the server has not acknowledged yet, keeping our position consistent

client_process_net_prediction_correction = function() {
//....

        //The most recent server update
    var latest_server_data = this.server_updates[this.server_updates.length-1];

    var my_last_input_on_server = this.players.self.host ? 
                                    latest_server_data.his : 
                                        latest_server_data.cis;

        //If the server has sent us a 'host input sequence' or 'client input sequence' state 
    if(my_last_input_on_server) {

            //The last input sequence index in my local input list
        var lastinputseq_index = -1;

            //Find this input in the list, and store the index of that input
        for(var i = 0; i < this.players.self.inputs.length; ++i) {

            if(this.players.self.inputs[i].seq == my_last_input_on_server) {
                lastinputseq_index = i;
                break;
            }

        }

            //Now we can crop the list of any updates we have already processed
        if(lastinputseq_index != -1) {

            //since we have now gotten an acknowledgement from the server that our inputs here have been accepted
            //and we now predict from the last known position instead of wherever we were.

                //remove the rest of the inputs we have confirmed on the server
            var number_to_clear = Math.abs(lastinputseq_index + 1));
                //Then clear the past ones out
            this.players.self.inputs.splice(0, number_to_clear);

                //The player is now located at the new server position, authoritive server
            this.players.self.cur_state.pos = this.pos(my_server_pos);
            this.players.self.last_input_seq = lastinputseq_index;
            
                //Now we reapply all the inputs that we have locally that
                //the server hasn't yet confirmed. This will 'keep' our position the same,
                //but also confirm the server position at the same time.
            this.client_update_physics();
            this.client_update_local_position();

        } // if(lastinputseq_index != -1)
    } //if my_last_input_on_server


//....
}

获取代码

https://github.com/FuzzYspo0N/realtime-multiplayer-in-html5 观看演示

http://localhost:4004?debug

翻译的不好 仅供学习参考 有兴趣可以直接查看原文 来文转载自斯文贝里斯特伦 2012年07月18 日 原文地址:http://buildnewgames.com/real-time-multiplayer/

6 回复

这个可以,

翻译的挺辛苦的吧,不过错别字和不通顺的语句很多,多多加油哦~

谢谢支持哈 新手还在学习中

观看地址怎么是 localhost呢。

回到顶部