移动 > 游戏 > 手游

WebGL着色器渲染小游戏实战

129人参与 2024-08-04 手游

项目起因

经过对 glsl 的了解,以及 shadertoy 上各种项目的洗礼,现在开发简单交互图形应该不是一个怎么困难的问题了。下面开始来对一些已有业务逻辑的项目做glsl渲染器替换开发。

起因是看到某些小游戏广告,感觉机制有趣,实现起来应该也不会很复杂,就尝试自己开发一个。

https://oscimg.oschina.net/oscnet/up-2b7391f7cf742f83a0f1edfb73334b36618.jpg

游戏十分简单,类似泡泡龙一样的从屏幕下方中间射出不同颜色大小的泡泡,泡泡上浮到顶部,相同颜色的泡泡可以合并成大一级的不同颜色泡泡。简单说就是一个上下反过来的合成大西瓜。

较特别的地方是为了表现泡泡的质感,在颜色相同的泡泡靠近时,会有水滴表面先合并的效果,这一部分就需要用到着色器渲染来实现了。

项目结构

先对逻辑分层

最上层为游戏业务逻辑game,管理游戏开始、结束状态,响应用户输入,记录游戏分数等。

其次为游戏逻辑驱动层engine,管理游戏元素,暴露可由用户控制的动作,引用渲染器控制游戏场景渲染更新。

再往下是物理引擎模块physics,管理游戏元素之间的关系,以及实现engine需要的接口。

与引擎模块并列的是渲染器模块renderer,读取从engine输入的游戏元素,渲染游戏场景。

这样分层的好处是,各个模块可以独立替换/修改;例如在glsl渲染器开发完成前,可以替换成其他的渲染器,如2d canvas渲染器,甚至使用html dom来渲染。

结构图如下:

https://oscimg.oschina.net/oscnet/up-2591a72345efa318f5c99f540e0bd538fb0.png

游戏逻辑实现

游戏业务逻辑 game

因为游戏业务比较简单,这一层只负责做这几件事:

  1. 输入html canvas元素,指定游戏渲染范围
  2. 初始化驱动层engine
  3. 监听用户操作事件touchend/click,调用engine控制射出泡泡
  4. 循环调用engineupdate更新方法,并检查超过指定高度的泡泡数量,如数量超过0则停止游戏
class game {
  constructor(canvas) {
    this.engine = new engine(canvas)
    document.addeventlistener('touchend', (e) => {
      if(!this.isend) {
        this.shoot({
          x: e.pagex,
          y: e.pagey
        }, randomlevel())
      }
    })
  }
  shoot(pos, newballlevel) {
    // 已准备好的泡泡射出去
    this.engine.shoot(pos, start_v)
    // 在初始点生成新的泡泡
    this.engine.addstillball(ball_info[newballlevel])
  }
  update() {
    this.engine.update()
    let point = 0;
    let overflowcount = 0;
    this.engine.physics.getallball().foreach(ball => {
      if(!ball.isstatic){
        point += math.pow(2, ball.level);
        if (ball.position.y > _this.scenesize.width * 1.2) {
          overflowcount++
        }
      }
    })
    if(overflowcount > 1){
      this.gameend(point);
    }
  }
  gameend(point) {
    this.isend = true
    ...
  }
}

驱动层 engine

这一层的逻辑负责管理物理引擎physics和渲染器模块renderer,并暴露交互方法供game调用。

指定了物理引擎模块需提供以下接口方法:

  1. 在指定的位置生成固定的泡泡,供用户作下一次操作时使用
  2. 把固定的泡泡按指定的方向射出

在更新方法update里,读取所有泡泡所在的位置和大小、等级颜色信息,再调用渲染器渲染泡泡。

class engine {
  constructor(canvas) {
    this.renderer = new renderer(canvas)
    this.physics = new physics()
  }
  addstillball({ pos, radius, level }) {
    this.physics.createball(pos, radius, level, true)
    this.updaterender()
  }
  shoot(pos, startv) {
    this.physics.shoot(pos, startv)
  }
  updaterender() {
    // 更新渲染器渲染信息
  }
  update() {
    // 调用渲染器更新场景渲染
    this.renderer.draw()
  }
}

物理引擎模块 physics

物理引擎使用了matter.js,没别的原因,就是因为之前有项目经验,并且自带一个渲染器,可以拿来辅助我们自己渲染的开发。

包括上一节驱动层提到的,物理引擎模块需要实现以下几个功能:

  1. 在指定的位置生成固定的泡泡,供用户作下一次操作时使用
  2. 把固定的泡泡按指定的方向射出
  3. 检查是否有相同颜色的泡泡相撞
  4. 相撞的相同颜色泡泡合并为高一级的泡泡

在这之前我们先需要初始化场景:

0.场景搭建

左、右、下的边框使用普通的矩形碰撞体实现。

顶部的半圆使用预先画好的svg图形,使用matter.jssvg类的pathtovertices方法生成碰撞体,插入到场景中。

因为泡泡都是向上漂浮的,所以置重力方向为y轴的负方向。

// class physics

constructor() {
  this.matterengine = matter.engine.create()
  // 置重力方向为y轴负方向(即为上)
  this.matterengine.world.gravity.y = -1

  // 添加三面墙
  matter.world.add(this.matterengine.world, matter.bodies.rectangle(...))
  ...
  ...

  // 添加上方圆顶
  const path = document.getelementbyid('path')
  const points = matter.svg.pathtovertices(path, 30)
  matter.world.add(this.matterengine.world, matter.bodies.fromvertices(x, y, [points], ...))

  matter.engine.run(this.matterengine)
}

1.在指定的位置生成固定的泡泡,供用户作下一次操作时使用

创建一个圆型碰撞体放到场景的指定位置,并记录为physics的内部属性供射出方法使用。

// class physics

createball(pos, radius, level, isstatic) {
  const ball = matter.bodies.circle(pos.x, pos.y, radius, {
    ...// 不同等级不同的大小通过scale区分
  })
  // 如果生成的是固定的泡泡,则记录在属性上供下次射出时使用
  if(isstatic) {
    this.stillball = ball
  }
  matter.world.add(this.matterengine.world, [ball])
}

2.把固定的泡泡按指定的方向射出

射出的方向由用户的点击位置决定,但射出的速度是固定的。

可以通过点击位置和原始位置连线的向量,作归一化后乘以初速度大小计算。

// class physics

// pos: 点击位置,用于计算射出方向
// startv: 射出初速度
shoot(pos, startv) {
  if(this.stillball) {
    // 计算点击位置与原始位置的向量,归一化(使长度为1)之后乘以初始速度大小
    let v = matter.vector.create(pos.x - this.stillball.position.x, pos.y - this.stillball.position.y) 
    v = matter.vector.normalise(v)
    v = vector.mult(v, startv)

    // 设置泡泡为可活动的,并把初速度赋予泡泡
    body.setstatic(this.stillball, false);
    body.setvelocity(this.stillball, v);
  }
}

3.检查是否有相同颜色的泡泡相撞

其实matter.js是有提供两个碰撞体碰撞时触发的collisionstart事件的,但是对于碰撞后合并生成的泡泡,即使与相同颜色的泡泡触碰,也不会触发这个事件,所以只能手动去检测两个泡泡是否碰撞。

这里使用的方法是判断两个圆形的中心距离,是否小于等于半径之和,是则判断为碰撞。

// class physics

checkcollision() {
  // 拿到活动中的泡泡碰撞体的列表
  const bodies = this.getallball()
  let targetbody, srcbody
  // 逐对泡泡碰撞体遍历
  for(let i = 0; i < bodies.length; i++) {
    const bodya = bodies[i]
    for(let j = i + 1; j < bodies.length; j++) {
      const bodyb = bodies[j]
      if(bodya.level === bodyb.level) {
        // 用距离的平方比较,避免计算开平方
        if(getdistsq(bodya.position, bodyb.position) <= 4 * bodya.circleradius * bodya.circleradius) {
          // 使用靠上的泡泡作为目标泡泡
          if(bodya.position.y < bodyb.position.y) {
            targetbody = bodya
            srcbody = bodyb
          } else {
            targetbody = bodyb
            srcbody = bodya
          }
          return {
            srcbody,
            targetbody
          }
        }
      }
    }
  }
  return false
}

4.相撞的相同颜色泡泡合并为高一级的泡泡

碰撞的两个泡泡,取y座标靠上的一个作为合并的目标,靠下的一个作为源泡泡,合并后的泡泡座标设在目标泡泡座标上。

源泡泡碰撞设为关闭,并设为固定位置;

只实现合并的功能的话,只需要把源泡泡的位置设为目标泡泡的座标就可以,但为了实现动画过渡,源泡泡的位置移动做了如下的处理:

  1. 在每个更新周期计算源泡泡和目标泡泡位置的差值,得到源泡泡需要移动的向量
  2. 移动向量的1/8,在下一个更新周期重复1、2的操作
  3. 当两个泡泡的位置差值小于一个较小的值(这里设为5)时,视为合并完成,销毁源泡泡,并更新目标泡泡的等级信息
// class physics

mergeball(srcbody, targetbody, callback) {
  const dist = math.sqrt(getdistsq(srcbody.position, targetbody.position))
  // 源泡泡位置设为固定的,且不参与碰撞
  matter.body.setstatic(srcbody, true)
  srcbody.collisionfilter.mask = mergecategory
  // 如果两个泡泡合并到距离小于5的时候, 目标泡泡升级为上一级的泡泡
  if(dist < 5) {
    // 合并后的泡泡的等级
    const newlevel = math.min(targetbody.level + 1, 8)
    const scale = ballradiusmap[newlevel] / ballraiusmap[targetbody.level]
    // 更新目标泡泡信息
    matter.body.scale(targetbody, scale, scale)
    matter.body.set(targetbody, {level: newlevel})
    matter.world.remove(this.matterengine.world, srcbody)
    callback()
    return
  }
  // 需要继续播放泡泡靠近动画
  const velovity = {
    x: targetbody.position.x - srcbody.position.x,
    y: targetbody.position.y - srcbody.position.y
  };
  // 泡泡移动速度先慢后快
  velovity.x /= dist / 8;
  velovity.y /= dist / 8;
  matter.body.translate(srcbody, matter.vector.create(velovity.x, velovity.y));
}

因为使用了自定义的方法检测泡泡碰撞,我们需要在物理引擎的beforeupdate事件上绑定检测碰撞和合并泡泡方法的调用

// class physics

constructor() {
  ...

  matter.events.on(this.matterengine, 'beforeupdate', e => {
    // 检查是否有正在合并的泡泡,没有则检测是否有相同颜色的泡泡碰撞
    if(!this.collisioninfo) {
      this.collisioninfo = this.checkcollision()
    }
    if(this.collisioninfo) {
      // 若有正在合并的泡泡,(继续)调用合并方法,在合并完成后清空属性
      this.mergeball(this.collisioninfo.srcbody, this.collisioninfo.targetbody, () => {
        this.collistioninfo = null
      })
    }
  }) 

  ...
}

渲染器模块

glsl渲染器的实现比较复杂,当前可以先使用matter.js自带的渲染器调试一下。

physics模块中,再初始化一个matter.jsrender:

class physics {
  constructor(...) {
    ...
    this.render = matter.render.create(...)
    matter.render.run(this.render)
  }
}

https://oscimg.oschina.net/oscnet/up-96213ed353f6267b437f66cad11bb00185d.jpg

开发定制渲染器

接下来该说一下渲染器的实现了。

先说一下这种像是两滴液体靠近,边缘合并的效果是怎么实现的。

https://oscimg.oschina.net/oscnet/up-394694909db196cca7a2bd98d10680bb590.gif

如果我们把眼镜脱下,或焦点放远一点,大概可以看到这样的图像:

https://oscimg.oschina.net/oscnet/up-431ce7c4656194cf4ecb4db5d7e80e8c0e2.gif

看到这里可能就有人猜到是怎样实现的了。

是的,就是利用两个边缘径向渐变亮度的圆形,在它们的渐变边缘叠加的位置,亮度的相加能达到圆形中心的程度。

然后在这个渐变边缘的图形上加一个阶跃函数滤镜(低于某个值置为0,高于则置1),就可以得出第一张图的效果。

着色器结构

因为泡泡的数量是一直变化的,而片段着色器fragmentshaderfor循环判断条件(如i < length)必须是和常量作判断,(即length必须是常量)。

所以这里把泡泡座标作为顶点座标传入顶点着色器vertexshader,初步渲染泡泡轮廓:

// 顶点着色器 vertexshader
attribute vec2 a_position;
attribute float a_pointsize;

void main() {
  gl_position = vec4(a_position, 0.0, 1.0);
  gl_pointsize = a_pointsize;
}
// 片段着色器 fragmentshader
#ifdef gl_es
precision mediump float;
#endif

void main() {
  float d = length(gl_pointcoord - vec2(0.5, 0.5));
  float c = smoothstep(0.40, 0.20, d);
  gl_fragcolor = vec4(vec3(c), 1.0);
}
// 渲染器 renderer.js
class glrenderer {
  ...
  // 更新游戏元素数据
  updatedata(posdata, sizedata) {
    ...
    this.posdata = new float32array(posdata)
    this.sizedata = new float32array(sizedata)
    ...
  }
  // 更新渲染
  draw() {
    ...
    // 每个顶点取2个数
    this.setattribute(this.program, 'a_position', this.posdata, 2, 'float')
    // 每个顶点取1个数
    this.setattribute(this.program, 'a_pointsize', this.sizedata, 1, 'float')
    ...
  }
}

渲染器的js代码中,把每个点的x,y座标合并成一个一维数组,传到着色器的a_position属性;把每个点的直径同样组成一个数组,传到着色器的a_pointsize属性。

再调用webgldrawarray(gl.points)方法画点,使每个泡泡渲染成一个顶点。

顶点默认渲染成一个方块,所以我们在片段着色器中,取顶点渲染范围的座标(内置属性)gl_pointcoord到顶点中心点(vec2(0.5, 0.5))距离画边缘亮度径向渐变的圆。

如下图,我们应该能得到每个泡泡都渲染成灯泡一样的效果:

gl.blendfunc(gl.src_alpha, gl.one)
gl.enable(gl.blend);

https://oscimg.oschina.net/oscnet/up-a8f0abc570308ba853bbd93f3c4a97dbad8.jpg

如上文所说的,我们还需要给这个图像加一个阶跃函数滤镜;但我们不能在上面的片段着色器上直接采用阶跃函数处理输出,因为它是对每个顶点独立渲染的,不会带有其他顶点在当前顶点范围内的信息,也就不会有前面说的「亮度相加」的计算可能。

一个思路是将上面着色器的渲染图像作为一个纹理,在另一套着色器上做阶跃函数处理,作最后实际输出。

对于这样的多级处理,webgl建议使用framebuffer容器,把渲染结果绘制在上面;整个完整的渲染流程如下:

使用framebuffer的方法如下:

// 创建framebuffer
var framebuffer = gl.createframebuffer()
// 创建纹理texture
var texture = gl.createtexture()
// 绑定纹理到二维纹理
gl.bindtexture(gl.texture_2d, texture)
// 设置纹理信息,注意宽度和高度需是2的次方幂,纹理像素来源为空
gl.teximage2d(
  gl.texture_2d,
  0,
  gl.rgba,
  1024,
  1024,
  0,
  gl.rgba,
  gl.unsigned_byte,
  null
)
// 设置纹理缩小滤波器
gl.texparameteri(gl.texture_2d, gl.texture_min_filter, gl.linear)
// framebuffer与纹理绑定
gl.framebuffertexture2d(gl.framebuffer, gl.color_attachment0, gl.texture_2d, texture, 0)

使用以下方法,指定framebuffer为渲染目标:

gl.bindframebuffer(gl.framebuffer, framebuffer)

framebuffer绘制完成,将自动存储到0号纹理中,供第二次的着色器渲染使用

// 场景顶点着色器 scenevertexshader
attribute vec2 a_position;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;

void main() {
  gl_position = vec4(a_position, 0.0, 1.0);
  v_texcoord = a_texcoord;
}
// 场景片段着色器 scenefragmentshader
#ifdef gl_es
precision mediump float;
#endif

varying vec2 v_texcoord;
uniform sampler2d u_scenemap;

void main() {
  vec4 mapcolor = texture2d(u_scenemap, v_texcoord);
  d = smoothstep(0.6, 0.7, mapcolor.r);
  gl_fragcolor = vec4(vec3(d), 1.0);
}

场景着色器输入3个参数,分别是:

  1. a_position: 纹理渲染的面的顶点座标,因为这里的纹理是铺满全画布,所以是画布的四个角
  2. a_textcoord: 各个顶点的纹理uv座标,因为纹理大小和渲染大小不一样(纹理大小为1024*1024,渲染大小为画布大小),所以是从(0.0, 0.0)(width / 1024, height / 1024)
  3. u_scenemap: 纹理序号,用的第一个纹理,传入0
// 渲染器 renderer.js
class renderer {
  ...
  drawscene() {
    // 把渲染目标设回画布
    gl.bindframebuffer(gl.framebuffer, null);
    // 使用渲染场景的程序
    gl.useprogram(sceneprogram);
    // 设置4个顶点座标
    this.setattribute(this.sceneprogram, "a_position", new float32array([
      -1.0,
      -1.0,

      1.0,
      -1.0,

      -1.0,
      1.0,

      -1.0,
      1.0,

      1.0,
      -1.0,

      1.0,
      1.0
    ]), 2, "float");
    // 设置顶点座标的纹理uv座标
    setattribute(sceneprogram, "a_texcoord", new float32array([
      0.0,
      0.0,

      canvas.width / mapsize,
      0.0,

      0.0,
      canvas.height / mapsize,

      0.0,
      canvas.height / mapsize,

      canvas.width / mapsize,
      0.0,

      canvas.width / mapsize,
      canvas.height / mapsize
    ]), 2, "float");
    // 设置使用0号纹理
    this.setuniform1i(this.sceneprogram, 'u_scenemap', 0);
    // 用画三角形面的方法绘制
    this.gl.drawarrays(this.gl.triangles, 0, 6);
  }
}

https://oscimg.oschina.net/oscnet/up-6eb33d0dc770ad3a5c4cf6faa66a66ebc24.jpg

不同类型的泡泡区别

在上一节中,实现了游戏里不同位置、不同大小的泡泡在画布上的绘制,也实现了泡泡之间粘合的效果,但是所有的泡泡都是一样的颜色,而且不能合并的泡泡之间也有粘合的效果,这不是我们想要的效果;

在这一节,我们把这些不同类型泡泡做出区别。

要区分各种类型的泡泡,可以在第一套着色器中只传入某个类型的泡泡信息,重复绘制出纹理供第二套场景着色器使用。但每次只绘制一个类型的泡泡会增加很多的绘制次数。

其实在上一节的场景着色器中,只使用了红色通道,而绿色、蓝色通道的值和红色是一样的:

d = smoothstep(0.6, 0.7, mapcolor.r);

其实我们可以在rgb3个通道中传入不同类型的泡泡数据(alpha通道的值若为0时,rgb通道的值与设定的不一样,所以不能使用),这样在一个绘制过程中可以绘制3个类型的泡泡;泡泡的类型共有8种,需要分3组渲染。我们在第一套着色器绘制泡泡的时候,增加传入绘制组别和泡泡等级的数据。

并在顶点着色器和片段着色器间增加一个varying类型数据,指定该泡泡使用哪一个rgb通道。

// 修改后的顶点着色器 vertexshader
uniform int group;// 绘制的组序号
attribute vec2 a_position;
attribute float a_level;// 泡泡的等级
attribute float a_pointsize;
varying vec4 v_color;// 片段着色器该使用哪个rgb通道

void main() {
  gl_position = vec4(a_position, 0.0, 1.0);
  gl_pointsize = a_pointsize;
  if(group == 0){
    if(a_level == 1.0){
      v_color = vec4(1.0, 0.0, 0.0, 1.0);// 使用r通道
    }
    if(a_level == 2.0){
      v_color = vec4(0.0, 1.0, 0.0, 1.0);// 使用g通道
    }
    if(a_level == 3.0){
      v_color = vec4(0.0, 0.0, 1.0, 1.0);// 使用b通道
    }
  }
  if(group == 1){
    if(a_level == 4.0){
      v_color = vec4(1.0, 0.0, 0.0, 1.0);
    }
    if(a_level == 5.0){
      v_color = vec4(0.0, 1.0, 0.0, 1.0);
    }
    if(a_level == 6.0){
      v_color = vec4(0.0, 0.0, 1.0, 1.0);
    }
  }
  if(group == 2){
    if(a_level == 7.0){
      v_color = vec4(1.0, 0.0, 0.0, 1.0);
    }
    if(a_level == 8.0){
      v_color = vec4(0.0, 1.0, 0.0, 1.0);
    }
    if(a_level == 9.0){
      v_color = vec4(0.0, 0.0, 1.0, 1.0);
    }
  }
}
// 修改后的片段着色器 fragmentshader
#ifdef gl_es
precision mediump float;
#endif

varying vec4 v_color;

void main(){
  float d = length(gl_pointcoord - vec2(0.5, 0.5));
  float c = smoothstep(0.40, 0.20, d);
  gl_fragcolor = v_color * c;
}

场景片段着色器分别对3个通道作阶跃函数处理(顶点着色器不变),同样传入绘制组序号,区别不同类型的泡泡颜色:

// 修改后的场景片段着色器
#ifdef gl_es
precision mediump float;
#endif

varying vec2 v_texcoord;
uniform sampler2d u_scenemap;
uniform vec2 u_resolution;
uniform int group;

void main(){
  vec4 mapcolor = texture2d(u_scenemap, v_texcoord);
  float d = 0.0;
  vec4 color = vec4(0.0);
  if(group == 0){
    if(mapcolor.r > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.r);
      color += vec4(0.86, 0.20, 0.18, 1.0) * d;
    }
    if(mapcolor.g > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.g);
      color += vec4(0.80, 0.29, 0.09, 1.0) * d;
    }
    if(mapcolor.b > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.b);
      color += vec4(0.71, 0.54, 0.00, 1.0) * d;
    }
  }
  if(group == 1){
    if(mapcolor.r > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.r);
      color += vec4(0.52, 0.60, 0.00, 1.0) * d;
    }
    if(mapcolor.g > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.g);
      color += vec4(0.16, 0.63, 0.60, 1.0) * d;
    }
    if(mapcolor.b > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.b);
      color += vec4(0.15, 0.55, 0.82, 1.0) * d;
    }
  }
  if(group == 2){
    if(mapcolor.r > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.r);
      color += vec4(0.42, 0.44, 0.77, 1.0) * d;
    }
    if(mapcolor.g > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.g);
      color += vec4(0.83, 0.21, 0.51, 1.0) * d;
    }
    if(mapcolor.b > 0.0){
      d = smoothstep(0.6, 0.7, mapcolor.b);
      color += vec4(1.0, 1.0, 1.0, 1.0) * d;
    }
  }
  gl_fragcolor = color;
}

这里使用了分多次绘制成3个纹理图像,处理后合并成最后的渲染图像,场景着色器绘制了3次,这需要在每次绘制保留上次的绘制结果;而默认的webgl绘制流程,会在每次绘制时清空图像,这需要修改这个默认流程:

// 设置webgl每次绘制时不清空图像
var gl = canvas.getcontext('webgl', {
  preservedrawingbuffer: true
});
class renderer {
  ...
  update() {
    gl.clear(gl.color_buffer_bit)// 每次绘制时手动清空图像
    this.drawpoint()// 绘制泡泡位置、大小
    this.drawscene()// 增加阶跃滤镜
  }
}

https://oscimg.oschina.net/oscnet/up-6542600ff245e68f95aab07d95c029cb76a.jpg

经过以上处理,整个游戏已基本完成,在这以上可以再修改泡泡的样式、添加分数展示等的部分。

完整项目源码可以访问: https://github.com/wenxiongid/bubble

欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(aotulabs),不定时推送文章。

(0)
打赏 微信扫一扫 微信扫一扫

您想发表意见!!点此发布评论

推荐阅读

如何从0到1制作一款 Cocos Creator 插件?超详细开发指南来了!

08-04

下载速率提升40% ,《斗罗大陆:魂师对决》是如何做到的?

08-04

Cocos Creator 3.8 LTS 今日发布!

08-04

【高手问答汇总】游戏服务器十问,《百万在线》作者倾情作答

08-04

一行Java代码实现游戏中交换装备

08-04

小游戏如何应对大流量?Shopee Shake 的大促实践

08-04

猜你喜欢

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论