元旦没事,大家都在干什么,canvas搓个贪吃蛇

900

写在开始

工作这么些年,一直以来写了太多的常规业务,大多数不是后台系统就是小程序,然后就是H5啥的,最近公司在使用各种厂家的地图,做相关地图的开发,但是也都是跟常规业务有关的东西。在大约半年前有点想写点游戏的相关的东西,有时候也在关注这方面的东西,刚好元旦放假,就自己搓了一个贪吃蛇玩玩,顺便写一篇文章(之前都没写过,主要自己一直不怎么想写东西)。

什么都不说,先上效果图

ps:UI图我是网上找的,自己又ps加工了一下,要是哪位设计大大说我盗图,这里说明一下不好意思哈,我不作商业用途,就是纯写文章用。

ex1.png

ex2.png

微信截图_20220103173409.png

在看代码之前,先要了解下这个游戏的基本情况

说明

贪吃蛇是一款比较经典的游戏,小时候就玩过很多次,当然现在有很多复杂的贪吃蛇各种效果都有,我这里呢就是实现基本的东西和简单的效果。

关系

classDiagram
Canvas <|-- Scene
Canvas <|-- Events
Scene <|-- SnakeGame
Scene <|-- Food
Scene <|-- Snake
Scene <|-- BackGround
Canvas:canvas类
class Canvas{
drawText() 
drawImage()
on()
remove()
get layer()
get events()
...
}
Scene:场景类
class Scene{
    add()
    join()
    loadSource()
    update()
    clear()
    ...
}
SnakeGame:游戏类
class SnakeGame{
initGame()
start()
_registerAction()
_loadImgSource()
...
}
class Events{
    - Map _listeners
    on()
    remove()
    findListenerByKey()
    事件系统作为canvas的成员...
}
class Food{
setScene()
draw()
collideCheck()
}
class Snake{
 setScene()
draw()
collideCheck()
}
class BackGround{
 setScene()
draw()
collideCheck()
}

类简介

  • canvas类提供了基础的绘图能力
  • events类提供事件注册和消息相关
  • Scene类注册场景,继承canvas基础能力,同时提供了资源加载、场景重绘、添加成员、进入场景等能力
  • SnakeGame类游戏主进程,继承了Scene,包含了游戏相关的事件注册、行为注册以及各种游戏初始化相关的执行逻辑
  • Food类、BackGround类、snake类包含 draw绘制,碰撞检测等实现了SnakeService.common接口

好了,上面啰嗦了半天,是时候代码出场了

ps:要是大佬又闲情雅致看到,有写的不好之处,请帮忙指正,谢谢了!

基础的canvas

class Canvas {
  constructor(config?: CanvasTypes.constructorParam) {
    /**
    这里太长了是巴拉巴拉的判断和createElement('canvas'),方便阅读先去掉
    */
    //......
    this._canvas = canvas;
    this._ctx = canvas?.getContext('2d');
    this._events = new Events();
  }
  get layer() {
    return this._canvas;
  }
  get events() {
    return this._events;
  }
  getLayerWH(): CanvasTypes.rect {
    return {
      width: this._canvas.width,
      height: this._canvas.height,
    };
  }
  /**
   * 注册事件/给canvas添加事件
   * @param key
   * @param listener
   * @param isAddLayer 是否给canvas也添加事件,默认false
   */
  on(key: string, listener: EventListenerOrEventListenerObject, isAddLayer: boolean = false) {
    }
  remove(key: string) {}
  setGlobalCompositeOperation(key?: string) {}
  setAttributes(obj: CanvasTypes.attributes) {}
  drawRect(color: string, rectPram: CanvasTypes.rectPram) {}
  clearRect(rectPram: CanvasTypes.rectPram) {}
  drawText(option: CanvasTypes.text) {}
  rotate(num: number) {}
  translate(x: number, y: number) {}
  /**
   * 将图像添加到画布中
   * @param img 添加对象
   * @param s1 要添加得对象裁剪规则
   * @param p2 要添加得对象放置规则
   * @param gco 添加对象合成操作规则,参考canvas得GlobalCompositeOperation属性
   */
  drawImage(obj: CanvasTypes.drawImage) {}
}

这个类没什么好说的,就是提供canvas的一些能力,只不过封装了一下,参数变化了

Scene 场景类


class Scene extends Canvas {
   // 属性和constructor代码去掉,占位置.......

  /**
   * 添加事物到场景中
   * @param member
   */
  add(member: any) {
    if (!Reflect.has(member, 'setScene')) {
      throw Error('加入场景的成员必须具备setScene函数');
    }
    if (!Reflect.has(member, 'draw')) {
      throw Error('加入场景的成员必须具备draw函数');
    }
    member.setScene(this);
    member.draw();
    this._memberContainer.add(member);
  }

  /**
   * 加入其他场景
   * @param scene
   */
  join(scene: ScenePo) {
    scene.drawImage({
      //  要传入的参数 ...
      });
  }
  /**
   * 添加加载好的资源
   * @param sourceData
   */
  addSourceMap(sourceData: Map<any, any>) {
    this.sourceMap = sourceData;
  }
  /**
   * 简单图片加载资源
   * @param url 可以是网络资源也可以是images里面的图片文件名字xxx.png
   * @param callback
   */
  loadSource(url: string, callback: callback) {
   // 场景加载资源用...
  }
  // 更新场景,调用场景内的成员绘制方法draw
  update() {
    this.clear();
    forEach(this._memberContainer, item => {
      item.draw();
    });
  }

  // 清理
  clear() {
    this.clearRect({
     // ...
     });
  }
}

场景类的作用就相当于一个容器,你可以往里面加入成员,同时也可以把这个容器加入到另一个容器。同时场景里面有成员需要呈现的所有资源,以及更新重绘场景和成员的方式,没个场景管好自己的事就行

Food 食物类


class Food implements SnakeService.common {
  // 属性代码去掉,占位置.......

  /**
   * 设置场景
   * @param scene
   */
  setScene(scene: ScenePo) {
    this.scene = scene;
  }

  _updateCollideTag(status: boolean) {
    this._isCollided = status;
  }

  collideCheck(snake: SnakePo) {
      // 计算与蛇碰撞...
  }
 
  _calFoodPosition(): CanvasTypes.position {
      // 计算位置,保证位置出现在置入的场景范围内....
  }
  
  _getFoodImg(position: CanvasTypes.position, img?: any) {
    // 获取食物图...
  }
  reload() {
    this._calFoodPosition();
  }
  /**
   * 绘制没有碰撞前的食物
   */
  _drawOld() {
    this.scene.drawImage(this._getFoodImg({ ...this.position }));
  }

  draw() {
    // 没有碰撞保持之前的位置不变
    if (this.position?.x && !this._isCollided) {
      this._drawOld();
      return;
    }
    const _position = this._calFoodPosition();
    this.scene.drawImage(this._getFoodImg({ ..._position }));
  }
}

食物类、蛇类和背景类因为实现的接口是同一个,都有draw、setScene等方法,只是实现的逻辑不一样而已,但是要说明下的是背景的实例和蛇与食物的实例是不在同一个场景,但是最后又到会合成到一个画面中,因为背景是不用动态变化的,保持一个状态就行,这里就不一一粘贴了,下面就只把蛇类粘贴一下。

蛇类

class Snake implements SnakeService.common {
   // 属性代码和部分逻辑代码去掉,占位置.......
  
  /**
   * 设置前进方向
   * @param direction
   */
  setDirection(direction: SnakeService.direction) {
    this.direction = direction;
  }
  _getBodyItem(data?: position): position {
    // 获取身体块...
  }
  /**
   * 设置场景
   * @param scene
   */
  setScene(scene: ScenePo) {
    this.scene = scene;
  }
  _getSnakeLastBody(): position {}
  _addHeader(body: position) {
    body.direction = this.direction;
    this.body.unshift(body);
  }
  // 下面是行为...
  up() {}
  down() {}
  left() {}
  right() {}

  _getBodyImg(position: CanvasTypes.position, img?: any) {}
  
  _rotateRange(name: string, data: position) {
    // 获取身体、头部、尾巴的img的方向
  }
  draw() {
     // 绘制....
  }
  /**
   * 说明:身体是由每块组成得
   * 最初想的处理移动得方式想着是整体动起来,在运动得时候后一块变成前一块得位置,但是这样做要循环处理数据
   * 最终还是做成视觉上的移动,就是每次操作是将最后一块拿到最前面来,造成视觉上的移动效果
   */
  _changeBody() {
    const _header = utils.findFirstItem(this.body);
    const _body = this._getSnakeLastBody();
    if (_body) {
      switch (this.direction) {
       // 根据移动方向 设置body.....
      }
    }
    this.body.unshift(_body);
  }
  /**
   * 边界检测
   * @returns
   */
  _checkOverSide(): boolean {
    // 判断碰壁结果
    // 所在场景范围
    const { width, height } = this.scene;
    const { x, y } = utils.findFirstItem(this.body);
    return x <= 0 || x >= width || y <= 0 || y >= height;
  }

  /**
   * 检测吃到蛇身体
   */
  _checkEatSelf(): boolean {}
  // 碰撞检测
  collideCheck(food: FoodPo) {
    const _isOverSide = this._checkOverSide();
    const _isEatSelf = this._checkEatSelf();
    if (_isOverSide || _isEatSelf) return true;
    // 检测食物碰撞,并重绘食物
    if (food.collideCheck(this)) this.body.push(this.lastBody);
    return false;
  }
}

轮到游戏主进程了

// 去掉依赖的类
// 这里是要初始化获取的图片资源
const headerImg = ['headerUp', 'headerDown', 'headerLeft', 'headerRight'];
const tailImg = ['tailUp', 'tailDown', 'tailLeft', 'tailRight'];
// 游戏本身就是一个场景,继承下Scene
class SnakeGame extends Scene {
  snake!: SnakePo;
  food!: FoodPo;
  backGround!: BackGroundPo;
  SF_scene!: ScenePo;
  fps = 1000 / 3;
  timer: any;
  isPause: boolean = true;
  source = ['bg', 'body', 'gameover', ...headerImg, 'start', ...tailImg, 'food'];
  // 资源加载计数器
  loadSourceNum = 0;
  score = 0;
  gameTimeTotal = 0;
  timeTimer: any;
  // 内置事件回调
  handleLoadSource!: callback;
  handlePause!: callback;
  handleEnd!: callback;
  handleScoreChange!: callback;

  constructor(data: CanvasTypes.initData) {
    // 去掉代码节省空间...
  }
   // 游戏初始化入口
  async initGame(handleCallback?: SnakeService.handleCallback) {
    if (handleCallback...) {
    // 把内置事件的赋值代码去掉...
    }
    // 初始化需要的额外场景对象,以备在资源加载好后,注入资源,以便后续直接绘制
    this._addNewScene();
    // 加载游戏开始所需的资源
    const _res = await this._loadImgSource();
    if (_res) {
      // 将资源加到食物和蛇的场景中
      this.SF_scene.addSourceMap(this.sourceMap);
      this._initGame();
    } else {
      console.log('资源加载出错...');
    }
  }
  _addNewScene() {
    const _sceneData = {...};
    const _scene = new Scene(_sceneData);
    // 食物和蛇所在的场景layer
    this.SF_scene = _scene;
  }
   // 初始化游戏所需要的对象和事件、行为
  _initGame() {
    this.backGround = new BackGround();
    this.food = new Food();
    this.snake = new Snake();
    this.add(this.backGround);
    this.SF_scene.add(this.snake);
    this.SF_scene.add(this.food);
    this.SF_scene.join(this);
    this._drawTimeAndLengthText();
    // 注册事件
    this._initAction();
  }
  /**
   * 资源加载器
   */
  _loadImgSource() {
     /**
     *使用Promise只是为了调用initGame的时候,在资源都加载完了后不在下面的消息中,调用各场景资源注入和
     *初始化游戏所需要的对象和事件、行为
     */
    return new Promise((resolve, reject) => {
      try {
        console.log('资源加载中...');
        this._registerAction('sourceLoading', () => {
          this.loadSourceNum += 1;
          if (this.loadSourceNum === this.source.length) {
            console.log('资源加载结束');
            resolve(true);
          }
          // 资源加载进度条百分数
          if (this.handleLoadSource)
            this.handleLoadSource((this.loadSourceNum / this.source.length) * 100);
        });
        forEach(this.source, item => {
          this.loadSource(item, () => {
            const _load = this._getAction('sourceLoading');
            _load();
          });
        });
      } catch (error) {
        resolve(false);
      }
    });
  }
  reloadGame() {
      ...
  }
  _start() {
    if (this.isPause) return;
    // 触发行为
    const fn = this._getAction(this.snake.direction + '');
    if (fn) fn();
    // 碰撞检测
    const isTrue = this.snake.collideCheck(this.food);
    // 分数变化通知
    this.score += 1;
    if (this.handleScoreChange) this.handleScoreChange(this.score);

    if (isTrue) {
      this.end();
      return;
    }
    this._updateView();
  }
  
  pause() {
    if (this.isPause) return;
    this.isPause = true;
    clearInterval(this.timer);
    clearInterval(this.timeTimer);
  }
  
  start() {
    if (!this.isPause) return;
    if (this.handlePause) this.handlePause(false);
    this._startSumTime();
    this.isPause = false;
    this.timer = setInterval(this._start.bind(this), this.fps);
  }
  // 游戏结束
  end() {
    ...
  }
  
   // 各场景更新自己的内容并绘制
  _updateView() {
    this.update();
    this.SF_scene.update();
    this.SF_scene.join(this);
    this._drawTimeAndLengthText();
  }

  _startSumTime() {
    this.timeTimer = setInterval(() => {
      this.gameTimeTotal += 1;
    }, 1000);
  }
  _drawTimeAndLengthText() {
    ...
    }
  _drawTime() {
    this._drawCommonText(...);
  }

  _drawSnakeLength() {
    this._drawCommonText(...);
  }

  _drawCommonText(text: string, x?: number) {
  // 绘制文字,时间,长度,得分啥的
  ...
  }

  // 获得行为
  _getAction(action: string): callback {
    return this.events.findListenerByKey(action);
  }
  // 注册行为
  _registerAction(action: string, callback: callback) {
    this.on(action, callback);
  }
  // 初始化行为
  _initAction() {
    // 注册蛇的行为,让键盘触发
    this._registerAction(direction.up + '', this.snake.up.bind(this.snake));
       ...
       ...
    this._addGlobalEvents();
  }

  _addGlobalEvents() {
    window.onkeydown = e => {
      // 键盘触发,省略一堆代码
      ...
      // 触发行为
      this._start();
    };
  }
}

游戏主场景功能也不多,只是稍微杂点,主要就是注册行为将蛇的动作和键盘泵绑定,初始化资源和食物,蛇,背,其他事件回调等等,_updateView函数的功能就是利用场景的update()便利场景中的成员的draw()去更新view。

再附带一个截图中的开始,结束等ui,react的组件

import React, { useRef, useEffect, useState } from 'react';
import Snake from '...';
import Loading from '...';
import Start from '...';
import GameOver from '...';
let snakeGame: any;
export default () => {
  const _canvas = useRef(null);
  const [process, setProcess] = useState(0);
  const [isPause, setIsPause] = useState(false);
  const [isOver, setIsOver] = useState(null);
  useEffect(() => {
    if (process >= 100) setIsPause(true);
  }, [process]);

  useEffect(() => {
    const ele = ele1.current;
    if (ele) {
      const _scene = {
        width: 800,
        height: 760,
        ele,
      };
      const _s = new Snake(_scene);
      snakeGame = _s;
      _s.initGame({
        handleLoadSource: val => {
          setProcess(val);
        },
        handlePause: val => {
          setIsPause(val);
          if (!val) setIsOver(null);
        },
        handleEnd: val => {
          setIsOver(val);
        },
      });
    }
  }, []);

  return (
    <>
      <div className="bgc-white border-dash">
        {isOver ? (
          <GameOver score={isOver.score} len={isOver.length} start={() => snakeGame.start()} />
        ) : null}
        {process >= 100 && isPause ? <Start start={() => snakeGame.start()} /> : null}
        <Loading process={process} />
        {/* 游戏主界面 */}
        <canvas className="border-base" ref={_canvas} />
      </div>
    </>
  );
};


写在最后(祝大家新的一年新气象!!)

其实最开始就是玩,只是想写完就行,完成个基本的点点再移动,吃了变大就好了,没想着搞什么UI,加入各种图层图片啥的,可能没个程序员都写完自己的东西后,都是想着尽量做到最好吧,后面就各种细节完善,然后又去找UI图,p图,不过完成后,觉得还是挺好的,写完心情也比较开心吧 不知道大家尤其是前端写页面,或者常规的那些业务,尤其是界面css弄多了之后,会不会觉得感觉自己像没做什么一样,就是天天写这些东西,如果大家有这种想法,我觉得可以换种内容自己玩玩,毕竟前端东西挺多的,没事我觉得玩玩node也行。有时候换种不同的方式也是放松自己...