请选择 进入手机版 | 继续访问电脑版

《球球大作战》源码解析:服务器与客户端架构

[复制链接]
查看: 277|回复: 0
  • TA的每日心情
    擦汗
    2019-6-2 01:56
  • 签到天数: 25 天

    [LV.4]偶尔看看III

    1002

    主题

    1103

    帖子

    6万

    积分

    管理员

    Rank: 9Rank: 9Rank: 9

    积分
    65281

    最佳新人活跃会员热心会员推广达人宣传达人灌水之王突出贡献优秀版主荣誉管理论坛元老

    发表于 2019-2-18 11:07:15 | 显示全部楼层 |阅读模式
    最新动态:
    һ һ
    《球球大作战》源码解析:服务器与客户端架构


    鉴于agar.io类型游戏的火爆场面,一些公司纷纷效仿,一时间出现各种《XX大作战》类型的游戏。出于学习的目的,亦是做些技术和方案储备,接下来会有大概10篇文章,分析下面这款使用nodejs编写的开源“球球大作战”。由于该游戏采用服务端运算、客户端显示的方式,服务端的逻辑处理是该源码的重点,故而系列文章主要针对服务端。通过这套源码,可以学习到“一种基于nodejs的简单服务器实现方法”“一种简单的服务端物理逻辑的实现方式”“一种基于redis pub/sub的跨服设计思想”“nodejs语法、框架及其使用方式”等内容。

    系列文章将会分析huytd/agar.io-clone的源码,这是一套简约而不简单的Agar.IO实现。该项目使用NodeJS开发,使用socket.IO作为网络通信,使用HTML5实现客户端。


    一、运行起来

    下图为游戏运行画面,游戏规则如下。

    1、玩家可以移动鼠标控制小球

    2、当小球吞食场景中的食物或其他玩家控制的小球时,玩家控制的小球会变大

    3、小球越大,移动速度越慢

    4、小球的质量代表它的大小,质量为它吞食的食物或其他玩家的质量之和

    5、游戏目标是尽可能的吞食其他玩家,使小球变大

    6、玩家刚出生时会有无敌属性,直到它吞食食物

    7、每当有玩家进入游戏,场景中会生成3个食物

    8、每当吞食食物时,场景中亦会生成一个新的食物


    第一步便是要让游戏运行起来,只有运行起来了,才谈得上后续的源码分析。为了“从零开始”,笔者购买Ubuntu系统的腾讯云,新的系统几乎没有安装额外软件,一步一步安装所需的软件,然后将游戏运行起来吧。笔者选用了最低一档配置的服务器,花费近50大洋(此处是不是应该发个求赞助的链接?)配置如下图所示。


    1、安装nodeJs

    游戏使用nodejs开发,那就必须要安装nodejs,可以有两种方法安装。

    方法1:输入sudo apt install nodejs,这是最简单的安装方法了。不过使用该方式安装的程序名叫为nodejs,而不是普遍使用的node。可以使用sudo ln-s/usr/bin/nodejs/usr/bin/node建立名为node的连接,以解决这个问题。

    方法2:下载源码、编译、安装。具体可以参考这篇文章在Ubuntu下安装Node.JS的不同方式-技术◆学习|Linux.中国-开源社区(文章里使用的node-v6.9.5要改为最新版的)

    完成后,可以使用node-v查看nodejs版本号,以验证是否成功安装。

    2、上传代码文件

    从github上下载源码,然后上传到linux服务器上。如下图所示,笔者将源码上传到/home/ubuntu/agar.io-clone-master目录下


    3、安装npm

    npm(node package manager)是nodejs的包管理和分发工具,一般安装nodejs后都需要安装该软件,可以使用以下命令安装:sudo apt install npm

    4、安装gulp

    项目使用到了gulp,需要安装它。gulp是一个前端构建工具,开发者可以使用它在项目开发过程中自动执行常见任务,比如复制文件,比如替换文件中某些字符。进入源码目录,执行sudo npm install-g gulp即可安装。

    5、安装项目所需的包文件

    进入源码目录,执行npm install即可安装项目所需包文件。npm install会检查当前目录下的package.json文件,文件包含了项目所需的模块,npm根据该文件的描述下载这些文件并把模块放到./node_modules目录下。关于package.json的格式可以参考这篇文章package.json for NPM文件详解

    6、运行服务器

    在源码目录下执行gulp run,可以看到服务器启动的提示信息。

    7、运行客户端

    运行浏览器,输入地址即可,笔者的腾讯云ip为139.199.179.39,由于默认配置了3000端口,所以要输入http://139.199.179.39:3000/,即可看到如下的游戏界面。


    在笔者的试验中,该页面报错,点击按钮没有反应。原因是src/client中的index.html最后面有这么一句,<script src="//code.jquery.com/jquery-2.2.0.min.js"></script>,该语句用于加载jquery的,而http://code.jquery.com/jquery-2.2.0.min.js无法访问(或国内网络访问速度慢),导致报错。只要换个文件地址即可,例如改成下面这样:

    <script src="http://libs.baidu.com/jquery/1.9.0/jquery.js"></script >

    运行游戏,服务端也会打印出相应的信息,如下图所示。


    把游戏运行起来后,下一步就要分析下游戏的流程了。

    二、程序流程

    在解析源码之前,需要先了解该项目的程序流程,了解客户端和服务端是如何运行和通信的。本文是wiki文档Game Architecture的翻译,以帮助读者从大方向上了解《球球大作战》。

    程序架构

    游戏程序使用NodeJs编写,服务端通过http://Socket.IO创建WebSocket服务并默认监听3000号端口。程序还使用ExpressJS建立一个简单的HTTP服务器,它负责html页面的显示。index.html是游戏主页面,它通过Canvas渲染游戏,通过Javascript脚本和服务端通信。


    目录结构该项目由3部分组成:

    1、配置文件,如package.json,config.json等等

    2、客户端程序

    3、服务端程序

    配置文件package.json列出了项目所需的库文件,读者只需在项目目录下执行“npm install”即可自动安装这些文件。package.json的格式可以参考下面的文章:

    npm package.json属性详解

    游戏客户端

    client文件夹里包含了客户端所需的代码,它是一个简单的HTML文件,该文件会通过canvas绘制游戏场景、聊天框等元素。


    js/app.js是客户端的逻辑代码,它实现了画面渲染、网络延迟检测、观战模式、聊天等功能,处理了鼠标输入、服务端通信等事项。游戏采用服务端运算模式,客户端只是负责将服务端发来的数据显示到屏幕上,以及接收鼠标事件。

    客户端程序使用了requestAnimationFrame程序渲染循环,而不是使用setInterval,这让游戏有着更好的渲染性能。你可以试着修改代码,调用setInterval方法,看看低效率的渲染是个啥样子。
    (function animloop(){
      requestAnimFrame(animloop);
      gameLoop();
    })();

    to

    setInterval(gameLoop, 16);


    游戏服务端

    server/server.js包含了服务端的配置和逻辑处理,配置了诸如食物质量、移动速度、无敌状态的最大质量,处理了食物颜色计算、碰撞检测、玩家移动处理等等事项。


    所有的游戏逻辑都在服务端处理,服务端和客户端的通信有着下面几个要点。

    1、服务端使用list保存玩家列表,而不是使用array,使用list保存食物列表,而不是使用array。服务端保存着socket列表,用于记录所有客户端连接。

    2、之前的版本设置了一个定时器,每隔几秒钟就产生一些食物,但这种方法的效率不高,会延迟服务端处理速度。所有在此版本中使用了一种新的方式来产生食物,当一个玩家进入游戏时,程序会随机产生3个食物(可以修改配置文件的newFoodPerPlayervariable改变该数值),当玩家吃掉一个食物时,程序会产生另外一个食物(可以修改配置文件的respawnFoodPerPlayer改变该数值)。如果场景中的食物数量大于50(配置文件的maxFoodCount),服务端会停止产生新食物。

    客户端服务端通信

    客户端与服务端通信可以分为两个部分,分别是登录认证和游戏内通信。

    登陆认证


    当一个玩家打开游戏网页,他先会看到一个输入用户名的对话框,点击“Play”按钮后,客户端发起socket连接,服务端accept连接后发出welcome协议,并把该客户端的UserID附带在协议中。

    当客户端收到welcome协议,它会返回附带用户名的gotit协议。

    当服务端收到gotit协议,它会其它的已连接玩家广播playerJoin协议,告诉他们有新的玩家加入。其它玩家收到该协议后,会在屏幕上绘制这个新加入的角色。

    此时,对于新加入的玩家来说,游戏刚刚开始。

    游戏内通信

    游戏内通信分为3个部分,分别是游戏逻辑、聊天和Ping(测试网络延迟)。

    游戏逻辑

    玩家在游戏中会有移动、吞食食物、吞食其他玩家三种行为,这些逻辑全部由服务端运算,客户端只是根据运算结果将图像显示在对应的位置上。

    移动


    当玩家移动鼠标,小球会朝着鼠标的位置移动。客户端会发送附带了目的地坐标的playerSendTarget协议。服务端收到协议后会更新小球的运动状态,然后向该客户端回复serverTellPlayerMove协议,然后发送serverUpdateAllPlayers给其他客户端,让全部客户端更新所有玩家的坐标。

    小球移动期间,服务端还会检测小球是否吞食了食物,或者吞食了其他玩家。

    吞食食物


    服务端维持了users列表和food列表来保存所有的小球和食物的信息,如果小球碰到食物,服务端会执行相应的逻辑,增加小球质量、删除列表里的食物、产生新的食物。然后服务端广播serverUpdateAllPlayers和serverUpdateAllFoods协议,让客户的更新玩家和食物。

    吞食其他玩家


    如果小球吞食了其他玩家的小球,服务端会比较两者的质量和距离,质量小的被吞食。服务端会发送RIP协议告诉质量下的玩家他死掉了,然后断开与该玩家的连接,同时在users列表里删除他。还会广播serverUpdateAllPlayers协议通知客户端。

    聊天

    聊天的流程如下图所示

    当玩家在聊天框中输入信息并按下回车键时,客户端向服务端发送playerChat协议,服务端收到协议后广播serverSendPlayerChat协议。


    当客户端收到serverSendPlayerChat协议时,它会解析该协议,将聊天内容显示到屏幕上。

    Ping(延迟检测)

    网络游戏都会实现ping机制来检测客户端和服务端之间的延迟,而它的实现也很简单。


    检测开始时,客户端会保存当前的开始时间,然后发送ping协议给服务端,服务端收到后,会返回pong协议。客户端收到pong协议会计算时间差,如果时间差很大,说明网络延迟很严重。

    愿这份文档能够协助读者理解agar.io-clone这个项目,你还可以继续完善这款游戏,将它做得更好。也希望各位能够在项目wiki中分享心得。

    三、gulp工具

    运行游戏使用的命令是gulp run,agar.io-clones使用了nodejs开发,gulp是基于nodejs的一个工具,它能够批量的做一些文件操作。gulp run意思是执行目录下gulpfile.js下的run任务,那么源码中使用了gulp的哪些功能呢?这篇文章将会做个简单介绍。

    gulp能自动化地完成javascript/coffee/sass/less/html/image/css等文件的的测试、检查、合并、压缩、格式化、浏览器自动刷新、部署文件生成,并检测文件变化。在实现上,gulp鉴了Unix操作系统的管道(pipe)思想,前一级的输出,直接变成后一级的输入。

    关于gulp入门,可以参考下面的文章:

    一点|gulp详细入门教程

    入门指南-gulp.js中文文档
    一个最简单的示例

    要使用gulp根据,当然得先安装它,有两种方式安装,对应于不同的命令参数。

    全局安装gulp:npm install--global gulp

    作为项目的开发依赖(devDependencies)安装:npm install--save-dev gulp

    现在新建一个目录并创建一个名为gulpfile.js的文件,在里面编写如下代码

    var gulp = require('gulp');gulp.task('default', function() {  // 将你的默认的任务代码放在这});
    在目录下执行gulp,此时程序会搜寻目录下gulpfile.js文件中的默认(default)任务,也就是上面代码中“//将你的默认的任务代码放在这”处的代码去执行。“gulp run”即表示执行名为run的任务,相关代码可以在项目文件夹下的gulpfile.js中看到。相关代码如下

    gulp.task('run', ['build'], function () {
        nodemon({
            delay: 10,
            script: './server/server.js',
            cwd: "./bin/",
            args: ["config.json"],
            ext: 'html js css'
        })
        .on('restart', function () {
            util.log('server restarted!');
        });
    });

    代码解析

    要看懂上面的代码,必须要了解gulp的一些API,知道“nodemon”等单词到底是什么意思,实现什么功能,gulp的api可以参考下面的文章:

    一点|gulp教程之gulp中文API

    依赖

    上面代码中的“gulp.task('run',['build'],function(){}”意为run依赖于build,当执行gulp run时,程序会先执行build任务,再执行run任务。

    nodemon

    先看看nodemon,详细的解释可以参考gulp-nodemon

    nodemon是一个工具,用于项目代码发生变化时可以自动重启,nodemon本意时检测项目变化的,对项目做监控的。重启只是它的一个功能。在上面的代码中,相当于执行./server/server.js这个文件。而这个文件其实是build任务中生成的。

    build任务

    接下来看看build任务是什么样子的,会发现build任务依赖于build-client、build-server、test、todo这4个任务,也就是说,需要按顺序先执行这4个任务,才会执行build。此时我们会发现,代码的执行流程是build-client、build-server、test、todo、run

    gulp.task('build',['build-client','build-server','test','todo']);

    build-client任务

    build-client处理了客户端代码的创建,它用到了uglify、webpack和babel。

    其中uglify表示压缩javascript文件,减小文件大小(参见一点|gulp教程之gulp-uglify)

    webpack表示模块打包,它能帮我们把本来需要在服务端运行的JS代码,通过模块的引用和依赖打包成前端可用的静态文件(参考《nodejs+gulp+webpack基础实战篇》课程笔记(三)--webpack篇-亡命小卒-博客园)

    babel是一个JavaScript转换编译器,它可以将ES6(下一代JavaScript规范,添加了一些新的特性和语法)转换成ES5(可以在浏览器中运行的代码)。这就意味你可以在一些暂时还不支持某些ES6特性的浏览器引擎中,使用ES6的这些特性。比如说,class和箭头方法。

    pipe表示管道,下面的代码是指将源文件(.src)“src/client/js/app.js”通过uglify方法压缩,然后将压缩后的结果通过webpack打包,然后通过babel做兼容性,最后通过将文件存入dest指定的目录下“bin/client/js/”

    gulp.task('build-client', ['lint', 'move-client'], function () {  return gulp.src(['src/client/js/app.js'])    .pipe(uglify())    .pipe(webpack(require('./webpack.config.js')))    .pipe(babel({      presets: [        ['es2015', { 'modules': false }]      ]    }))    .pipe(gulp.dest('bin/client/js/'));});
    webpack()方法的参数是“require('./webpack.config.js') ”“ ./webpack.config.js”,该文件的内容如下,它是打包的配置文件。

    module.exports = {
        entry: "./src/client/js/app.js",
        output: {
            path: require("path").resolve("./src/bin/client/js"),
            library: "app",
            filename: "app.js"
        },
        module: {
            loaders: [
                {
                    test: /\.jsx?$/,
                    exclude: /(node_modules|bower_components)/,
                    loader: 'babel'
                }
            ]
        }
    };

    “build-client”依赖于“lint”和“move-client”,先要完成这两个任务,程序才会执行“build-client”任务。

    lint任务

    “lint”任务如下所示,它使用了jshint方法。jshint是用来检测javascript的语法错误的。如果有错误,就报告fail。

    gulp.task('lint', function () {
      return gulp.src(['**/*.js', '!node_modules/**/*.js', '!bin/**/*.js'])
        .pipe(jshint({
              esnext: true
          }))
        .pipe(jshint.reporter('default', { verbose: true}))
        .pipe(jshint.reporter('fail'));
    });

    move-client任务

    “build-client”还依赖于“move-client”代码如下,它只是移动一些文件

    gulp.task('move-client', function () {
      return gulp.src(['src/client/**/*.*', '!client/js/*.js'])
        .pipe(gulp.dest('./bin/client/'));
    });

    build-server任务

    build-server任务比较简单,它也是复制下文件

    gulp.task('build-server', ['lint'], function () {
      return gulp.src(['src/server/**/*.*', 'src/server/**/*.js'])
        .pipe(babel())
        .pipe(gulp.dest('bin/server/'));
    });

    test任务

    build任务依赖于build-client、build-server、test和todo任务,在建了客户端和服务端文件后,自然需要对它测试一下,test任务调用了mocha方法,它是一个测试方法。

    gulp.task('test', ['lint'], function () {
        gulp.src(['test/**/*.js'])
            .pipe(mocha());
    });

    todo任务

    todo任务调用了todo方法,该方法会收集符合“src/**/*js”匹配符的文件信息,生成一个名为TODO.md的文件。

    gulp.task('todo', ['lint'], function() {  gulp.src('src/**/*.js')      .pipe(todo())      .pipe(gulp.dest('./'));});
    生成的TODO.md如下图所示。


    由于实际运行的文件在是bin/目录下,如果修改了源文件,需要重新执行gulp run才能生效。

    四、Websocket

    运行服务端后,玩家只要打开浏览器,输入地址和端口,就可以看到游戏画面。这就意味着,游戏服务端开了个http服务器。Node.js标准库提供了http模块,其中封装了一个高效的HTTP服务器和一个简易的HTTP客户端。http.Server是一个基于事件的HTTP服务器,它的核心由Node.js下层C++部分实现,而接口由JavaScript封装,兼顾了高性能与简易性。http.request则是一个HTTP客户端工具,用于向HTTP服务器发起请求。关于http服务端的入门,可以参考下面教程。

    Node.js学习(11)----HTTP服务器与客户端-推酷

    安装http包

    使用http模块,必须先安装它,执行npm install http命令安装即可。

    显示Html文本

    新建一个js文件,然后输入如下的代码。通过require('http').Server创建一个http服务器,“http.listen”表示开启监听,如下代码是监听3001端口,监听成功后会在屏幕中打印出“[DEBUG]Listening”。http.on('request'function(){……})表示当服务端收到客户端的请求时做出怎样的处理,这里向客户端返回html信息。

    var http = require('http').Server()

    http.on('request',function(req,res){
            console.log('[DEBUG] on request ' );
            res.writeHead(200,{'Content-Type':'text/html'});
            res.write('<h1>Node.js</h1>');
            res.end('<p>HelloWorld</p>');
    });

    http.listen(3001, function() {
        console.log('[DEBUG] Listening ' );
    });

    运行脚本,然后用浏览器打开3001端口,即可看到html文本。


    用express显示Html文件

    Express是一个基于Node.js平台的web应用开发框架,可以使用它指定要显示的网页文件。在使用之前需要使用npm install express命令安装express。

    新建js文件填入下面的代码,除了创建http服务器外,还使用express指定了网页目录“__dirname+'/'”,即代码文件的同一目录下。

    var express = require('express');
    var app = express();
    var http = require('http').Server(app)
    var io = require('socket.io')(http);
    app.use(express.static(__dirname + '/'));

    console.log("hehe");


    http.listen(3001, function() {
        console.log('[DEBUG] Listening ' );
    });

    在同一目录下新建index.html,输入下面的文本。

    <html>
    <head>
        <title>Ssocket</title>
    </head>

    <body>
            <P>测试</P>

    </body>
    </html>

    运行服务端,用浏览器打开页面,将会看到如下网页。


    WebSocket介绍

    谈到Web实时推送,就不得不说WebSocket。在WebSocket出现之前,很多网站为了实现实时推送技术,通常采用的方案是轮询(Polling)和Comet技术,Comet又可细分为两种实现方式,一种是长轮询机制,一种称为流技术,这两种方式实际上是对轮询技术的改进,这些方案带来很明显的缺点,需要由浏览器对服务器发出HTTP request,大量消耗服务器带宽和资源。面对这种状况,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽并实现真正意义上的实时推送。

    WebSocket协议本质上是一个基于TCP的协议,它由通信协议和编程API组成,WebSocket能够在浏览器和服务器之间建立双向连接,以基于事件的方式,赋予浏览器实时通信能力。既然是双向通信,就意味着服务器端和客户端可以同时发送并响应请求,而不再像HTTP的请求和响应。

    具体可以参考下面的文章

    使用Node.js+Socket.IO搭建WebSocket实时应用-OPEN开发经验库

    WebSocket简单实例

    下面通过一个简单的例子介绍WebSocket的使用方法,在安装WebSocket后编写如下的代码和html文件。当客户端发起连接(connection)后,它会打印出“A user connected!”

    var express = require('express');
    var app = express();
    var http = require('http').Server(app)
    var io = require('socket.io')(http);
    app.use(express.static(__dirname + '/'));

    console.log("hehe");

    io.on('connection', function (socket) {
        console.log('A user connected!', socket.handshake.query.type);
    })

    http.listen(3001, function() {
        console.log('[DEBUG] Listening ' );
    });

    html代码如下所示,页面中会有一个按钮,当点击按钮时,会通过io.connect连接服务端

    <html>
    <head>
        <title>Ssocket</title>
            http://139.199.179.39:3001/socket.io/socket.io.js</a>">   
    </head>

    <body>
            <P>测试</P>
            <input type="button" id="btn" value="click" />
            <script type="text/javascript">
                    var oBtn = document.getElementById('btn');
                            oBtn.onclick = function(){
                            var socket = io.connect('http://139.199.179.39:3001/');
                            alert("send");
                    };
            </script>

    </body>
    </html>

    运行程序,点击客户端上的按钮,服务端会显示“A user connected!”

    收发信息

    客户端和服务端可要相互通信,在下面的例子中,网页上有connect和send两个按钮,点击send按钮后,会发送login协议,服务端收到login协议后,会打印客户端传来的信息。

    var express = require('express');
    var app = express();
    var http = require('http').Server(app)
    var io = require('socket.io')(http);
    app.use(express.static(__dirname + '/'));

    console.log("hehe");

    io.on('connection', function (socket) {
        console.log('A user connected!', socket.handshake.query.type);
            
            socket.on('login', function (data) {
          console.log(data);
        });
    })

    http.listen(3001, function() {
        console.log('[DEBUG] Listening ' );
    });
    <html>
    <head>
        <title>Socket</title>
            http://139.199.179.39:3001/socket.io/socket.io.js</a>">   
    </head>

    <body>
            <P>测试</P>
            <input type="button" id="btn1" value="connect" />
            <input type="button" id="btn2" value="send" />
            <script type="text/javascript">
                    var oBtn1 = document.getElementById('btn1');
                            oBtn1.onclick = function(){
                            socket = io.connect('http://139.199.179.39:3001/');
                            alert("connect");
                    };
                   
                    var oBtn2 = document.getElementById('btn2');
                            oBtn2.onclick = function(){
                            socket.emit('login', { name: 'LPY' });
                            alert("send");
                    };
            </script>

    </body>
    </html>

    运行程序,点击按钮,服务端将会显示客户端login协议传入的用户名“LPY”,如下图所示。

    客户端


    服务端


    客户端回显

    在下面的代码中,服务端收到客户端的login协议后会恢复客户端loginBack协议,客户端收到loginBack协议后会弹出对话框。

    var express = require('express');
    var app = express();
    var http = require('http').Server(app)
    var io = require('socket.io')(http);
    app.use(express.static(__dirname + '/'));

    console.log("hehe");

    io.on('connection', function (socket) {
        console.log('A user connected!', socket.handshake.query.type);
            
            socket.on('login', function (data) {
                    console.log(data);
                    socket.emit('loginBack', { result: 'success' });
        });
    })

    http.listen(3001, function() {
        console.log('[DEBUG] Listening ' );
    });

    <html>
    <head>
        <title>Socket</title>
            http://139.199.179.39:3001/socket.io/socket.io.js</a>">   
    </head>

    <body>
            <P>测试</P>
            <input type="button" id="btn1" value="connect" />
            <input type="button" id="btn2" value="send" />
            <script type="text/javascript">
                    var oBtn1 = document.getElementById('btn1');
                            oBtn1.onclick = function(){
                                    socket = io.connect('http://139.199.179.39:3001/');
                                    alert("connect");
                            
                                    socket.on('loginBack', function (data) {   
                                            alert(data.result);
                                    });
                    };
                   
                    var oBtn2 = document.getElementById('btn2');
                            oBtn2.onclick = function(){
                                    socket.emit('login', { name: 'LPY' });
                                    alert("send");
                            };
            </script>

    </body></html>

    运行程序,结果如下图所示。


    还是放个广告吧,笔者出版的一本书《Unity3D网络游戏实战》充分的讲解怎样开发一款网络游戏,特别对网络框架设计、网络协议、数据处理等方面都有详细的描述,相信会是一本好书的。





    帖子的最近访客


    ByteEdu声明 1、欢迎大家加入ByteEdu交流群: 群①:221273219 群②:816865824 群③:552042911 群④:713828896
    2、本站所有主题由该帖子作者发表,该帖子作者与 ByteEdu 享有帖子相关版权
    3、其他单位或个人使用、转载或引用本文时必须注明原文的出处
    4、如本帖侵犯到任何版权问题,请立即告知本站,本站将及时予与删除并致以最深的歉意
    5、ByteEdu -服务您的独立游戏开发专家!
    6、联系人Email:admin@ByteEdu.com 网址:www.ByteEdu.Com

    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    精彩推荐

    专业教育平台

    • 反馈建议:service@ByteEdu.com
    • 工作时间:周一到周五 10:00-19:00
    028-8555 6107

    关注我们

    Copyright   ©2018-2020  www.ByteEdu.Com  Powered by©ByteEdu!  技术支持:成都贝游特教育科技有限公司    ( 蜀ICP备18026143号 )