Canvas如何做个十连抽

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

前言

能够享特殊的鸿运的人,

同时也会提防鸿运。

——塞内加

介绍

你还在为没钱氪金而沮丧么,你还在为脸黑而苦恼么,来这学习这个吧,让你可以得到应有尽有的卡牌~

本次的主题还是冷饭王,哦不,是游戏王,那可是80,90后一代人的记忆,小卖铺买卡一起找小伙伴凡尔赛自己的稀有卡,然后对局臆想两人跟动漫里的画面,也是段挺中二的回忆。。

我们本次做的任务很简单,就是利用纯canvas而非css3也无游戏引擎和插件,使卡片平铺起来适应横向屏幕,然后点击后反转就齐活了。

VID_20210819_090545.gif

我们这次重点在于卡牌是如何横向排布换行的,如何检测到点击的,如何做出3d效果翻转的,大致会从基础结构, 卡牌横排坐标处理,卡牌类的实现来展开。

PS:游戏都是基于画布实现的,没法去用css3,在这种如何去实现他,所以本篇也仅仅是提供一种处理思路,如果做小活动页的时候,还是建议用css3的preserve-3d模式去改元素的rotateY最方便。

出发

初章·基础结构

我们依然先写好html,在里面我们用module模式,方面后面的模块加载

<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>
复制代码

然后,我们先写好卡牌类的结构,方面在主逻辑里面引入。

/*Card.js*/
class Card {
  constructor(options) {
    this.name = "";
    this.x = 0;
    this.y = 0;
    return this;
  }
  render(ctx) {}
  draw() {}
}
export default Card;
复制代码
/*app.js*/
import Card from "./js/Card.js";
复制代码

接下来我们要准备一些卡牌素材了,作为假数据实现。

/*app.js*/
const cardData = [{
  name: "back",
  src: "./assets/back.png"
}, {
  name: "破壊神 ヴァサーゴ",
  src: "./assets/破壊神 ヴァサーゴ.png"
}, {
  name: "青眼の白龍",
  src: "./assets/青眼の白龍.png"
}, {
  name: "ライトロード・プリースト ジェニス",
  src: "./assets/ライトロード・プリースト ジェニス.png"
}, {
  name: "V・HERO ウィッチ・レイド",
  src: "./assets/V・HERO ウィッチ・レイド.png"
}, {
  name: "エクシーズ・ユニバース",
  src: "./assets/エクシーズ・ユニバース.png"
}, {
  name: "A・O・J コアデストロイ",
  src: "./assets/A・O・J コアデストロイ.png"
},{
  name:"霞の谷の巨神鳥",
  src: "./assets/霞の谷の巨神鳥.png"
},{
  name:"天狗のうちわ",
  src: "./assets/天狗のうちわ.png"
},{
  name:"ナチュル・ランドオルス",
  src: "./assets/ナチュル・ランドオルス.png"
},{
  name:"チェーン・リゾネーター",
  src: "./assets/チェーン・リゾネーター.png"
}];
复制代码

微信截图_20210819094243.png

手游抽卡其实都是通过后端算法返回到前台数据,然后展示的,所以我们这里直接写死。

有了资源了,我们就可以去写主逻辑,让主逻辑去加载资源,然后做个反馈:

class Application {
  constructor() {
    this.canvas = null;           // 画布
    this.ctx = null;              // 环境
    this.w = 0;                   // 画布宽
    this.h = 0;                   // 画布高
    this.textures = new Map();    // 纹理集
    this.spriteData = new Map();  // 精灵数据
    this.cardList = [];           // 卡牌数组
    this.progress = 0;            // 加载进度[0-1]
    this.init();
  }
  init() {
    // 初始化
    this.canvas = document.getElementById("canvas");
    this.ctx = this.canvas.getContext("2d");
    window.addEventListener("resize", this.reset.bind(this));
    this.reset();
    cardData.forEach(card => {
      this.textures.set(card.name, card.src);
    })
    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;
    if (this.progress >= 1) this.render();
  }
  render() {
    // 主渲染
    this.cardList.length = 0;
    this.step();
  }
  load() {
    // 加载纹理
    const { textures, spriteData } = this;
    let n = 0;
    return new Promise((resolve, reject) => {
      if (textures.size == 0) {
        this.progress = 1;
        resolve();
      }
      for (const key of textures.keys()) {
        let _img = new Image();
        spriteData.set(key, _img);
        _img.onload = () => {
          this.progress += (1 / textures.size);
          if (++n == textures.size) {
            this.progress = 1;
            resolve();
          }
        }
        _img.src = textures.get(key);
      }
    })
  }
  drawCard() {
    // 绘制卡牌数组内的卡牌
    this.cardList.forEach(card => {
      card.draw();
    })
  }
  drawBackground() {
    // 绘制底背景
    const { w, h, ctx} = this;
    ctx.fillStyle = "#000";
    ctx.fillRect(0, 0, w, h);
  }
  step(delta) {
    // 重绘  
    const { w, h, ctx } = this;
    requestAnimationFrame(this.step.bind(this));
    ctx.clearRect(0, 0, w, h);
    this.drawBackground();
    this.drawCard()
  }
}
​
window.onload = new Application();
复制代码

跟之前一样,我们把图片数据一张张塞到纹理集里面,然后逐个加载他们,本次加了progress变量来看加载进度,因为有屏幕改变的情况,我们会清空卡组,重新绘制卡牌所在位置,做这些的前提就是progress是1也就是资源全部加载完成才会做出改变。

加载完我们进入主渲染,这里我们后面会在这里处理生成卡牌塞入卡组,我们先要绘制一个黑色的底背景,遍历卡组去绘制每一张卡牌,再去重绘事件不断执行。

次章·卡牌排布

我们期望是卡牌横排如果超出屏幕宽度就自动换行,如果这是在css做弹性或浮动都很好实现,但是在canvas是如何处理的呢?

VID_20210818_162509.gif

我们先要在主逻辑上新增卡牌的生成的内容

render() {
    const { ctx, spriteData, w } = this;
    this.cardList.length = 0;
    let scale = 0.225;
    let n = 1;
    while (n < cardData.length) {
        let item = cardData[n];
        let img = spriteData.get(item.name);
        let size = Math.max(1, Math.floor(w / (img.width * scale + 20)));
        let i = n - 1;
        let x = 20 * (i % size + 1) + (i % size) * img.width * scale;
        let y = 20 * Math.ceil(n / size) + Math.floor(i / size) * img.height * scale;
        let card = new Card({
            name: item["name"],
            scale,
            x,
            y,
            img,
            back: spriteData.get("back")
        }).render(ctx)
        this.cardList.push(card);
        n++;
    }
    this.step();
}
复制代码

我们拿到的素材图片其实尺寸非常大,所以我们要想办法缩放他,这里我们后面会在card里面进行缩放计算。但实现要定义好缩放系数。接下来,变量n作为图片资源使用的计数,从1开始是因为第0张是卡背图片,每张都有,所以我们遍历的是他不同的正面,我们期望的是,传入他的x轴y轴坐标名称正面图背面图缩放程度,这也是我们目前能想到的正常逻辑了,实例化后再让他渲染。。

现在的关键问题是我们如何得到,他的x轴和y轴坐标?

我们至少先要获取到他屏幕最多能放几个,size就是(画布宽度)/ (图片缩放后的宽度+ 留白)。同时还要保证至少存在1个的数量。

然后我们就可以利用他去依次计算他的横坐标,就是留白的累加与图片缩放后的宽度的累加值,再利用刚刚算计好的size,去取模,让其断行,从头开始计算。与此类似,纵坐标也是如此,不过是做除法取到当前行数而已。

第三章·卡牌类

/*Card.js*/
class Card {
  constructor(options) {
    this.name = "";                     // 名称
    this.x = 0;                         // x轴坐标
    this.y = 0;                         // y轴坐标
    this.back = null;                   // 卡牌背面
    this.img = null;                    // 卡牌正面
    this.speed = 0.02;                  // 翻转速度
    this.scale = 1;                     // 缩放大小
    Object.assign(this, options);
    this.ctx = null;                    // 环境
    this.timer = null;                  // 定时器
    this.scaleX = this.scale;           // x轴缩放
    this.scaleY = this.scale;           // y轴缩放
    this.isActive = false               // 是否被激活
    return this;
  }
  render(ctx) {
    // 主渲染
    if (!ctx) return;
    this.ctx = ctx;
    this.draw();
     return this;
  }
  show() {
    // 显示
    if(this.isActive) return;
    this.isActive = true;
    this.timer = setInterval(() => {
      this.scaleX -= this.speed;
      if (this.scaleX <= 0) {
        this.isShow = true;
      }
      if (this.scaleX <= -this.scale ) {
        this.scaleX = -this.scale;
        clearInterval(this.timer);
        this.timer = null;
      }
    }, 1000 / 60)
  }
  draw() {
    // 绘制
    if(this.isShow)
      this.drawCard();
    else  
      this.drawBack();   
  }
  drawCard() {
    // 绘制正面
    const {ctx, img, x, y, scaleX, scaleY,scale} = this;
    ctx.save();
    ctx.translate(x+img.width*(scale-scaleX)/2, y);
    ctx.scale(scaleX, scaleY);
    ctx.drawImage(img, 0, 0);
    ctx.restore();
  }
  drawBack() {
    // 绘制背面
    const {ctx, x, y, scaleX, scaleY, back, img,scale} = this;
    ctx.save();
    ctx.translate(x+img.width*(scale-scaleX)/2, y);
    ctx.scale(scaleX, scaleY);
    ctx.drawImage(back, 0, 0);
    ctx.restore();
  }
}
复制代码

我们这么就是通过isShow来判断到底绘制正面还是背面,isActive是判断他是否已经激活,激活后就不能让他再改变了,一个单向的状态。

我们着重要讲的是,如何绘制他,我们可能一开始联想到画布的旋转rotate。但是,这种形式却只能针对于2d的水平旋转。而我们实现的卡牌翻转是3d的。无法用它去实现,此时此刻,我们会联想到,另外一个算法,就是透视图算法,近大远小通过缩放去模拟他如何呢。

所以写出了如下操作:

/*Card.js*/
show() {
    if(this.isActive) return;
    this.isActive = true;
    this.timer = setInterval(() => {
        this.scaleX -= this.speed;
        if (this.scaleX <= 0) {
            this.isShow = true;
        }
        if (this.scaleX <= -this.scale ) {
            this.scaleX = -this.scale;
            clearInterval(this.timer);
            this.timer = null;
        }
    }, 1000 / 60)
}
复制代码

因为是沿着y轴旋转,所以我们要适当更改x轴的缩放比例使之背面缩放到0后显示正面还原到初始的相反值上。

但,光改变这个是不够的,因为这样只会绕着0点去翻转,我们期望的是中心点,不过x轴的中心点最重要的还是不断计算当前尺寸与原尺寸的差值,使其不断平移相应距离,欺骗肉眼让其达到他没有动,在原位置反转了。

/*Card.js*/
ctx.translate(x+img.width*(scale-scaleX)/2, y);
复制代码

终章·捕获卡牌

我们都写好了,现在只差怎么能让鼠标触发他的翻转效果了~

/*app.js*/
init() {
  + canvas.addEventListener("click", this.clickHandle.bind(this), false)
}
​
clickHandle(e) {
    let offsetX = e.offsetX;
    let offsetY = e.offsetY;
    let card = this.cardList.find(card => {
        const { img, x, y, scale } = card;
        return (offsetX > x && offsetX < x + img.width * scale) && (offsetY > y && offsetY < y + img.height * scale)
    })
    card && card.show();
}
复制代码

先要监听他的点击事件,绑定应该方法,然后每次鼠标点击后判断当前坐标,这里我们用到了外接矩形判定法。

外接矩形判定法:指的是如果检测物体是一个矩形或近似矩形,我们可以把这个物体抽象成一个矩形,然后用判断两个矩形是否碰撞的方法进行检测。简单来说,就是把物体看成一个矩形来处理。

我们的卡牌算是个矩形就根据他的横纵坐标配上他的尺寸,鼠标点在不在范围内,然后就遍历筛出点击的卡牌使其翻转。


我们这样就做完了,是不是如此的简单,你学废了么,在线演示

拓展与延伸

我们实现伪3d的这种翻转其实是通过透视法延伸而来,如果想做比如3d放置类游戏,想用2d画布来模拟就实现。当然如果需求很多,还是要乖乖的使用一些游戏引擎来开发,我们就不用频繁计算坐标和锚点了。

这里只提供思路,更多创造和想法需要自己动手动脑实现~


最后,想对之前凡尔赛的同学说,你们买的都是盗版~哈哈哈哈~~

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享