Skip to content

kong2dog/metaverse

Repository files navigation

在十月份的一天,我突发奇想,想利用已有的知识在24小时内开发出一款3D多人即时射击游戏,也就是类似CS的那种游戏。

话不多说,开干,首先要实现这样一套系统,要用到的技术栈包含以下几个:

  • WebSocket
  • Babylonjs
  • WebGL
  • Nodejs
  • Nginx 最后设计出的架构图如下所示:

图片

首先,websocket方面,我考察了socket.io, mqtt 和 ws这三个包,他们各自的优缺点如下:

  • Socket.io
    • 优点:
      • 开箱即用,封装的比较好
      • 前后端支持比较好
      • 比较成熟
    • 缺点:
      • 最大的缺点就是无法保持时序性,也就是socket.io发送的消息是没办法保证前后顺序的,这一点也让我直接pass掉了socket.io
      • 性能相对较差
  • Mqtt
    • 优点:
      • 性能卓越
      • 体积小
    • 缺点:
      • mqtt协议是为工作在低带宽,不可靠网络的远程传感器和控制设备通讯而设计的协议,而Socket则是为了浏览器与服务器全双工通信的一种协议。因为是基于udp的,所以无法保证到达性,不可靠,也让我直接pass掉了mqtt
  • ws
    • 优点:
      • 时序有保证,性能比较好
      • 包体积小,容易拓展
      • 可靠性高
    • 缺点:
      • 因为比较原始,所以很多方法要自己写,像存活检查和广播的方法都需要自己去扩展
      • 要求对socket编程有一定的了解。 最后在朋友的推荐下,我选择了ws这个包,这个包只对socket进行了最基本的封装,可以说是非常简洁,同时性能也有保证,此处这个包也很方便去拓展和改造,也是最适合我的选择。

其次,在threejs和babylonjs之间,我需要选出webgl的引擎,因为公司的项目都是采用的threejs,所以我对threejs算是比较熟悉,threejs的优点很多,上手也非常快,不过我最后还是选择了babylonjs作为开发引擎,这样的选择并不是说threejs不好,而是因为一方面我想学一下babylonjs,了解一下其与threejs的区别,另一方面babylonjs的框架本身就是游戏框架,对于游戏开发的支持度较高。

开发引擎选好了,接着我就开始了场景的开发和模型的构建。

图片

这里我用的是minecraft风格的模型,手里拿的是一把AK,同时我也生成了行走和开枪的动画。

代码如下:


   
    
    const faceColors = [];
    // 头部
    this.head = new BABYLON.MeshBuilder.CreateBox("head", {width: 1, height: 0.8, faceColors: faceColors}, this.Scene); 
    this.head.material = new BABYLON.StandardMaterial("headm", this.Scene);
    this.head.parent = this.player;
    const indices = this.head.getIndices();
    const positions = this.head.getVerticesData(BABYLON.VertexBuffer.PositionKind);
    let colors = this.head.getVerticesData(BABYLON.VertexBuffer.ColorKind);        
    const nbVertices = positions.length / 3;
    if (!colors) {
        colors = new Array(4 * nbVertices);
        colors = colors.fill(1);
    }
    let vertex;
    for (var i = 0; i < 6; i++) {
        vertex = indices[3 * 0 + i];
        colors[4 * vertex] = 1;
        colors[4 * vertex + 1] = 1;
        colors[4 * vertex + 2] = 0;
        colors[4 * vertex + 3] = 1;
    }
    this.head.setVerticesData(BABYLON.VertexBuffer.ColorKind, colors);
    this.head.locallyTranslate(new BABYLON.Vector3(0, 0.4, 0));;

    // 头发
    const hair = new BABYLON.MeshBuilder.CreateBox("hair", {width: 1, height: 0.2}, this.Scene);
    hair.setPivotMatrix(new BABYLON.Matrix.Translation(0, -0.1, 0))
    hair.parent = this.head;
    hair.locallyTranslate(new BABYLON.Vector3(0, 0.5, 0));;
    hair.material = new BABYLON.StandardMaterial("hairm", this.Scene);
    hair.material.diffuseColor = new BABYLON.Color3(0.61, 0.23, 0.29);
    
    // 身体
    this.body = new BABYLON.MeshBuilder.CreateBox("body", {width:1.2, height: 1.2, depth: 0.5}, this.Scene);
    this.body.parent = this.player;
    this.body.material = new BABYLON.StandardMaterial("bodym", this.Scene);
    this.body.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.body.locallyTranslate(new BABYLON.Vector3(0, -0.6, 0));
    
    // 屁股
    const but = new BABYLON.MeshBuilder.CreateBox("but", {width:1.25, height: 0.4, depth: 0.55}, this.Scene);
    but.parent = this.body;
    but.material = new BABYLON.StandardMaterial("butm", this.Scene);
    but.material.diffuseColor = new  BABYLON.Color3(0.1, 0.1, 0.1);
    but.locallyTranslate(new BABYLON.Vector3(0, -0.8, 0));
    
    // 左上臂
    this.leftarm = new BABYLON.MeshBuilder.CreateBox("leftupperarm", {width:0.4, height: 0.8, depth: 0.4}, this.Scene);
    this.leftarm.material = new BABYLON.StandardMaterial("leftupperarmm", this.Scene);
    this.leftarm.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.leftarm.parent = this.player;
    this.leftarm.setPivotMatrix(new BABYLON.Matrix.Translation(0, -0.4, 0))
    this.leftarm.locallyTranslate(new BABYLON.Vector3(-0.9, -0.4, 0));
    
    // 左肘
    this.leftelbow = new BABYLON.MeshBuilder.CreateBox("leftelbow", {width:0.4, height: 0.2, depth: 0.4}, this.Scene);
    this.leftelbow.material = new BABYLON.StandardMaterial("leftelbowm", this.Scene);
    this.leftelbow.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.leftelbow.parent = this.leftarm;
    this.leftelbow.locallyTranslate(new BABYLON.Vector3(0, -0.5, 0));
    
    // 左下臂
    this.leftlowerarm = new BABYLON.MeshBuilder.CreateBox("leftlowerarm", {width:0.4, height: 0.8, depth: 0.4}, this.Scene);
    this.leftlowerarm.material = new BABYLON.StandardMaterial("leftlowerarmm", this.Scene);
    this.leftlowerarm.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.leftlowerarm.parent = this.leftarm;
    this.leftlowerarm.setPivotMatrix(new BABYLON.Matrix.Translation(0, -0.4, 0))
    this.leftlowerarm.locallyTranslate(new BABYLON.Vector3(0, -0.8, 0));
    
    // 左腕
    const leftwaist = new BABYLON.MeshBuilder.CreateBox("leftwaist", {width:0.44, height: 0.1, depth: 0.44}, this.Scene);
    leftwaist.material = new BABYLON.StandardMaterial("leftwaistm", this.Scene);
    leftwaist.material.diffuseColor = new  BABYLON.Color3(1, 1, 1);
    leftwaist.parent = this.leftlowerarm;
    leftwaist.locallyTranslate(new BABYLON.Vector3(0, -0.4, 0));
    
    // 左手
    const lefthand = new BABYLON.MeshBuilder.CreateBox("lefthand", {width:0.4, height: 0.2, depth: 0.4}, this.Scene);
    lefthand.material = new BABYLON.StandardMaterial("lefthandm", this.Scene);
    lefthand.material.diffuseColor = new BABYLON.Color3(0.78, 0.27, 0.39);
    lefthand.parent = this.leftlowerarm;
    lefthand.locallyTranslate(new BABYLON.Vector3(0, -0.55, 0));
    
    // 右上臂
    this.rihgtupperarm = new BABYLON.MeshBuilder.CreateBox("rihgtupperarm", {width:0.4, height: 0.8, depth: 0.4}, this.Scene);
    this.rihgtupperarm.material = new BABYLON.StandardMaterial("rihgtupperarmm", this.Scene);
    this.rihgtupperarm.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.rihgtupperarm.parent = this.player;
    this.rihgtupperarm.setPivotMatrix(new BABYLON.Matrix.Translation(0, -0.4, 0))
    this.rihgtupperarm.locallyTranslate(new BABYLON.Vector3(0.9, -0.4, 0));

    // 右肘
    const rihgtelbow = new BABYLON.MeshBuilder.CreateBox("rihgtelbow", {width:0.4, height: 0.2, depth: 0.4}, this.Scene);
    rihgtelbow.material = new BABYLON.StandardMaterial("rihgtelbowm", this.Scene);
    rihgtelbow.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    rihgtelbow.parent = this.rihgtupperarm;
    rihgtelbow.locallyTranslate(new BABYLON.Vector3(0, -0.5, 0));
     
    // 右下臂
    this.rihgtlowerarm = new BABYLON.MeshBuilder.CreateBox("rihgtlowerarm", {width:0.4, height: 0.8, depth: 0.4}, this.Scene);
    this.rihgtlowerarm.material = new BABYLON.StandardMaterial("rihgtlowerarmm", this.Scene);
    this.rihgtlowerarm.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.rihgtlowerarm.parent = this.rihgtupperarm;
    this.rihgtlowerarm.setPivotMatrix(new BABYLON.Matrix.Translation(0, -0.4, 0))
    this.rihgtlowerarm.locallyTranslate(new BABYLON.Vector3(0, -0.8, 0));
    
    // 右腕
    const rihgtwaist = new BABYLON.MeshBuilder.CreateBox("rihgtwaist", {width:0.44, height: 0.1, depth: 0.44}, this.Scene);
    rihgtwaist.material = new BABYLON.StandardMaterial("rihgtwaistm", this.Scene);
    rihgtwaist.material.diffuseColor = new  BABYLON.Color3(1, 1, 1);
    rihgtwaist.parent = this.rihgtlowerarm;
    rihgtwaist.locallyTranslate(new BABYLON.Vector3(0, -0.4, 0));
    
    // 右手
    const rihgthand = new BABYLON.MeshBuilder.CreateBox("rihgthand", {width:0.4, height: 0.2, depth: 0.4}, this.Scene);
    rihgthand.material = new BABYLON.StandardMaterial("rihgthandm", this.Scene);
    rihgthand.material.diffuseColor = new BABYLON.Color3(0.78, 0.27, 0.39);
    rihgthand.parent = this.rihgtlowerarm;
    rihgthand.locallyTranslate(new BABYLON.Vector3(0, -0.55, 0));
    
    // 左大腿
    this.leftleg = new BABYLON.MeshBuilder.CreateBox("leftupperleg", {width:0.5, height: 0.8, depth: 0.5}, this.Scene);
    this.leftleg.material = new BABYLON.StandardMaterial("leftupperlegm", this.Scene);
    this.leftleg.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.leftleg.parent = this.player;
    this.leftleg.setPivotMatrix(new BABYLON.Matrix.Translation(0, -0.4, 0))
    this.leftleg.locallyTranslate(new BABYLON.Vector3(-0.26, -2, 0));
    
    // 左膝盖
    const leftkneel = new BABYLON.MeshBuilder.CreateBox("leftkneel", {width:0.5, height: 0.2, depth: 0.5}, this.Scene);
    leftkneel.material = new BABYLON.StandardMaterial("leftkneelm", this.Scene);
    leftkneel.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    leftkneel.parent = this.leftleg;
    leftkneel.locallyTranslate(new BABYLON.Vector3(0, -0.5, 0));
    
    // 左小腿
    const leftlowerleg = new BABYLON.MeshBuilder.CreateBox("leftlowerleg", {width:0.5, height: 0.6, depth: 0.5}, this.Scene);
    leftlowerleg.material = new BABYLON.StandardMaterial("leftlowerlegm", this.Scene);
    leftlowerleg.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    leftlowerleg.parent = this.leftleg;
    leftlowerleg.locallyTranslate(new BABYLON.Vector3(0, -0.9, 0));
    
    // 左脚
    const leftfoot = new BABYLON.MeshBuilder.CreateBox("leftfoot", {width:0.5, height: 0.4, depth: 0.5}, this.Scene);
    leftfoot.material = new BABYLON.StandardMaterial("leftfootm", this.Scene);
    leftfoot.material.diffuseColor = new  BABYLON.Color3(0.1, 0.1, 0.1);
    leftfoot.parent = this.leftleg;
    leftfoot.locallyTranslate(new BABYLON.Vector3(0, -1.4, 0));
    
    // 右大腿
    this.rightleg = new BABYLON.MeshBuilder.CreateBox("rightupperleg", {width:0.5, height: 0.8, depth: 0.5}, this.Scene);
    this.rightleg.material = new BABYLON.StandardMaterial("rightupperlegm", this.Scene);
    this.rightleg.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    this.rightleg.parent = this.player;
    this.rightleg.locallyTranslate(new BABYLON.Vector3(0.26, -2, 0));
    
    // 右膝盖
    const rightkneel = new BABYLON.MeshBuilder.CreateBox("rightkneel", {width:0.5, height: 0.2, depth: 0.5}, this.Scene);
    rightkneel.material = new BABYLON.StandardMaterial("rightkneelm", this.Scene);
    rightkneel.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    rightkneel.parent = this.rightleg;
    rightkneel.locallyTranslate(new BABYLON.Vector3(0, -0.5, 0));

    // 右小腿
    const rightlowerleg = new BABYLON.MeshBuilder.CreateBox("rightlowerleg", {width:0.5, height: 0.6, depth: 0.5}, this.Scene);
    rightlowerleg.material = new BABYLON.StandardMaterial("rightlowerlegm", this.Scene);
    rightlowerleg.material.diffuseColor = new  BABYLON.Color3(0.2, 0.2, 0.2);
    rightlowerleg.parent = this.rightleg;
    rightlowerleg.locallyTranslate(new BABYLON.Vector3(0, -0.9, 0));
    
    // 右脚
    const rightfoot = new BABYLON.MeshBuilder.CreateBox("rightfoot", {width:0.5, height: 0.4, depth: 0.5}, this.Scene);
    rightfoot.material = new BABYLON.StandardMaterial("rightfootm", this.Scene);
    rightfoot.material.diffuseColor = new  BABYLON.Color3(0.1, 0.1, 0.1);
    rightfoot.parent = this.rightleg;
    rightfoot.locallyTranslate(new BABYLON.Vector3(0, -1.4, 0));

这里与threejs不同的是,每次创建几何体都要将scene的实例传入,而不是生成后添加到scene中,另外还有一点不同的是babylonjs每个几何体的锚点位置都在模型的中央,所以在计算相对位置的时候要把模型的高度宽度也要考虑进去。但是babylonjs比较方便的一点是可以设置每个面的材质,甚至每个顶点的材质,比如这个人物的脸我就单独设置成了黄色,而头部其他的面则是白色。 在生成了人物之就要开始控制他的前后左右和开枪的动作,代码:

// 行走
run() {
    const run = new BABYLON.AnimationGroup("run");
    const frameRate = 5;

    const leftanime = new BABYLON.Animation("xSlide", "rotation.x", frameRate, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
    const keyFrames = [];

    keyFrames.push({
        frame: 0,
        value: -Math.PI / 4,
    });

    keyFrames.push({
        frame: frameRate,
        value: Math.PI / 4,
    });

    keyFrames.push({
        frame: 2 * frameRate,
        value: -Math.PI / 4,
    });

    leftanime.setKeys(keyFrames);

    const rightanime = new BABYLON.Animation("xSlide", "rotation.x", frameRate, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
    const rightkeyFrames = [];

    rightkeyFrames.push({
        frame: 0,
        value: Math.PI / 4,
    });

    rightkeyFrames.push({
        frame: frameRate,
        value: -Math.PI / 4,
    });

    rightkeyFrames.push({
        frame: 2 * frameRate,
        value: Math.PI / 4,
    });

    rightanime.setKeys(rightkeyFrames);

    run.addTargetedAnimation(leftanime, this.leftleg);
    run.addTargetedAnimation(rightanime, this.rightleg);
    run.normalize(0, 2 * frameRate);
    run.play(true);
  }
 // 开枪
 animate() {
    const start = this._initialRotation.clone();
    const end = start.clone();
    end.x += Math.PI/100;

    // Create the Animation object
    const display = new BABYLON.Animation(
        "fire",
        "rotation",
        60,
        BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
        BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT);

    // Animations keys
    const keys = [{ 
        frame: 0,
        value: start
    },{
        frame: 10,
        value: end
    },{
        frame: 100,
        value: start
    }];

    // Add these keys to the animation
    display.setKeys(keys);

    // Link the animation to the mesh
    this.mesh.animations.push(display);

    this.scene.Scene.beginAnimation(this.mesh, 0, 100, false, 10, function() {

    });
  }

接下来是websocket的编程,这里采用指令式的方式编程,涉及的指今一共有以下几种:

  • setId 设置用户id,目前只支持英文和数字
  • disconnect 断线或者下线
  • request init game 初始化游戏
  • update position 更新玩家位置
  • hit player 击中玩家
  • player fired shot 开枪 后端需要将对应的指令发送到对应的client,同时前端在收到指令后进行相应的操作。因为ws这个npm包没有广播的功能,所以这块的逻辑要自己来实现,具体的实现代码如下:
wss.clients.forEach((client) => {
  if (client !== wsclient && client.readyState === WebSocket.OPEN) {
    client.send(JSON.stringify({cmd: 'start render', player: player}));
  }
});

需要遍历所有连接到后端的client,再找出不是发送方的client,将指令发送过去。除此之外,我还通过setInterval的方法实现了socket的心跳检测。

const interval = setInterval(()=> {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      onClientDisconnect(ws)
      return;
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 3000);

然后实现运动更新的功能,这里需要通过控制摄像机的运动来模拟第一人称视角的运动,再将摄像机的运动后的位置和变换信息实时传给后端,后端再广播到其他的客户端,从而让用户的运动能够同步到别的客户端上。

前端代码实现:

updatePosition() {
    const xOffset = Math.abs(this.lastPosition.x - this.scene.camera.position.x);
    const yOffset = Math.abs(this.lastPosition.y - this.scene.camera.position.y);
    const zOffset = Math.abs(this.lastPosition.z - this.scene.camera.position.z);
    
    const xRotOffset = Math.abs(this.lastRotation.x - this.scene.camera.rotation.x);
    const yRotOffset = Math.abs(this.lastRotation.y - this.scene.camera.rotation.y);
    const zRotOffset = Math.abs(this.lastRotation.z - this.scene.camera.rotation.z);
    
    const posOffset = xOffset + yOffset + zOffset;
    const rotOffset = yRotOffset + xRotOffset + zRotOffset;

    if(posOffset > 0.1 || rotOffset > 0.01){ 
        this.submitMovement();
    } 
  }

  submitMovement() {
    this.scene.controller.sendLocalPlayerMovement(this.scene.camera.position, this.scene.camera.rotation);
    this.lastPosition = new BABYLON.Vector3(this.scene.camera.position.x - 0.3, this.scene.camera.position.y -0.5, this.scene.camera.position.z);
    this.lastRotation = new BABYLON.Vector3(this.scene.camera.rotation.x , this.scene.camera.rotation.y , this.scene.camera.rotation.z);
  }
  .....
  
 sendLocalPlayerMovement(pos, rot) {
        const position = { x: pos.x, y : pos.y , z : pos.z}; 
        const rotation = { x: rot.x, y : rot.y , z : rot.z};
        //更新位置信息到后端
        this.client.send(JSON.stringify({
            cmd: 'update position',
            pos: position,
            rot: rotation
        }))
    }

这里后端收到用户发送过来的位置信息后,会将位置信息广播给其他端:

function onUpdatePosition(wsclient, data) {
  const movedPlayer = playerById(wsclient.id);
  // 用户未找到
  if (!movedPlayer) {
    util.log("Player not found (onUpdatePosition): " + wsclient.id);
    return;
  } 
  // 更新服务器存储的用户的位置信息
  movedPlayer.setXYZ(data.pos.x, data.pos.y, data.pos.z); 
  movedPlayer.setRotXYZ(data.rot.x, data.rot.y, data.rot.z);      
  // 将新的用户位置广播给其他端
  wss.clients.forEach((client) => {
    if (client !== wsclient && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify({cmd: 'move player', id: wsclient.id, rot : data.rot, pos : data.pos}));
    }
  });
}

其他端收到这些会更新相应的用户的位置信息;

  
  onMovePlayer(data) {
      this.movePlayer(data.id, data.pos, data.rot)
  }

  movePlayer(id, pos, rot) {
      const player = this._findPlayer(id);
      if(!player) return;
      player.move(pos, rot)
      player.player.setXYZ(pos.x, pos.y, pos.z);
      player.player.setRotXYZ(rot.x, rot.y, rot.z);
  }

这样就实现了位置信息的实时传输。 射击也是通过类似的方式来实现,这里要用到babylonjs里的一个检测碰撞的技巧,会通过pick函数选中屏幕正中的(也就是枪口指向的)Mesh的名字,如果是敌人的名字,那就给相应的敌人扣血,具体的信息同步的方式和同步位置的方式一致。

const pickResult = this.scene.Scene.pick(width/2, height/2, null, false, this.scene.camera);
  if(pickResult.pickedMesh){
    for(let i = 0; i < this.scene.store.state.remotePlayers.length; i++) {
      console.log(this.scene.store.state.remotePlayers[i].player)
      if(pickResult.pickedMesh.name === this.scene.store.state.remotePlayers[i].player._id){
        this.scene.controller.hitPlayer(this.scene.store.state.remotePlayers[i].player)
      }
    }
  }

到此,整个多人在线的3d实时射击游戏就基本成型了,包含了移动,射击的基本要素,因为是个人的兴趣项目,时间上不充裕,而且我并不是游戏开发相关专业的,对游戏开发并不是很熟悉,所以并没有加其他一些换枪,爆头动画等额外的功能,不过万变不离其宗,只要掌握了方法,增加这些功能来丰富细节也不是难事。

最后我也通过这个小小的个人项目,总结出了babylonjs和threejs的几个优缺点:

首先与threejs不同的是,babylonjs每次创建几何体都要将场景实例传入,而不是创建好了之后再加到场景中,这一点有点反人类,另外babylonjs每个几何体的锚点位置在模型中央,而且要创建了之后才能指定上级,所以在计算相对位置的时候也要比threejs要麻烦一点。

不过babylonjs的定制性非常高,可以设置每个面的材质,比如在这个游戏里我就把人的脸设置成黄色,头部其他面是白色。另外babylonjs相比threejs要成熟的多,很多库都内置的有,比如物理引擎,音乐处理,GUI界面全都内置,能做的事情也更多。

从渲染上来看,babylonjs的渲染要更偏写实,而threejs的渲染的色彩饱和度更高,更明艳。然而babylonjs的调试不如threejs方便,很多helper都不提供,甚至连坐标轴的绘制都要自己手动实现,辅助工具欠缺,对于向量的计算也不如threejs直观。而且babylonjs的使者人数相对threejs要少,在遇到问题时解决起来比较复杂。

总体而言,babylonjs的深度很深,对有研究专研和想深入渲染引擎的同学来说很不错,但threejs更简洁明了,很简单易用,更好上手,用的人也更多。

以上就是24小时内实现一整套的多人在线的3d射击游戏的全过程,希望能与各位一起交流分享,也希望各位能够喜欢这款游戏。

代码已经开源,感兴趣的朋友可以在线体验一下,也可以下载看看。

在线体验地址: http://cs.hiotk.com

源码地址:https://github.com/kong2dog/metaverse

后端启动命令: node ./server.js

前端启动命令: npm run start

需要将metaverse/src/controller/controller.js 中的websocket的ip改为自己的ip

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published