IT技术互动交流平台

仿制 slither.io 第一步:先画条蛇

作者:W·Axes  来源:IT165收集  发布日期:2016-04-26 21:21:26

  最近 slither.io 貌似特别火,中午的时候,同事们都在玩,包括我自己也是玩的不亦乐乎。

  好久好久没折腾过canvas相关的我也是觉得是时候再折腾一番啦,所以就试着仿造一下吧。其实难度还是蛮大的,没有看源码,纯粹靠自己想逻辑。

  其实楼主心里也有点发虚,因为有些逻辑还是不知道怎么实现呀,毕竟算是一个蛮成熟的网络游戏了,楼主也没做过网游,所以只能写一步算一步了,不立flag,实话实说:不一定会更新下去,如果写到不会写了,就不一定写了哈~

  本文所讲内容做出来的效果step1:http://whxaxes.github.io/slither/dist/step1/

  当前项目最新效果:http://whxaxes.github.io/slither/  (如果跟上面那个效果一样,说明楼主尚未更新新的代码)

  

  如题,做这个游戏嘛,功能很多很复杂,一口气是肯定搞不完的,所以得一步一步来,第一步就是先造条蛇!!

  不像常见的那种以方格为运动单位的贪吃蛇,slither里的蛇动的动的更自由,先不说怎么动,先说一下蛇体的构成。

  

  这构造很显然易见,其实就是由一个又一个的圆构成的,可以分通ky"http://www.it165.net/qq/" target="_blank" class="keylink">qq5ubPJye3M5bXE1LKjrNLUvLC5ubPJzbeyv7XE1LKhozwvcD4KPHA+oaGhodTZy7XJ37XE0sa2r6Osyd+1xNLGtq/G5Mq1vs3Kx8nfzbe1xNLGtq+jrLWxyd/Nt7avwcujrMnfye3U2bj618XJ3823tq+jrLb4x9LJ38ntu+HW2Li019/J3823vq25/bXEtdi3vaGj0vK0y6Osyd/Nt7j6yd/J7crH09C63LbgubLNrLXEtdi3vaOsy/nS1M7SsNHBvbj2sr+31ra8z8iz6c/zs/bSu7j2u/nA4CZuYnNwOzxzdHJvbmc+QmFzZTwvc3Ryb25nPqO6PC9wPgoKPHByZSBjbGFzcz0="brush:java;">// 蛇头和蛇身的基类 class Base { constructor(options) { this.ctx = options.ctx; this.x = options.x; this.y = options.y; this.r = options.r; // 皮肤颜色 this.color_1 = `rgba(${options.color.r},${options.color.g},${options.color.b},${options.color.a || 1})`; // 描边颜色 this.color_2 = `#000`; this.vx = 0; this.vy = 0; this.tox = this.x; this.toy = this.y; // 生成元素图片镜像 this.createImage(); } /** * 生成图片镜像 */ createImage() { this.img = document.createElement('canvas'); this.img.width = this.r * 2 + 10; this.img.height = this.r * 2 + 10; this.imgctx = this.img.getContext('2d'); this.imgctx.lineWidth = 2; this.imgctx.save(); this.imgctx.beginPath(); this.imgctx.arc(this.img.width / 2, this.img.height / 2, this.r, 0, Math.PI * 2); this.imgctx.fillStyle = this.color_1; this.imgctx.strokeStyle = this.color_2; this.imgctx.stroke(); this.imgctx.fill(); this.imgctx.restore(); } /** * 给予目标点, 计算速度 * @param x * @param y */ moveTo(x, y) { this.tox = x; this.toy = y; const dis_x = this.tox - this.x; const dis_y = this.toy - this.y; const dis = Math.sqrt(dis_x * dis_x + dis_y * dis_y); this.vy = dis_y * (SPEED / dis); this.vx = dis_x * (SPEED / dis); } /** * 更新位置 */ update() { this.x += this.vx; this.y += this.vy; } /** * 渲染镜像图片 * @param nx 渲染的x位置, 可不传 * @param ny 渲染的y位置, 可不传 */ render(nx, ny) { let x = nx === undefined ? this.x : nx; let y = ny === undefined ? this.y : ny; this.ctx.drawImage( this.img, x - this.img.width / 2, y - this.img.height / 2 ); } }

  简单说明一下各个属性的意义:

  x,y  基类的坐标

  r  为基类的半径,因为这个蛇是由圆组成的,所以r就是圆的半径

  color_1、color_2  用于着色

  vx,vy  为基类的水平方向的速度,以及垂直方向的速度

      tox,toy   为基类的目标位置,简单来说就是基类会朝着这个坐标点移动。

  再说明一下几个方法:

  createImage方法:用于创建基类的镜像,虽然基类只是画个圆,但是绘制操作还是不少,所以最好还是先创建镜像,之后每次绘制的时候就只需要调用一次drawImage即可,对提升性能还是有效的

  moveTo方法:给予基类目标位置,同时根据给予的位置,计算出基类的水平速度以及垂直速度

  update方法:每次的动画循环都会调用的方法,根据基类的速度来更新其位置

  render方法:基类的绘制自身的方法,里面就只有一个绘制镜像的操作。

  基类写好了,就可以写蛇身类Body了,代码如下:  

// 蛇的身躯类
class Body extends Base {
  constructor(options) {
    super(options);

    this.aims = [];
  }

  // 身躯跟头部的运动轨迹不同, 身躯要走完当前目标后才进入下一个目标
  moveTo(x, y) {
    this.aims.push({x, y});

    if (this.tox == this.x && this.toy == this.y) {
      let naim = this.aims.shift();
      super.moveTo(naim.x, naim.y);
    }
  }

  update() {
    super.update();

    // 到达目的地设置x为目标x
    if (Math.abs(this.tox - this.x) <= SPEED) {
      this.x = this.tox;
    }

    // 到达目的地设置y为目标y
    if (Math.abs(this.toy - this.y) <= SPEED) {
      this.y = this.toy;
    }
  }
}

  因为蛇身躯的逻辑不多,只需要跟着动就行了,所以不需要重载很多方法,因为每一个蛇身都需要走蛇头走过的路,所以给蛇身加一个叫aims的堆栈数组,用于压入前者走过的路坐标,当蛇身走到栈顶的坐标时,继续获取下一个运动目标,参见重载后的moveTo方法。然后在每次动画循环中,如果蛇身位置与目标位置差值低于SPEED(蛇速)这个常量的时候,就直接赋值目标位置给蛇身的位置。好在moveTo方法中判断蛇身已经到了当前目标,并且获取下一个目标。

  

  再接下来就是蛇头Header类,蛇头的逻辑会复杂一些,毕竟是运动的首脑,先上代码:

// 蛇头类
class Header extends Base {
  constructor(options) {
    super(options);

    this.vx = SPEED;
    this.aims = [];
    this.angle = BASE_ANGLE + Math.PI / 2;
    this.toa = this.angle;
  }

  /**
   * 添加画眼睛的功能
   */
  createImage() {
    super.createImage();
    const self = this;
    const eye_r = this.r * 3 / 7;

    // 画左眼
    drawEye(
      this.img.width / 2 + this.r - eye_r,
      this.img.height / 2 - this.r + eye_r
    );

    // 画右眼
    drawEye(
      this.img.width / 2 + this.r - eye_r,
      this.img.height / 2 + this.r - eye_r
    );

    function drawEye(eye_x, eye_y) {
      self.imgctx.beginPath();
      self.imgctx.fillStyle = '#fff';
      self.imgctx.strokeStyle = self.color_2;
      self.imgctx.arc(eye_x, eye_y, eye_r, 0, Math.PI * 2);
      self.imgctx.fill();
      self.imgctx.stroke();

      self.imgctx.beginPath();
      self.imgctx.fillStyle = '#000';
      self.imgctx.arc(eye_x + eye_r / 2, eye_y, 3, 0, Math.PI * 2);
      self.imgctx.fill();
    }
  }

  /**
   * 这里不进行真正的移动, 而是计算移动位置与目前位置的补间位置, 目的是为了让蛇的转弯不那么突兀
   */
  moveTo(x, y) {
    if (!this.aims.length)
      return this.aims.push({x, y});

    const olderAim = this.aims[this.aims.length - 1];
    const dis_x = x - olderAim.x;
    const dis_y = y - olderAim.y;
    const dis = Math.sqrt(dis_x * dis_x + dis_y * dis_y);

    if (dis > 50) {
      const part = ~~(dis / 50);
      for (let i = 1; i <= part; i++) {
        // 记录的目标点不超过20个
        if (this.aims.length > 20)
          this.aims.shift();

        this.aims.push({
          x: olderAim.x + dis_x * i / part,
          y: olderAim.y + dis_y * i / part
        });
      }
    } else {
      this.aims[this.aims.length - 1] = {x, y};
    }
  }

  /**
   * 增加蛇头的逐帧逻辑
   */
  update() {
    const time = new Date();

    // 每隔一段时间获取一次目标位置集合中的数据, 进行移动
    if ((!this.time || time - this.time > 50) && this.aims.length) {
      const aim = this.aims.shift();

      // 调用父类的moveTo, 让蛇头朝目标移动
      super.moveTo(aim.x, aim.y);

      // 根据新的目标位置, 更新toa
      this.turnTo();

      this.time = time;
    }

    // 让蛇转头
    this.turnAround();

    super.update();
  }

  /**
   * 根据蛇的目的地, 调整蛇头的目标角度
   */
  turnTo() {
    const olda = Math.abs(this.toa % (Math.PI * 2));// 老的目标角度, 但是是小于360度的, 因为每次计算出来的目标角度也是0 - 360度
    let rounds = ~~(this.toa / (Math.PI * 2));      // 转了多少圈
    this.toa = Math.atan(this.vy / this.vx) + (this.vx < 0 ? Math.PI : 0) + Math.PI / 2; // 目标角度

    if (olda >= Math.PI * 3 / 2 && this.toa <= Math.PI / 2) {
      // 角度从第一象限左划至第四象限, 增加圈数
      rounds++;
    } else if (olda <= Math.PI / 2 && this.toa >= Math.PI * 3 / 2) {
      // 角度从第四象限划至第一象限, 减少圈数
      rounds--;
    }

    // 计算真实要转到的角度
    this.toa += rounds * Math.PI * 2;
  }

  /**
   * 让蛇头转角更加平滑, 渐增转头
   */
  turnAround() {
    const angle_dis = this.toa - this.angle;

    if(angle_dis) {
      this.angle += angle_dis * 0.2;

      // 当转到目标角度, 重置蛇头角度
      if (Math.abs(angle_dis) <= 0.01) {
        this.toa = this.angle = BASE_ANGLE + this.toa % (Math.PI * 2)
      }
    }
  }

  /**
   * 根据角度来绘制不同方向的蛇头
   */
  render() {
    //绘制补间点
    const self = this;
    this.aims.forEach(function(aim) {
      self.ctx.fillRect(aim.x - 1, aim.y - 1, 2, 2);
    });

    // 要旋转至相应角度
    this.ctx.save();
    this.ctx.translate(this.x, this.y);
    this.ctx.rotate(this.angle - BASE_ANGLE - Math.PI / 2);
    super.render(0, 0);
    this.ctx.restore();
  }
}

  蛇头的属性又增加了一些,首先是aims,跟蛇身一样的意义,本来是可以写在基类上的,忘了,之后再改。初次之外还有一个angle,就是蛇头的角度,还有toa,跟tox,toy差不多的意思,代表蛇头转头时要转到的目标角度。BASE_ANGLE常量是为了保证蛇头的角度一直都是正数,我这里定的Math.PI * 200,因为负数在转头计算的时候有点麻烦,所以干脆就让它永远不为负数即可。

  在蛇头的创建镜像方法中,增加了绘制眼睛的逻辑,因为蛇头,所以必须要有眼睛啦。

  蛇头的moveTo方法并不进行真正的移动,计算位置的补间,并且将补间坐标以及目标坐标都压入aims中,这样做的目的是为了让蛇转弯不会那么突兀,更加平滑,也就是当我鼠标移动时,蛇不会立马转头,而是会有个过渡的转头过程。

  在蛇头的update方法中,进行aims目标点的读取,并且调用父类的moveTo方法进行目标点设置,同时计算蛇要转头的角度。

  后面的两个方法,都是为了让蛇转头的时候更平滑,不会产生转头卡顿的现象而写的,

  

  蛇头、蛇身都写完了,是时候把两者组合起来了,所以再创建一个蛇类Snake

/**
 * 蛇类
 */
export default class Snake {
  constructor(options) {
    this.length = options.length;

    // 创建脑袋
    this.header = new Header(options);

    // 创建身躯
    this.bodys = [];
    let body_dis = options.r * 0.6;
    for (let i = 0; i < this.length; i++) {
      options.x -= body_dis;
      options.r -= 0.2;

      this.bodys.push(new Body(options));
    }
  }

  /**
   * 蛇的移动就是头部的移动
   */
  moveTo(x, y) {
    this.header.moveTo(x, y);
  }

  render() {
    // 蛇的身躯沿着蛇头的运动轨迹运动
    for (let i = this.bodys.length - 1; i >= 0; i--) {
      let body = this.bodys[i];
      let front = this.bodys[i - 1] || this.header;

      body.moveTo(front.x, front.y);

      body.update();
      body.render();
    }

    this.header.update();
    this.header.render();
  }
}

  蛇类的逻辑就简单多了,就只需要把头和身子组合起来即可,在构造函数中创建一个蛇头,然后在创建多个蛇身,因为一个蛇身就是一个圆,要想蛇有长度,就得多个蛇身组合起来,蛇的moveTo方法,其实就是蛇头的moveTo方法,而render方法里,循环所有蛇身,并且把前者的位置赋给后者作为目标点,从而让蛇身按照蛇头走过的路进行移动。

  至此,整个蛇类都写完了,再写一下动画循环逻辑即可:

import Snake from './snake';
import Stats from './third/stats.min';

const sprites = [];
const RAF = window.requestAnimationFrame
  || window.webkitRequestAnimationFrame
  || window.mozRequestAnimationFrame
  || window.oRequestAnimationFrame
  || window.msRequestAnimationFrame
  || function(callback) {
    window.setTimeout(callback, 1000 / 60)
  };

const canvas = document.getElementById('cas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const stats = new Stats();
stats.setMode(0);
stats.domElement.style.position = 'absolute';
stats.domElement.style.right = '0px';
stats.domElement.style.top = '0px';
document.body.appendChild( stats.domElement );

function init() {
  const snake = new Snake({
    ctx,
    x: canvas.width / 2,
    y: canvas.height / 2,
    r: 25,
    length: 40,
    color: {
      r: 255,
      g: 255,
      b: 255,
      a: 1
    }
  });

  sprites.push(snake);

  window.onmousemove = function(e) {
    e = e || window.event;

    snake.moveTo(
      e.clientX,
      e.clientY
    );
  };

  animate();
}

let time = new Date();
let timeout = 0;
function animate() {
  const ntime = new Date();

  if(ntime - time > timeout) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    sprites.forEach(function(sprite) {
      sprite.render();
    });

    time = ntime;
  }

  stats.update();

  RAF(animate);
}

init();

  这一块的代码就不解释了,应该很容易看懂。里面有个叫timeout的参数,用于降低游戏fps,用来debug的。

  这个项目目前还是单机的,所以我放在了github,之后加上网络功能的话,估计就无法预览了。

  github地址:https://github.com/whxaxes/slither

  

Tag标签: 第一  
  • 专题推荐

About IT165 - 广告服务 - 隐私声明 - 版权申明 - 免责条款 - 网站地图 - 网友投稿 - 联系方式
本站内容来自于互联网,仅供用于网络技术学习,学习中请遵循相关法律法规