Canvas如何开发角色行动与键盘事件

673 阅读6分钟

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

前言

所有的青春都像一盏灯,

在雨中被冲倒,

湿漉漉却在燃烧。

——聂鲁达

介绍

说起年少,最喜欢的就是口袋妖怪系列了,那时,自己培养的宝可梦和隐藏剧情都成了课间活动同学们的谈资,当然,直到现在包括我很多人依然热爱这个话题。所以,今天的主题就是口袋妖怪,我们不依赖任何游戏引擎和插件,将从0开始做一个通过键盘控制角色行动的场景。

VID_20210818_093457.gif

我们本次主要从基础结构搭建,瓦片地图,角色类,键盘类的开发来进行,看似简单其实东西还是很多的。准备好吗?这就出发~

开发

1.基础搭建

<style>
    * {
        padding: 0;
        margin: 0;
    }
    html,
    body {
        width: 100%;
        height: 100vh;
        overflow: hidden;
    }
    #canvas{
        width: 100%;
        height: 100%;
    }
</style>
<body>
    <canvas id="canvas"></canvas>
    <script type="module" src="./app.js"></script>
</body>

这里我们依旧基础结构先写出来,利用module模式方便导入模块。

/*app.js*/
// 从角色类导出角色,方向类型,状态类型(后面会有具体说明)
import { Hero, DIRECTION, STATE } from "./js/Hero.js"
// 键盘操作(后面会有具体说明)
import keyboard from "./js/keyboard.js";
​
class Application {
  constructor() {
    this.canvas = null;              // 画布
    this.ctx = null;                 // 环境
    this.w = 0;                      // 宽
    this.h = 0;                      // 高
    this.hero = null;                // 当前角色
    this.textures = new Map();       // 纹理集
    this.spriteData = new Map();     // 精灵数据
    this.init();
  }
  init() {
    // 初始化
    this.canvas = document.getElementById("canvas");
    this.ctx = this.canvas.getContext("2d");
    window.addEventListener("resize", this.reset.bind(this));
    this.reset();
    this.textures.set("map", "../assets/map.png");
    this.textures.set("hero", "../assets/hero.png");
    this.load().then(this.render.bind(this));
  }
  reset() {
    // 根据屏幕变化调整宽高
    this.w = this.canvas.width = this.ctx.width = window.innerWidth;
    this.h = this.canvas.height = this.ctx.height = window.innerHeight;
  }
  load() {
    // 加载纹理
    const {textures, spriteData} = this;
    let n = 0;
    return new Promise((resolve, reject) => {
      if (textures.size == 0) resolve();
      for (const key of textures.keys()) {
        let _img = new Image();
        spriteData.set(key, _img);
        _img.onload = () => {
          if (++n == textures.size)
            resolve();
        }
        _img.src = textures.get(key);
      }
    })
  }
  addKeys() {
    // 添加键盘事件
  }
  render() {
    // 主渲染
    this.addKeys();
    this.step();
  }
  drawBackground() {
      // 绘制瓦片地图
  }
  step(delta) {
    // 重绘
    const {w, h, ctx} = this;
    requestAnimationFrame(this.step.bind(this));
    ctx.clearRect(0, 0, w, h);
    this.drawBackground();
    this.hero&&this.hero.update(delta);
  }
}
window.onload = new Application();

我们在主场景逻辑定义好了各类变量,在这里我们分别加载好角色行动和瓦片两张图片就可以去进入渲染了然后渲染重绘了。

微信截图_20210818095324.png

2.瓦片地图

真正的瓦片地图其实是根据场景做出矩阵,然后设置地块的不同属性,比如什么是土地,什么是墙,什么是草地等等,然后根据这个矩阵相应的点对应的资源图片一张张贴到相应位置上。但是,我们本次重点在于角色的行动控制上,所以并没有建立完整的矩阵,通过for循环贴一种类型的地块信息。

drawBackground() {
    const {w, h, ctx, spriteData} = this;
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, w, h);
    let _map_img = spriteData.get("map");
    for (let i = 0; i < w / _map_img.width; i++) {
        for (let j = 0; j < h / _map_img.height; j++) {
            ctx.save();
            ctx.drawImage(_map_img, _map_img.width * i, _map_img.height * j);
            ctx.restore();
        }
    }
}

现在一张张小瓦片图就贴到了背景上,写完这个就能看到大地图了,如图所示:

微信截图_20210818101143.png

3.角色类

这里我们先考虑一下,角色动作和状态有哪些呢,因为口袋妖怪没有斜上斜下那种走法,只有上下左右四种方向,而目前我们也只有两种状态,就是等待和行走。我们一定要先定义好这些,抽离出来,因为更直观也方便后期维护。

/*Hero.js*/
export const DIRECTION = {
  DOWN: 0,
  LEFT: 1,
  RIGHT: 2,
  UP: 3,
}
export const STATE = {
  WAIT: "WAIT",
  RUN: "RUN"
}

接下来我们就集中精力完整写下来角色类的实现了:

export class Hero {
  constructor(options) {
    this.x = 0;                                // x轴坐标
    this.y = 0;                                // y轴坐标
    this.direction = DIRECTION.DOWN;           // 方向
    this.state = STATE.WAIT;                   // 状态
    this.img = null;                           // 图片
    this.scale = 1;                            // 大小
    this.speed = 3;                            // 行动速度
    this.rows = [4, 4];                        // 图片横纵分割数
    Object.assign(this, options);
    this.vx = 0;                               // x轴位移量
    this.vy = 0;                               // y轴位移量
    this.offsetX = 0;                          // 图片x轴位置
    this.offsetY = 0;                          // 图片y轴位置
    this.ctx = null;                           // 绘制环境
    this.timer = null;                         // 定时器
  }
  render(ctx) {
    // 主渲染
    if (!ctx) return false;
    this.ctx = ctx;
    this.draw();
    this.setDirection(this.direction);
    return this;
  }
  draw() {
    // 绘制角色  
    const {ctx, img, x, y, rows, scale, offsetX, offsetY} = this;
    let w = img.width / rows[0];
    let h = img.height / rows[1];
    ctx.save();
    ctx.translate(x, y);
    ctx.scale(scale, scale);
    ctx.drawImage(img, w * offsetX, h * offsetY, w, h, 0, 0, w, h);
    ctx.restore();
  }
  setDirection(direction) {
    // 设置方向
    if (this.direction == direction) return;
    this.direction = direction;
    this.offsetY = this.direction;
    this.stop();
  }
  play(state) {
    // 触发状态事件
    this.state = state || STATE.WAIT;
    if (this.state.WAIT == STATE.WAIT) {
      // 等待
      this.stop();
    }
    if (this.state == STATE.RUN) {
      // 行动
      if (this.timer == null) {
        this.timer = setInterval(() => {
          this.offsetX++;
          this.offsetX %= this.rows[0];
        }, 60)
      }
    }
  }
  stop() {
    // 停止
    this.timer && clearInterval(this.timer);
    this.timer = null;
    this.offsetX = 0;
  }
  update(dt) {
    // 更新
    const {ctx, rows, img} = this;
    const {width, height} = ctx;
    this.x += this.vx;
    this.y += this.vy;
    // 边界限制
    this.x = Math.max(0, Math.min(this.x, width - img.width / rows[0]))
    this.y = Math.max(0, Math.min(this.y, height - img.height / rows[1]))
    this.draw();
  }
}

我们其实就是根据他的方向和状态来运行角色的,状态是等待就取相应方向的第一张图,如果是行动那么就利用定时器加他的offsetX属性不断加1,如果超过限度就变回0重新开始,形成动画效果,这就是帧动画。

let w = img.width / rows[0];
let h = img.height / rows[1];
ctx.drawImage(img, w * offsetX, h * offsetY, w, h, 0, 0, w, h);

因为图片是等分的,所以我们很好取到人物的宽高,然后根据偏移对应的位置在逐个绘制出来。

然后讲讲为何方向来确定offsetY属性,原因如图:

微信截图_20210818102515.png

因为我们要拆分这些精力图,的相对应位置,正好下方向对应的是第0个横排动作,左方向对应的是第1横排动作。。就是这么简单就确定好了当前方向对应的动作集位置。

最后就算渲染重绘了,因为我们以后要用键盘取控制他的行动,所以呢,我们要给他个x与y轴的相对位移量,然后不断执行的时候就会动了。当然我们初始化的位移量是0的,在键盘事件触发的时候,相应键位才会使角色改变位移。同时,我们也当做好边界判断,不让角色出画布。

最后,我们回到app.js上,给他实例化一下:

render() {
    const {w, h, ctx, spriteData} = this;
    let _hero_img = spriteData.get("hero")
    this.hero = new Hero({
        rows: [4, 4],
        scale: 1,
        x: w / 2 - _hero_img.width / 4,
        y: h / 2 - _hero_img.height / 4,
        img: _hero_img
    }).render(ctx);
    this.addKeys();
    this.step();
}

我们就可以看到角色显示出来了~

微信截图_20210818103821.png

4.键盘类

我们先将完整的代码写下来:

/*keyboard.js*/class Keyboard {
  constructor() {
    this.values = [];    // 事件集
    this.init();
  }
  add({key, press, release}) {
    // 添加事件
    this.values.push({
      isDown: false,        // 是否按下
      isUp: true,           // 是否抬起
      value: key,           // 事件名称
      press,                // 按下事件
      release               // 抬起事件
    })
  }
  init() {
    // 初始化,监听绑定按下与抬起事件
    window.addEventListener("keydown", this.downHandler.bind(this), false);
    window.addEventListener("keyup", this.upHandler.bind(this), false);
  }
  destroy(key) {
    // 过滤掉某个按键
    this.values = this.values.filter(obj => obj.value != key)
  }
  unsubscribe() {
    // 移除监听
    window.removeEventListener("keydown", this.downHandler.bind(this));
    window.removeEventListener("keyup", this.upHandler.bind(this));
  }
  downHandler(event) {
    // 按下事件
    event.preventDefault();
    let key = this.values.find(v => v.value === event.key);
    if (!key) return false;  
    key.isDown = true;
    key.isUp = false;
    key.press && key.press(key);
  }
  upHandler(event) {
    // 抬起事件
    event.preventDefault();
    let key = this.values.find(v => v.value === event.key);
    if (!key) return false;
    key.isDown = false;
    key.isUp = true;
    key.release && key.release(key);
  }
}
export default new Keyboard();

这里我们直接导出实例化,键盘对象作为单例使用。

我们期望是从外界将一些变量和事件注入到键盘对象,每次按下抬起找寻相应事件并执行注入回调。

接下来我们就在app.js做注入演示了,当然这也是最后一步了。

/*app.js*/
addKeys() {
    const {hero} = this;
    keyboard.add({
        key: "ArrowUp",
        press: (key) => {
            hero.setDirection(DIRECTION.UP);
            hero.vx = 0;
            hero.vy = -hero.speed;
            hero.play(STATE.RUN);
        },
        release: () => {
            if (hero.direction == DIRECTION.UP) {
                hero.vy = 0;
                hero.stop();
            }
        }
    });
    keyboard.add({
        key: "ArrowDown",
        press: (key) => {
            hero.setDirection(DIRECTION.DOWN);
            hero.vx = 0;
            hero.vy = hero.speed;
            hero.play(STATE.RUN);
        },
        release: () => {
            if (hero.direction == DIRECTION.DOWN) {
                hero.vy = 0;
                hero.stop();
            }
        }
    });
    keyboard.add({
        key: "ArrowLeft",
        press: (key) => {
            hero.setDirection(DIRECTION.LEFT);
            hero.vy = 0;
            hero.vx = -hero.speed;
            hero.play(STATE.RUN);
        },
        release: () => {
            if (hero.direction == DIRECTION.LEFT) {
                hero.vx = 0;
                hero.stop();
            }
        }
    });
    keyboard.add({
        key: "ArrowRight",
        press: (key) => {
            hero.setDirection(DIRECTION.RIGHT);
            hero.vy = 0;
            hero.vx = hero.speed;
            hero.play(STATE.RUN);
        },
        release: () => {
            if (hero.direction == DIRECTION.RIGHT) {
                hero.vx = 0;
                hero.stop();
            }
        }
    });
}

我们注册四个键盘事件分别是上下左右四个方向键。当按下某一方向的时候就会让控制的角色朝向和行动,当抬起就让其停止。

这时候有人会提出疑问:

1.为什么不直接改变角色的x轴y轴坐标能让其加一个值,非要加位移量去改变?

答:这里的原因是因为很多键盘型号和系统帧率不同,所以当键盘长按第一次与第二次有一个间隔时长,他与第二次以后的时长也长很多,当行动的时候按下键盘第一帧就会出现明显的移动卡顿

2.为什么抬起要判断他的动作方向,才做停止?

答:因为我们键盘可能会同时触发多个组合按键,有可能会互相冲突,而口袋妖怪角色都是单一状态的,在行动中我们只有判断他状态还存在再给他停止下来。


写到这里我们本次的口袋妖怪的角色控制场景已经完成了,你可以自由的通过键盘控制角色行动了,在线演示

拓展&延伸

这次是讲述了游戏最基本的一些事件处理,还有更多处理没有完成,比如摄像机的实现,完整瓦片地图的加载与生成,物体的阻挡,宝可梦跟随角色移动,场景交互等等,如果有兴趣的话其实可以一直写下去,但是我们目前是没有引擎实现了这些,如果想完全实现一款属于自己的h5比较大的游戏,那么或许我们要自己开发一个游戏引擎或者选择世面上泛用型的一款引擎比如pixijs,cocos,白鹭等等来改造开发。。


最早想用仙剑做演示的,但是想想还是口袋妖怪的记忆最多,而且成本会更高,所以帮不了你了逍遥~

VID_20210818_111441.gif