这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战
前言
能够享特殊的鸿运的人,
同时也会提防鸿运。
——塞内加
介绍
你还在为没钱氪金而沮丧么,你还在为脸黑而苦恼么,来这学习这个吧,让你可以得到应有尽有的卡牌~
本次的主题还是冷饭王,哦不,是游戏王,那可是80,90后一代人的记忆,小卖铺买卡一起找小伙伴凡尔赛自己的稀有卡,然后对局臆想两人跟动漫里的画面,也是段挺中二的回忆。。
我们本次做的任务很简单,就是利用纯canvas而非css3也无游戏引擎和插件,使卡片平铺起来适应横向屏幕,然后点击后反转就齐活了。
我们这次重点在于卡牌是如何横向排布换行的,如何检测到点击的,如何做出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"
}];
复制代码
手游抽卡其实都是通过后端算法返回到前台数据,然后展示的,所以我们这里直接写死。
有了资源了,我们就可以去写主逻辑,让主逻辑去加载资源,然后做个反馈:
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是如何处理的呢?
我们先要在主逻辑上新增卡牌的生成的内容
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画布来模拟就实现。当然如果需求很多,还是要乖乖的使用一些游戏引擎来开发,我们就不用频繁计算坐标和锚点了。
这里只提供思路,更多创造和想法需要自己动手动脑实现~
最后,想对之前凡尔赛的同学说,你们买的都是盗版~哈哈哈哈~~