这是我参与8月更文挑战的第12天,活动详情查看:8月更文挑战
前言
夫君子之行,静以修身,俭以养德,
非淡泊无以明志。非宁静无以致远。
——诸葛亮
介绍
本期的主题是三国,这是个一直伴随我们成长的话题,最开始是连环画,电视剧,小说,有了电脑后我们就开始沉迷这类游戏许久,傲世三国,吞食天地,三国英杰传,曹操传,孔明传,当然还有三国志系列。。。
言归正传,本期的目的就是制作三国志中各个人物五维图绘制和切换。
我们将不借助任何引擎,用canvas从0开始实现它。我们将从结构搭建,绘制数据,多边形类,五维图类等方面去讲解,准备好了么,全军出击~
出发
1.结构搭建
我们还是放个画布元素,再通过module模式来引入主逻辑,方便后面的模块加载进来。
* {
padding: 0;
margin: 0;
}
html,
body {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-image: radial-gradient(
circle at bottom left,
rgb(242, 242, 242) 0%,
rgb(242, 242, 242) 6%,
rgb(238, 238, 238) 6%,
rgb(238, 238, 238) 15%,
rgb(234, 234, 234) 15%,
rgb(234, 234, 234) 47%,
rgb(230, 230, 230) 47%,
rgb(230, 230, 230) 54%,
rgb(225, 225, 225) 54%,
rgb(225, 225, 225) 56%,
rgb(221, 221, 221) 56%,
rgb(221, 221, 221) 90%,
rgb(217, 217, 217) 90%,
rgb(217, 217, 217) 100%
);
}
#canvas {
width: 640px;
height: 480px;
cursor: pointer;
}
我们先用css绘制一张背景,让界面显得不那么单调。
/*app.js*/
// 五维属性类(后面会讲解)
import AttrShap from "./js/AttrShap.js"
class Application {
constructor() {
this.canvas = null; // 画布
this.ctx = null; // 环境
this.w = 640; // 画布宽
this.h = 480; // 画布高
this.attrShap = null; // 五维属性实例
this.textures = new Map(); // 纹理集
this.spriteData = new Map(); // 精灵数据
this.roleData = []; // 角色数据
this.roleIndex = 0; // 角色标记
this.init();
}
init() {
// 初始化
this.canvas = document.getElementById("canvas");
this.ctx = this.canvas.getContext("2d");
this.canvas.width = this.ctx.width = this.w;
this.canvas.height = this.ctx.height = this.h;
this.textures.set("xiahoudun", "./assets/xiahoudun.png");
this.textures.set("guanyu", "./assets/guanyu.png");
this.textures.set("zhangliao", "./assets/zhangliao.png");
this.textures.set("zhangfei", "./assets/zhangfei.png");
this.load().then(this.render.bind(this));
this.canvas.addEventListener("click",this.nextRole.bind(this),false)
}
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);
}
})
}
render() {
// 主渲染
this.renderRole();
this.step()
}
renderRole() {
// 角色渲染
}
drawBackground() {
// 绘制背景
const {ctx, w, h} = this;
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,.15)';
ctx.strokeStyle = 'rgba(0,0,0,1)';
ctx.lineJoin = 'round';
ctx.fillRect(0, 0, w, h);
ctx.strokeRect(0, 0, w, h);
ctx.restore();
}
drawRole() {
// 绘制角色
}
drawName() {
// 绘制姓名
}
nextRole(){
// 切换角色
}
step(delta) {
// 重绘
requestAnimationFrame(this.step.bind(this));
const {ctx, w, h} = this;
ctx.clearRect(0, 0, w, h);
this.drawBackground();
this.drawRole();
this.drawName();
this.attrShap&&this.attrShap.draw();
}
}
window.onload = new Application();
这就是我所有要做的事。初始化我们要塞入一些要加载的角色图片,在加载器中逐个加载,加载完毕后自动进入主渲染里面。我们要在主渲染中将所有有关角色的初始化好,再进入到重绘,重绘中我们要绘制背景其实就加个蒙层让我们看到界面的位置大小,然后绘制角色和角色名,绘制五维图。我们先开始讲述下前面的步骤,五维图最后再说。
2.绘制数据
我们绘制数据必须先要有数据,我们先写死四条数据吧。
/*app.js*/
render() {
const {spriteData, roleData} = this;
const _data = [{
name: "夏侯惇【元让】",
attrs: [
["统帅", 89],
["武力", 90],
["智力", 60],
["政治", 74],
["魅力", 88]
]
}, {
name: "关羽【云长】",
attrs: [
["统帅", 96],
["武力", 97],
["智力", 75],
["政治", 63],
["魅力", 94]
]
}, {
name: "张辽【文远】",
attrs: [
["统帅", 95],
["武力", 92],
["智力", 78],
["政治", 58],
["魅力", 77]
]
}, {
name: "张飞【翼德】",
attrs: [
["统帅", 86],
["武力", 98],
["智力", 33],
["政治", 22],
["魅力", 44]
]
}];
[...spriteData.entries()].forEach(([key, value], i) => {
roleData.push({
name: _data[i].name,
attrs: _data[i].attrs,
img: value
})
});
this.renderRole();
this.step()
}
我们把刚刚加载好的图片和角色数据一一对应的塞入roleData,这样我们需要哪条就可以从roleData利用roleIndex找到当前的角色了。
还等什么,先把人物立绘和姓名绘制出来看看吧。
/*app.js*/
drawRole() {
const {ctx, w, h, roleData,roleIndex} = this;
let role = roleData[roleIndex]
ctx.save();
ctx.translate(w - role.img.width * 0.65, (h - role.img.height) / 2);
ctx.scale(1, 1);
ctx.drawImage(role.img, 0, 0);
ctx.restore();
}
drawName() {
const {ctx, w, h, roleData,roleIndex} = this;
let role = roleData[roleIndex]
ctx.save();
ctx.translate(w * 0.1 + 120, h * 0.2);
ctx.font = `bold 24px fangsong, SimHei`;
ctx.textAlign = "center";
ctx.fillStyle = "#333"
ctx.fillText(role.name, 0, 0);
ctx.restore();
}
我们再考虑怎么切换他。至于五维图先放着,坏不了。
/*app.js*/
nextRole(){
this.roleIndex += 1;
this.roleIndex %= this.roleData.length;
this.renderRole();
}
因为初始化已经绑定了 click 事件会执行 nextRole ,所以,我们相当于点击画布切换角色。每次点击在当前标记上加一,再与角色数量取模,使其超过数量就从0重新开始。然后再就渲染五维图,这个稍后再处理。
3.多边形类
在写五维图之前,我们先写个实现多边形的类,作为组件类:
/*Polygon.js*/
class Polygon {
constructor(options) {
this.x = 0; // x轴坐标
this.y = 0; // y轴坐标
this.num = 5; // 边数
this.r = 120; // 半径
this.lineWidth = 3; // 线宽
this.strokeStyle = 'rgba(56,56,128,.5)'; // 线的颜色
this.fillStyle = ''; // 填充色
Object.assign(this, options);
this.ctx = null;
return this;
}
render(ctx) {
if (!ctx)
throw new Error("context is undefined.");
this.ctx = ctx;
this.draw();
return this;
}
draw() {
// 绘制
const {ctx, x, y, r, num, strokeStyle, lineWidth, fillStyle} = this;
ctx.save();
let angle = 360/(num+1);
ctx.translate(x, y);
ctx.beginPath();
let startX = r * Math.cos(angle);
let startY = r * Math.sin(angle);
ctx.moveTo(startX, startY);
for (let i = 1; i <= num; i++) {
let newX = r * Math.cos(Math.PI/180*360 * i / num + angle);
let newY = r * Math.sin(Math.PI/180*360 * i / num + angle);
ctx.lineTo(newX, newY);
}
ctx.closePath();
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.lineJoin = 'round';
ctx.stroke();
ctx.fillStyle = fillStyle;
ctx.fill();
ctx.restore();
}
}
export default Polygon;
这里绘制非常简单,假象成一个圆,然后得到多边形点的角度,根据角度去求坐标。至于 angle 这个值是我们想让他们旋转一个角度,使五边形的单顶点朝正上方,有助于美观。
4.五维图类
多边形类准备好我们要开始绘制五维图了。
/*AttrShap.js*/
// 引入多边形类
import Polygon from "./Polygon.js"
class AttrShap {
constructor(options) {
this.x = 0; // x轴坐标
this.y = 0; // y轴坐标
this.backgroundColor = "rgba(62,49,64,.35)"; // 图背景色
this.lineColor = "rgba(228,228,228,.36)"; // 线的颜色
this.valueColor = "rgba(199,100,73,.8)" // 五维属性色值
this.r = 120; // 半径
this.num = 5; // 边数
this.fontSize = 16; // 字体大小
this.valueMax = 120; // 最大数值
this.attrs = [ // 最初属性
["统帅", 0],
["武力", 0],
["智力", 0],
["政治", 0],
["魅力", 0]
]
Object.assign(this, options)
this.polygonList = []; // 多边形列表
this.attrMap = new Map(this.attrs); // 属性词典
this.angle = 360 / (this.num + 1); // 旋转角度
this.scale = .1; // 五维属性缩放大小
return this;
}
render(ctx, x, y) {
// 主渲染
if (!ctx)
throw new Error("context is undefined.");
this.ctx = ctx;
if (x)
this.x = x;
if (y)
this.y = y;
this.draw();
return this;
}
draw() {
// 绘制
this._drawBox();
this._drawLine();
this._drawKey();
this._drawValue();
}
_drawValue() {
// 绘制五维属性图
const {ctx, r, x, y, attrMap, num, valueColor, angle, valueMax,scale} = this;
ctx.save();
ctx.translate(x, y);
ctx.scale(scale,scale);
[...attrMap.values()].forEach((value, i) => {
let v = value / valueMax;
if (i == 0) {
ctx.moveTo(r * v * Math.cos(angle), r * v * Math.sin(angle));
}
let newX = r * v * Math.cos(Math.PI / 180 * 360 * i / num + angle);
let newY = r * v * Math.sin(Math.PI / 180 * 360 * i / num + angle);
ctx.lineTo(newX, newY);
})
ctx.fillStyle = valueColor;
ctx.fill();
ctx.restore();
this.scale += 0.025;
if(this.scale>=1){
this.scale = 1;
}
}
_drawKey() {
// 绘制五维相应文字
const {ctx, r, x, y, attrMap, num, fontSize, angle} = this;
[...attrMap.entries()].forEach(([key, value], i) => {
ctx.save();
ctx.translate(x, y);
let newX = (r + 24) * Math.cos(Math.PI / 180 * 360 * i / num + angle);
let newY = (r + 24) * Math.sin(Math.PI / 180 * 360 * i / num + angle);
ctx.font = `bold ${fontSize}px fangsong, SimHei`;
ctx.textAlign = "center";
ctx.fillStyle="#333";
ctx.fillText(key, newX, newY);
ctx.fillText(value, newX, newY + fontSize);
ctx.restore();
})
}
_drawLine() {
// 绘制图中心连线
const {ctx, r, x, y, lineColor, num, angle} = this;
for (let i = 0; i < num; i++) {
ctx.save();
ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, 0);
let newX = r * Math.cos(Math.PI / 180 * 360 * i / num + angle);
let newY = r * Math.sin(Math.PI / 180 * 360 * i / num + angle);
ctx.lineTo(newX, newY);
ctx.closePath();
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
ctx.stroke();
ctx.restore();
}
}
_drawBox() {
// 绘制图主题五边形底框
const {ctx, r, x, y, backgroundColor,lineColor} = this;
let n = 6;
for (let i = 0; i < n; i++) {
new Polygon({
x,
y,
lineWidth: i == 0 ? 3 : 1,
r: r - i * r / n,
fillStyle: i == 0 ? backgroundColor : 'rgba(0,0,0,0)',
strokeStyle:lineColor
}).render(ctx);
}
}
}
export default AttrShap;
我们底框的五边形绘制就用到了我们刚才封装好的多边形类。因为期望是蜘蛛网效果一层套一层,所以,我们在_drawBox方法里遍历生成多个半径逐渐减小的五边形套在一起。这样五维图的底框就完成了。
至于文字,中心连线,真实五维内容图的绘制都与其多边形绘制相似。
我们都要那他的角度利用三角函数求坐标。即:
let newX = (r+n) * Math.cos(Math.PI / 180 * 360 * i / num + angle);
let newY = (r+n) * Math.sin(Math.PI / 180 * 360 * i / num + angle);
这里文字与连线就不再赘述了。我们看 _drawValue 方法,他们主要是根据最大属性值跟数值换算求出真实半径,再通过角度求出当前顶点坐标,然后连接起来,形成真实数值的五维属性图。这里我们还期望,他是有个从小到大的动画,所以,每次绘制他都累加他的缩放大小,直至达到1。
然后我们回到主逻辑中:
/*app.js*/
renderRole() {
const {roleIndex, roleData, w, h, ctx} = this;
let role = roleData[roleIndex]
this.attrShap = new AttrShap({
x: w * 0.1 + 120,
y: h * 0.65,
attrs: role.attrs
}).render(ctx);
}
最后,我们要在主逻辑中实例化一下五维图类就大功告成了~
三国志武将的五维属性图在此就终结了,在线演示
拓展与延伸
现在如果还有需求,让用户选中五维图又当如何判断鼠标点中了呢,对于正五边形我们可以让他假设成圆形去判断,当然多少又有出入不大可以接收。但如果是不规则图形,我们需要用到分离轴定理和最小平移向量去做判断,相对较为复杂。
其实本来想做六边形战士的,但感觉太欺负人了,就还是回到三国志身上吧。。