游戏王卡片 Yugioh Card
前言
我曾在 3 年前制作了一个游戏王卡片生成器的网站,当时用的技术是 dom + css 渲染卡片。在不同浏览器渲染的结果差异挺大(需要精确到像素级)。其实一开始我有考虑过 Canvas 去实现,奈何当时遇到的问题比较多,也没啥经验。文本渲染需要处理非常复杂的情况,最终还是觉得 dom 实现比较简单。
3 年过去了,我想应该考虑用 Canvas 重构一下,就当练练手。正巧遇到了 Leafer UI 这个库,让我有了行动力。
项目展示
左侧是卡片,右侧是调试工具。直接修改右侧的 JSON 数据就能动态渲染了。具体的数据内容不是本文的重点,感兴趣的小伙伴可以自行研究。
开发流程
我设计每个卡片拥有自己的属性,且能够动态改变卡片内容,于是就采用了面向对象的思想,使用类的实现方式。
目前项目包含 5 种卡片,每个卡片都有基础属性与方法是重复的,重复地复制粘贴不是一个好方法,我自然地想到了类的继承。我定义了一个 Card 类,每个卡片都需要继承它,它基础结构如下:
class Card {
constructor(data = {}) {
this.leafer = null; // Leafer 实例
this.cardWidth = 100; // 卡片宽度,需要重写
this.cardHeight = 100; // 卡片高度,需要重写
this.key = 0; // 类似与 vue 中的 key,用于强制重新渲染
this.data = {}; // 卡片数据
this.defaultData = {}; // 默认卡片数据,需要重写
this.view = data.view; // 渲染容器
this.resourcePath = data.resourcePath; // 静态资源路径
loadCSS(); // 加载字体样式
}
setData() { ... } // 更新数据
loadFont() { ... } // 加载字体
initData() { ... } // 初始化数据
initLeafer() { ... } // 初始化 Leafer
initDraw() { ... } // 绘制卡片,需要重写
updateScale() { ... } // 更新 Leafer 的尺寸
}
接着我们定义一个 YugiohCard 子类:
class YugiohCard extends Card {
constructor(data = {}) {
super(data); // 把参数传递给父类
// 以下是 Leafer 子元素
this.cardLeaf = null;
this.nameLeaf = null;
// 此处省略,根据卡片内容而定
this.defaultData = {}; // 卡片默认值
this.initData(data); // 初始化数据
this.initLeafer(); // 初始化 Leafer
this.initDraw(); // 初始化绘制
this.loadFont(); // 加载字体
}
initDraw() {
this.drawCard();
this.drawName();
// 以下省略
this.updateScale(); // 更新卡片尺寸
}
drawCard() { ... } // 绘制卡片背景
drawName() { ... } // 绘制卡名
}
在 Canvas 中加载字体可以用 webfontloader
等库,由于卡片包含的字体数量过多,不适合一次性加载。于是我采用引入 css 动态加载字体的方式。对于 dom 很容易,字体触发加载后页面文本会自动更新。但是在 Canvas 中第一次渲染时,字体还没加载完成,等字体加载完后 Canvas 又结束渲染了。所以正确的动态加载流程是这样的:
当卡片数据更新时,要调用父类的 setData
方法,我们来看看是怎么实现的:
setData(data = {}) {
data = cloneDeep(data);
let needDraw = false;
let needLoadFont = false;
Object.keys(data).forEach(key => {
const value = data[key] ?? this.defaultData[key];
if (JSON.stringify(this.data[key]) !== JSON.stringify(value)) {
this.data[key] = value;
if (['language', 'font'].includes(key)) {
needLoadFont = true;
}
needDraw = true;
}
});
if (needDraw) {
this.initDraw();
}
// 先触发绘制,再触发字体加载
if (needLoadFont) {
this.loadFont();
}
}
首先把数据深拷贝一份,以防对象类型数据被篡改。循环传进来的对象,通过空值合并运算符得出参数值。我使用 JSON.stringify
去对比数据是考虑到数组、对象的值不能直接比较是否相同。如果改变的参数涉及到字体改动,则需要去触发一次字体加载监听,我们再来看看父类中 loadFont
的实现:
loadFont() {
document.fonts.ready.finally(() => {
this.key++;
this.initDraw();
});
}
代码很少,通过 document.fonts.ready
去监听字体加载,不管成功失败都去初始化渲染,因为可能存在多个字体同时加载,某个字体失败的情况。有注意到这里改变了 key
吗?key
是用来强制更新文本渲染的,用 drawName
举例:
drawName() {
const { name } = this.style;
if (!this.nameLeaf) {
this.nameLeaf = new CompressText();
this.leafer.add(this.nameLeaf);
}
this.nameLeaf.set({
text: this.data.name,
fontFamily: name.fontFamily,
fontSize: name.fontSize,
letterSpacing: name.letterSpacing || 0,
wordSpacing: name.wordSpacing || 0,
textAlign: this.data.align || 'left',
color: this.data.color || this.autoNameColor,
gradient: this.data.gradient,
gradientColor1: this.data.gradientColor1,
gradientColor2: this.data.gradientColor2,
rtFontSize: name.rtFontSize,
rtTop: name.rtTop,
rtColor: this.autoNameColor,
width: this.showAttribute ? 1033 : 1161,
height: 200,
x: 116,
y: name.top,
key: this.key,
zIndex: 10,
});
}
每个复杂的文本渲染都用到 CompressText
类,关于这个类的实现,可以看我上一篇文章。为什么要用 key
强制渲染,虽然 CompressText
内部也会监听字体加载动态更新。我们再来看看渲染文本的流程:
流程看着没有问题,但是 CompressText.set
方法,如果传入的新值和旧值相同,则不会去重新渲染文本。所以我们引入了一个 key
变量,能够手动触发重新渲染。
有细心的小伙伴可能注意到,每次数据更新都会重复执行 initDraw
方法,那么这不会影响性能吗?答案是不会的,看它内部是怎么写的:
drawXxx() {
if (!this.xxxLeaf) {
this.xxxLeaf = new Image();
this.leafer.add(this.xxxLeaf);
}
this.xxxLeaf.set({
...
});
}
首先会判断 xxxLeaf
有没有生成过实例,然后再调用 set
方法,而我们知道参数相同的情况下是不会重复渲染的。无需考虑参数是什么,从内部实现了局部更新。
以上就是整个卡片类的流程介绍,具体每个 drawXxx
的内容就不一一介绍了,感兴趣可以直接参考源码。
总结
我不知道这样的文章算不算纯粹的技术分享,但是我想写就写了O(∩_∩)O~。也许这里也有喜欢游戏王的牌佬呢(我自己不会打牌,就是单纯喜欢卡片)。
项目地址
在线演示:kooriookami.github.io/yugioh-card…
最佳实践:ygo.ygosgs.com/