Canvas如何绘制三国志五维属性图

1,061 阅读7分钟

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

前言

夫君子之行,静以修身,俭以养德,

非淡泊无以明志。非宁静无以致远。

——诸葛亮

介绍

本期的主题是三国,这是个一直伴随我们成长的话题,最开始是连环画,电视剧,小说,有了电脑后我们就开始沉迷这类游戏许久,傲世三国,吞食天地,三国英杰传,曹操传,孔明传,当然还有三国志系列。。。

言归正传,本期的目的就是制作三国志中各个人物五维图绘制和切换。

VID_20210826_160710.gif

我们将不借助任何引擎,用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绘制一张背景,让界面显得不那么单调。

微信截图_20210826162838.png

/*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();

这就是我所有要做的事。初始化我们要塞入一些要加载的角色图片,在加载器中逐个加载,加载完毕后自动进入主渲染里面。我们要在主渲染中将所有有关角色的初始化好,再进入到重绘,重绘中我们要绘制背景其实就加个蒙层让我们看到界面的位置大小,然后绘制角色和角色名,绘制五维图。我们先开始讲述下前面的步骤,五维图最后再说。

微信截图_20210826165412.png

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();
}

微信截图_20210826170109.png

我们再考虑怎么切换他。至于五维图先放着,坏不了。

/*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方法里遍历生成多个半径逐渐减小的五边形套在一起。这样五维图的底框就完成了。

至于文字,中心连线,真实五维内容图的绘制都与其多边形绘制相似。

微信截图_20210827085127.png

我们都要那他的角度利用三角函数求坐标。即:

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);
}

最后,我们要在主逻辑中实例化一下五维图类就大功告成了~


三国志武将的五维属性图在此就终结了,在线演示

拓展与延伸

现在如果还有需求,让用户选中五维图又当如何判断鼠标点中了呢,对于正五边形我们可以让他假设成圆形去判断,当然多少又有出入不大可以接收。但如果是不规则图形,我们需要用到分离轴定理和最小平移向量去做判断,相对较为复杂。


其实本来想做六边形战士的,但感觉太欺负人了,就还是回到三国志身上吧。。

src=http___wx1.sinaimg.cn_large_005xspZlly1gsyuprqeimj60ru0fogno02.jpg&refer=http___wx1.sinaimg.jpg