相信大家都是知道游戏的吧。
这玩意还是很有意思的,无论是超级玛丽,还是魂斗罗,亦或者是王者荣耀以及阴阳师。
当然,这篇文章不涉及到那么牛逼的游戏,这里就简单的做一个小游戏吧。
先给它取个名字,就叫“球球作战”吧。
玩法咳咳,简单易懂嘛
任何人进入游戏输入名字然后就可以连接进入游戏,控制一个小球。
你可以操作这个小球进行一些动作,比如:移动,发射子弹。
通过*死其他玩家来获取积分,并在排行榜上进行排名。
其实这类游戏有一个统一的名称,叫做IO类游戏,在这个网站中有大量的这类游戏:https://iogames.space/
这个游戏的github地址:https://github.com/lionet1224/node-game
在线体验: http://120.77.44.111:3000/
演示GIF:
准备工作首先制作这个游戏,我们需要的技术为:
并且你需要对以下技术有一定了解:
游戏架构其实本来想使用deno和ts来开发的,但是因为我对这两项技术都是半生不熟的阶段,所以就不拿出来献丑了。
后端服务需要做的是:
前端需要做的是:
这也是典型的状态同步方式开发游戏。
后端服务搭建开发因为前端是通过后端的数据驱动的,所以我们就先开发后端。
搭建起一个Express服务首先我们需要下载express,在根目录下输入以下命令:
//创建一个package.json文件
>npminit
//安装并且将其置入package.json文件中的依赖中
>npminstallexpresssocket.io--save
//安装并置入package.json的开发依赖中
>npminstallcross-envnodemon--save-dev
这里我们也可以使用cnpm进行安装
然后在根目录中疯狂建文件夹以及文件。
image.png
我们就可以得出以上的文件啦。
解释一下分别是什么东西:
然后我们在server.js中编写启动服务的相关代码。
//server.js
//引入各种模块
constexpress=require('express')
constsocketio=require('socket.io');
constapp=express();
constSocket=require('./core/socket');
constGame=require('./core/game');
//启动服务
constport=process.env.PORT||3000;
constserver=app.listen(3000,()=>{
console.log('ServerListeningonport:' port)
})
//实例游戏类
constgame=newGame;
//监听socket服务
constio=socketio(server)
//将游戏以及io传入创建的socket类来统一管理
constsocket=newSocket(game,io);
//监听连接进入游戏的回调
io.on('connect',item=>{
socket.listen(item)
})
上面的代码还引入了两个其他文件core/game、core/socket。
这两个文件中的代码,我大致的编写了一下。
//core/game.js
classGame{
constructor(){
//保存玩家的socket信息
this.sockets={}
//保存玩家的游戏对象信息
this.players={};
//子弹
this.bullets=[];
//最后一次执行时间
this.lastUpdateTime=Date.now();
//是否发送给前端数据,这里将每两帧发送一次数据
this.shouldSendUpdate=false;
//游戏更新
setInterval(this.update.bind(this),1000/60);
}
update(){
}
//玩家加入游戏
joinGame(){
}
//玩家断开游戏
disconnect(){
}
}
module.exports=Game;
//core/socket.js
constConstants=require('../../shared/constants')
classSocket{
constructor(game,io){
this.game=game;
this.io=io;
}
listen(){
//玩家成功连接socket服务
console.log(`Playerconnected!SocketId:${socket.id}`)
}
}
module.exports=Socket
在core/socket中引入了常量文件,我们来看看我在其中是怎么定义的。
//shared/constants.js
module.exports=Object.freeze({
//玩家的数据
PLAYER:{
//最大生命
MAX_HP:100,
//速度
SPEED:500,
//大小
RADUIS:50,
//开火频率,0.1秒一发
FIRE:.1
},
//子弹
BULLET:{
//子弹速度
SPEED:1500,
//子弹大小
RADUIS:20
},
//道具
PROP:{
//生成时间
CREATE_TIME:10,
//大小
RADUIS:30
},
//地图大小
MAP_SIZE:5000,
//socket发送消息的函数名
MSG_TYPES:{
JOIN_GAME:1,
UPDATE:2,
INPUT:3
}
})
Object.freeze() 方法可以冻结一个对象。一个被冻结的对象再也不能被修改;冻结了一个对象则不能向这个对象添加新的属性,不能删除已有属性,不能修改该对象已有属性的可枚举性、可配置性、可写性,以及不能修改已有属性的值。此外,冻结一个对象后该对象的原型也不能被修改。freeze() 返回和传入的参数相同的对象。- MDN
通过上面的四个文件的代码,我们已经拥有了一个具备基本功能的后端服务结构了。
接下来就来将它启动起来吧。
创建启动命令在package.json中编写启动命令。
//package.json
{
//...
"scripts":{
"dev":"cross-envNODE_ENV=developmentnodemonsrc/servers/server.js",
"start":"cross-envNODE_ENV=productionnodemonsrc/servers/server.js"
}
//..
}
这里的两个命令dev和start都使用到了cross-env和nodemon,这里解释一下:
执行以下命令开启开发模式。
>npmrundev
可以看到我们成功的启动服务了,监听到了3000端口。
在服务中,我们搭载了socket服务,那要怎么测试是否有效呢?
所以我们现在简单的搭建一下前端吧。
Webpack搭建前端文件我们在开发前端的时候,用到模块化的话会开发更加丝滑一些,并且还有生产环境的打包压缩,这些都可以使用到Webpack。
我们的打包有两种不同的环境,一种是生产环境,一种是开发环境,所以我们需要两个webpack的配置文件。
当然傻傻的直接写两个就有点憨憨了,我们将其中重复的内容给解构出来。
我们在根目录下创建webpack.common.js、webpack.dev.js、webpack.prod.js三个文件。
此步骤的懒人安装模块命令:
npm install @babel/core @babel/preset-env babel-loader css-loader html-webpack-plugin mini-css-extract-plugin optimize-css-assets-webpack-plugin terser-webpack-plugin webpack webpack-dev-middleware webpack-merge webpack-cli \--save-dev
//webpack.common.js
constpath=require('path');
constMiniCssExtractPlugin=require('mini-css-extract-plugin');
constHtmlWebpackPlugin=require('html-webpack-plugin');
module.exports={
entry:{
game:'./src/client/index.js',
},
//将打包文件输出到dist文件夹
output:{
filename:'[name].[contenthash].js',
path:path.resolve(__dirname,'dist'),
},
module:{
rules:[
//使用babel解析js
{
test:/\.js$/,
exclude:/node_modules/,
use:{
loader:"babel-loader",
options:{
presets:['@babel/preset-env'],
},
},
},
//将js中的css抽出来
{
test:/\.css$/,
use:[
{
loader:MiniCssExtractPlugin.loader,
},
'css-loader',
],
},
],
},
plugins:[
newMiniCssExtractPlugin({
filename:'[name].[contenthash].css',
}),
//将处理后的js以及css置入html中
newHtmlWebpackPlugin({
filename:'index.html',
template:'src/client/html/index.html',
}),
],
};
上面的代码已经可以处理css以及js文件了,接下来我们将它分配给development和production中,其中production将会压缩js和css以及html。
//webpack.dev.js
const{merge}=require('webpack-merge')
constcommon=require('./webpack.common')
module.exports=merge(common,{
mode:'development'
})
//webpack.prod.js
const{merge}=require('webpack-merge')
constcommon=require('./webpack.common')
//压缩js的插件
constTerserJSPlugin=require('terser-webpack-plugin')
//压缩css的插件
constOptimizeCssAssetsPlugin=require('optimize-css-assets-webpack-plugin')
module.exports=merge(common,{
mode:'production',
optimization:{
minimizer:[newTerserJSPlugin({}),newOptimizeCssAssetsPlugin({})]
}
})
上面已经定义好了三个不同的webpack文件,那么该怎么样使用它们呢?
首先开发模式,我们需要做到修改了代码就自动打包代码,那么代码如下:
//src/servers/server.js
constwebpack=require('webpack')
constwebpackDevMiddleware=require('webpack-dev-middleware')
constwebpackConfig=require('../../webpack.dev')
//前端静态文件
constapp=express();
app.use(express.static('public'))
if(process.env.NODE_ENV==='development'){
//这里是开发模式
//这里使用了webpack-dev-middleware的中间件,作用就是代码改动就使用webpack.dev的配置进行打包文件
constcompiler=webpack(webpackConfig);
app.use(webpackDevMiddleware(compiler));
}else{
//上线环境就只需要展示打包后的文件夹
app.use(express.static('dist'))
}
接下来就在package.json中添加相对应的命令吧。
{
//...
"scripts":{
"build":"webpack--configwebpack.prod.js",
"start":"npmrunbuild&&cross-envNODE_ENV=productionnodemonsrc/servers/server.js"
},
//...
}
接下来,我们试试dev和start的效果吧。
可以看到使用npm run dev命令后不仅启动了服务还打包了前端文件。
再试试npm run start。
也可以看到先打包好了文件再启动了服务。
我们来看看打包后的文件。
测试Socket是否有效先让我装一下前端的socket.io。
>npminstallsocket.io-client--save
然后编写一下前端文件的入口文件:
//src/client/index.js
import{connect}from'./networking'
Promise.all([
connect()
]).then(()=>{
}).catch(console.error)
可以看到上面代码我引入了另一个文件networking,我们来看一下:
//src/client/networking
importiofrom'socket.io-client'
//这里判断是否是https,如果是https就需要使用wss协议
constsocketProtocal=(window.location.protocol.includes('https')?'wss':'ws');
//这里就进行连接并且不重新连接,这样可以制作一个断开连接的功能
constsocket=io(`${socketProtocal}://${window.location.host}`,{reconnection:false})
constconnectPromise=newPromise(resolve=>{
socket.on('connect',()=>{
console.log('Connectedtoserver!');
resolve();
})
})
exportconstconnect=onGameOver=>{
connectPromise.then(()=>{
socket.on('disconnect',()=>{
console.log('Disconnectedfromserver.');
})
})
}
上面的代码就是连接socket,将会自动获取地址然后进行连接,通过Promise传给index.js,这样入口文件就可以知道什么时候连接成功了。
我们现在就去前端页面中看一下吧。
可以很清楚的看到,前后端都有连接成功的相关提示。
创建游戏对象我们现在来定义一下游戏中的游戏对象吧。
首先游戏中将会有四种不同的游戏对象:
我们来一一将其实现吧。
首先他们都属于物体,所以我给他们都定义一个父类Item:
//src/servers/objects/item.js
classItem{
constructor(data={}){
//id
this.id=data.id;
//位置
this.x=data.x;
this.y=data.y;
//大小
this.w=data.w;
this.h=data.h;
}
//这里是物体每帧的运行状态
update(dt){
}
//格式化数据以方便发送数据给前端
serializeForUpdate(){
return{
id:this.id,
x:this.x,
y:this.y,
w:this.w,
h:this.h
}
}
}
module.exports=Item;
上面这个类是所有游戏对象都要继承的类,它定义了游戏世界里每一个元素的基本属性。
接下来就是player、Prop、Bullet的定义了。
//src/servers/objects/player.js
constItem=require('./item')
constConstants=require('../../shared/constants')
/**
*玩家对象类
*/
classPlayerextendsItem{
constructor(data){
super(data);
this.username=data.username;
this.hp=Constants.PLAYER.MAX_HP;
this.speed=Constants.PLAYER.SPEED;
//击败分值
this.score=0;
//拥有的buffs
this.buffs=[];
}
update(dt){
}
serializeForUpdate(){
return{
...(super.serializeForUpdate()),
username:this.username,
hp:this.hp,
buffs:this.buffs.map(item=>item.type)
}
}
}
module.exports=Player;
然后是道具以及子弹的定义。
//src/servers/objects/prop.js
constItem=require('./item')
/**
*道具类
*/
classPropextendsItem{
constructor(){
super();
}
}
module.exports=Prop;
//src/servers/objects/bullet.js
constItem=require('./item')
/**
*子弹类
*/
classBulletextendsItem{
constructor(){
super();
}
}
module.exports=Bullet
上面都是简单的定义,随着开发会逐渐添加内容。
添加事件发送上面的代码虽然已经定义好了,但是还需要使用它,所以在这里我们来开发使用它们的方法。
在玩家输入名称加入游戏后,需要生成一个Player的游戏对象。
//src/servers/core/socket.js
classSocket{
//...
listen(socket){
console.log(`Playerconnected!SocketId:${socket.id}`);
//加入游戏
socket.on(Constants.MSG_TYPES.JOIN_GAME,this.game.joinGame.bind(this.game,socket));
//断开游戏
socket.on('disconnect',this.game.disconnect.bind(this.game,socket));
}
//...
}
然后在game.js中添加相关逻辑。
//src/servers/core/game.js
constPlayer=require('../objects/player')
constConstants=require('../../shared/constants')
classGame{
//...
update(){
constnow=Date.now();
//现在的时间减去上次执行完毕的时间得到中间间隔的时间
constdt=(now-this.lastUpdateTime)/1000;
this.lastUpdateTime=now;
//更新玩家人物
Object.keys(this.players).map(playerID=>{
constplayer=this.players[playerID];
player.update(dt);
})
if(this.shouldSendUpdate){
//发送数据
Object.keys(this.sockets).map(playerID=>{
constsocket=this.sockets[playerID];
constplayer=this.players[playerID];
socket.emit(
Constants.MSG_TYPES.UPDATE,
//处理游戏中的对象数据发送给前端
this.createUpdate(player)
)
})
this.shouldSendUpdate=false;
}else{
this.shouldSendUpdate=true;
}
}
createUpdate(player){
//其他玩家
constotherPlayer=Object.values(this.players).filter(
p=>p!==player
);
return{
t:Date.now(),
//自己
me:player.serializeForUpdate(),
others:otherPlayer,
//子弹
bullets:this.bullets.map(bullet=>bullet.serializeForUpdate())
}
}
//玩家加入游戏
joinGame(socket,username){
this.sockets[socket.id]=socket;
//玩家位置随机生成
constx=(Math.random()*.5 .25)*Constants.MAP_SIZE;
consty=(Math.random()*.5 .25)*Constants.MAP_SIZE;
this.players[socket.id]=newPlayer({
id:socket.id,
username,
x,y,
w:Constants.PLAYER.WIDTH,
h:Constants.PLAYER.HEIGHT
})
}
disconnect(socket){
deletethis.sockets[socket.id];
deletethis.players[socket.id];
}
}
module.exports=Game;
这里我们开发了玩家的加入以及退出,还有Player对象的数据更新,以及游戏的数据发送。
现在后端服务已经有能力提供内容给前端了,接下来我们开始开发前端的界面吧。
前端界面开发上面的内容让我们开发了一个拥有基本功能的后端服务。
接下来来开发前端的相关功能吧。
接收后端发送的数据我们来看看后端发过来的数据是什么样的吧。
先在前端编写接收的方法。
//src/client/networking.js
import{processGameUpdate}from"./state";
exportconstconnect=onGameOver=>{
connectPromise.then(()=>{
//游戏更新
socket.on(Constants.MSG_TYPES.UPDATE,processGameUpdate);
socket.on('disconnect',()=>{
console.log('Disconnectedfromserver.');
})
})
}
exportconstplay=username=>{
socket.emit(Constants.MSG_TYPES.JOIN_GAME,username);
}
//src/client/state.js
exportfunctionprocessGameUpdate(update){
console.log(update);
}
//src/client/index.js
import{connect,play}from'./networking'
Promise.all([
connect()
]).then(()=>{
play('test');
}).catch(console.error)
上面的代码就可以让我们进入页面就直接加入游戏了,去页面看看效果吧。
image.png
原文链接: https://mp.weixin.qq.com/s/hoc5YXVRDDV_7jGmrO5Vfg
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved