本文不允许任何形式的转载!
阅读提示
本系列文章不适合以下人群阅读,如果你无意点开此文,请对号入座,避免浪费你宝贵的时间。
- 想要学习利用游戏引擎开发游戏的朋友。本文不会涉及任何第三方游戏引擎。
- 不具备面向对象编程经验的朋友。本文的语言主要是Javascript(ECMA 2016),如果你不具备JS编程经验倒也无妨,但没有面向对象程序语言经验就不好办了。
关注我的微信公众号,回复“源代码1”可获得本文示例代码下载地址,谢谢各位了!
上期作业
上期留了个作业,用Canvas 2d context的状态让一个方块绕中心缩放。这个很简单,首先移动到该方块的中心并缩放以改变当前坐标系:
// 假设有一个矩形,变量名为rect,具有left,top,width,height属性,
let scale = 1.5;
let transformX = rect.left + rect.width/2;
let transformY = rect.top + rect.height/2;
ctx.translate(transformX , transformY );
ctx.scale(scale,scale); // x和y轴同时缩放为以前的1.5倍
这时候如果开始绘制,就会发现这个矩形的左上角被移动到了其中心附近(并不是它之前的中心位置,参考上一期文章)并且发生了缩放。所以我们想要让这个矩形是按照它之前中心缩放的,只需要把坐标系进行反向移动,让此时的矩形中心和未改变之前的矩形中心重合。
由于整个坐标系被缩放,就不能直接translate(- transformX , -transformY )返回之前坐标系,在调用scale的时候已经和以前不一样了,被放大(或者缩小)了scale倍,所以移动回去的坐标计算如下:
let newx = transformX / scale - transformX;
let newy = transformY / scale - transformY;
ctx.translate(newx , newy);
ctx.rect(rect.left,rect.top,rect.width,rect.height);
ctx.fillStyle = '某颜色';
ctx.fill();
绘制封装
为了方便演示,我将在微信小游戏上进行代码测试,有兴趣的朋友可以去看看什么是微信小游戏,反正都是js代码,虽然跟浏览器端有本质上的区别吧。几点需要注意,微信小游戏不具备DOM,所以canvas是不能用document创建的,需要用它提供的接口wx.createCanvas , 同样Image也不能用dom方式创建,也要用wx.createImage来获取。
文中除了生成的类,其余代码都是在微信小游戏的game.js中编写的
我们一般在canvas上进行2d绘制一个图形,都会有以下几个步骤:
- 获得canvas的2d context,也就是我说的画笔
- 确定要绘制图形的大小以及左上角坐标
- 开始具体绘制图形
例如我想要画一个红色矩形,并且有个绿色的边框,大致代码如下:
let canvas = wx.createCanvas(); // 微信小游戏获得canvas的方法
let ctx = canvas.getContext('2d'); // 获得2d ctx
// 确定矩形的坐标位置以及大小
let left = 100;
let top = 100;
let width = 100;
let height = 100;
ctx.beginPath();
ctx.rect(left, top, width, height);
ctx.closePath();
ctx.fillStyle = 'red'; // 设置填充色
ctx.fill();
ctx.strokeStyle = 'green'; // 设置边框色
ctx.stroke();
输出结果:
如果我想要画100个矩形呢,初学者都懂,这就需要封装刚才的代码改成一个可调用的方法,参数就是坐标,大小,颜色等:
let canvas = wx.createCanvas();
let ctx = canvas.getContext('2d');
for (let i = 0; i < 100; i++) {
let left = Math.floor(Math.random() * canvas.width);
let top = Math.floor(Math.random() * canvas.height);
let width = Math.floor(Math.random() * 100);
let height = Math.floor(Math.random() * 100);
let color = randomColor();
drawRectangle(ctx, left, top, width, height, randomColor(),randomColor());
}
function randomColor() {
let r = Math.floor(Math.random() * 255);
let g = Math.floor(Math.random() * 255);
let b = Math.floor(Math.random() * 255);
return 'RGB(' + r + ',' + g + ',' + b + ')';
}
function drawRectangle(ctx, left, top, width, height, color, borderColor) {
ctx.save();
ctx.beginPath();
ctx.rect(left, top, width, height);
ctx.closePath();
if (color) {
ctx.fillStyle = color;
ctx.fill();
}
if (borderColor) {
ctx.strokeStyle = borderColor;
ctx.stroke();
}
}
输出结果:
经过封装后,我们就可以调用drawRectangle方法在canvas上绘制方块了。
Figure类的诞生
那么如果我要想要绘制更多的图形呢,比如我想要绘制一个圆,那我们也可以设计出这么一个方法,叫做drawCircle,同样,这个方法也可以给出一个左上角坐标,以及该圆形需要在canvas上显示的大小,比如:
let canvas = wx.createCanvas();
let ctx = canvas.getContext('2d');
for (let i = 0; i < 100; i++) {
let left = Math.floor(Math.random() * canvas.width);
let top = Math.floor(Math.random() * canvas.height);
let width = Math.floor(Math.random() * 100);
let height = width;//Math.floor(Math.random() * 100);
let color = randomColor();
let borderColor = randomColor();
drawCircle(ctx, left, top, width, height, color, borderColor);
}
function randomColor() {
let r = Math.floor(Math.random() * 255);
let g = Math.floor(Math.random() * 255);
let b = Math.floor(Math.random() * 255);
return 'RGB(' + r + ',' + g + ',' + b + ')';
}
function drawCircle(ctx, left, top, width, height, color, borderColor) {
if (width != height) return;
let radius = width / 2;
ctx.save();
ctx.beginPath();
ctx.arc(left + radius, top + radius, radius, 0, 2 * Math.PI);
ctx.closePath();
if (color) {
ctx.fillStyle = color;
ctx.fill();
}
if (borderColor) {
ctx.strokeStyle = borderColor;
ctx.stroke();
}
}
输出结果:
你肯定会注意到,不管我们绘制什么图形,实际上在2d画布上都可以看成在一个以(left,top)为左上角坐标,(width,height)大小的一个矩形区域内的绘制结果,不管是圆,多边形还是一个Image都是这样的。
我们可以这样抽象认为:在2d画布上的绘制的任何一个图形,都是在一个我们所给出的特定的矩形区域内的绘制结果,而这个矩形区域内绘制动作是可以我们需要进行替换修改的。这样一来我们就可以定义一个类,这个类专门负责描述我们所要绘制图形的属性以及一个可以调用的绘制方法接口,Figure.js:
let _lt = Symbol('左上角坐标Float32数组,0是x,1是y');
let _wh = Symbol('图形大小Float32数组,0是width,1是height');
export default class Figure{
constructor(p){
p = p || {};
this[_lt] = new Float32Array(2);
this[_wh] = new Float32Array(2);
this.left = p['left'] || 0;
this.top = p['top'] || 0;
this.width = p['width'] || 0;
this.height = p['height'] || 0;
}
get left(){
return this[_lt][0];
}
set left(value){
this[_lt][0] = value;
}
get top(){
return this[_lt][1];
}
set top(value){
this[_lt][1] = value;
}
get width(){
return this[_wh][0];
}
set width(value){
this[_wh][0] = value;
}
get height(){
return this[_wh][1];
}
set height(value){
this[_wh][1] = value;
}
/**
* 具体绘制方法
* @param ctx
*/
draw(ctx){
}
}
这是ECMA2016代码,可以定义类以及一个叫做symbol的变量,还不会ECMA2016的朋友可以去看看。注意,因为js是不能声明私有变量的,这里我用symbol模拟私有变量。有人会问为什么要用Float32Array去定义坐标以及长宽而不是直接定义成一个变量呢,个人认为定义成强类型这可以提高计算速度,至少省去了js类型判断和转换的操作,在日后的文章中,会涉及到很多线性代数的计算,都会用到强类型数组。
这个Figure类目前来看还什么都做不了,仅仅只是描述了图形的所在区域,不过要知道,这种是一个顶层类(js没有抽象类,我就叫它顶层类吧),我们是可以再定义一些有具体绘制方法的类继承之它,比如下面这个类Shape.js:
import Figure from "./Figure";
export default class Shape extends Figure {
constructor(p) {
p = p || {};
super(p);
this.color = p['color'] || '#000000';
this.borderColor = p['borderColor'] || '#000000';
}
draw(ctx) {
ctx.save();
ctx.beginPath();
this.drawShap(ctx);
ctx.closePath();
if (this.color) {
ctx.fillStyle = this.color;
ctx.fill();
}
if (this.borderColor) {
ctx.strokeStyle = this.borderColor;
ctx.stroke();
}
}
drawShape(ctx) {
}
}
Shape类继承自Figure,并且增加了color和borderColor属性,在draw方法里限制了path,以及根据color和borderColor决定是否要填充以及描边。
同样,Shape和Figure一样也算是一个顶层类,因为真正实施具体绘制的类在后面:
import Shape from "./Shape";
export default class Rectangle extends Shape{
constructor(p){
super(p);
}
drawShape(ctx){
ctx.rect(0,0,this.width,this.height);
}
}
Rectangle就是一个真正要进行绘制的类了,它实现了Shape的drawShape方法。
看上去很繁琐是吧,简单绘制一个矩形定义了尼玛3个类,没耐心的人肯定跳脚骂娘。我们慢慢往下看,你就知道就这么简单的绘制矩形还有很多变化,如果不靠抽象出顶层类去封装根本就不可能完成后续的工作。
我把刚才提到的绘制圆形的类也给出来:
import Shape from "./Shape";
export default class Circle extends Shape {
constructor(p) {
super(p);
}
get center() {
// 始终是由left和top以及区域大小来决定的
return {x: this.left + this.radius, y: this.top + this.radius};
}
set center(center) {
this.left = center.x - this.radius;
this.top = center.y - this.radius;
}
get radius() {
return this.width / 2;
}
set radius(r) {
this.width = r * 2;
}
set width(value) {
// 保证这个圆形所在区域是个正方形
super.width = value;
super.height = value;
}
set height(value) {
super.width = value;
super.height = value;
}
get width() {
return super.width;
}
get height() {
return super.height;
}
drawShape(ctx) {
ctx.arc(this.left+this.radius, this.top+this.radius, this.radius, 0, 2 * Math.PI);
}
}
实际上我们发现,工作量并没有变少,反而变多了。
引入新的属性以及context状态变化
现在我想画两个矩形,一个绕其中心旋转了45度的矩形,而另一个是半透明的。
那我们需要利用context状态变化配合绘制才能做到了,不明白的可以看我上一期文章。而这些旋转啊,透明度啊等等,如果体现在我们刚才设计的Figure类中,实际上就是用来描述这个图形一个属性而已,而且我们可以在不改变Rectangle类的情况下来增加这些属性(实际上Rectangle的父类Shape要改一个地方)。
注意,我们在改变一个Figure的状态时,是不能影响到其他figure的,看下Figure的draw方法,这是要被子类复写才能实现绘制的,但我们又要在这个方法里进行状态变化设置,所以我们要加入一个方法,叫做drawSelf,专门让子类复写实现绘制,而我们只需要在draw方法里进行状态的保存变化以及恢复即可:
let _lt = Symbol('左上角坐标Float32数组,0是x,1是y');
let _wh = Symbol('图形大小Float32数组,0是width,1是height');
export default class Figure {
constructor(p) {
p = p || {};
this[_lt] = new Float32Array(2);
this[_wh] = new Float32Array(2);
this.left = p['left'] || 0;
this.top = p['top'] || 0;
this.width = p['width'] || 0;
this.height = p['height'] || 0;
this.opacity = p['opacity'] || 1; // 新加入的透明度属性,默认为不透明
this.rotate = p['rotate'] || 0 // 新加入的旋转度数,默认为0
}
get left() {
return this[_lt][0];
}
set left(value) {
this[_lt][0] = value;
}
get top() {
return this[_lt][1];
}
set top(value) {
this[_lt][1] = value;
}
get width() {
return this[_wh][0];
}
set width(value) {
this[_wh][0] = value;
}
get height() {
return this[_wh][1];
}
set height(value) {
this[_wh][1] = value;
}
/**
* 对外调用的接口
* @param ctx
*/
draw(ctx) {
ctx.save(); // 保存之前状态,然后就可以任意修改了,最后恢复一下就可以保证在这个figure之外的其他地方状态不变
ctx.globalAlpha = this.opacity; // 设置透明度
// 我们默认以中心旋转
let center = {x: this.left + this.width / 2, y: this.top + this.height / 2};
ctx.translate(center.x, center.y);
ctx.rotate(this.rotate * Math.PI / 180); // 把角度转成弧度
ctx.translate(-center.x, -center.y);
this.drawSelf(ctx);
ctx.restore(); // 恢复到之前状态
}
/**
* 具体绘制自己方法
* @param ctx
*/
drawSelf(ctx) {
}
}
新的Figure类在draw方法中实现了旋转以及透明度的设置,再次友情提示,如果不明白绕中心旋转怎么写的,请退回去看我前一篇文章。实际上我们还需要把伸缩也加进去,我就不写了,文章一开始我就已经写好了。
这个draw方法子类就不要随便复写了,只要复写drawSelf方法就可以,所以我们还要把Shape类复写的draw方法改成drawSelf才算最终完成。
根据一开始我给出的case,我们的代码如下:
let canvas = wx.createCanvas();
let ctx = canvas.getContext('2d');
let rect = new Rectangle({
left: 100,
top: 100,
width: 100,
height: 200,
color: 'white',
rotate: 45
});
// rect1的左上角是rect的中心点
let rect1 = new Rectangle({
left: 150,
top: 200,
width: 200,
height: 200,
color: 'red',
opacity:0.5
});
rect.draw(ctx);
rect1.draw(ctx);
输出结果如下:
根据代码我们可以看出,图形被定义为了类后,每次绘制一个图形我们只需要描述出这个图形的基本属性即可,这大大增加了代码可读性,而且比起简单的方法封装,重构性和扩展性强太多太多,这也是为什么面向对象比起面向方法“高级”。“万物皆可对象”,这句话虽然是当年调侃java程序员的,但是仔细想想并不无道理。
子Figure是什么?
这一节很重要,请认真看
现在让我们重新思考一下,Figure类到底是什么。
我们定义Figure的初衷是希望能将绘制图形对象化,目前来看好像初步做到了。那好,我再给出一个case,我想要在画布上绘制两个图形,两个正方形,左上角坐标要重合,并且其中一个正方形的width是另外一个的一半。
按照目前我们的设计来看,那就定义两个Rectangle,然后两个Figure的左上角坐标要重合,并且设置好对应的width即可。看似并且有什么难度嘛,但我现在希望这两个图形要保证同时基于最大正方形的中心进行缩放和旋转呢?
如果你做过UI编程,应该不难发现,我们设计的Figure类有个很大的弊端,就是它只有一层结构,即所有Figure都是在canvas上的,大家都是同一level,没有层次结构。
而我们熟悉的html也好,xml也好,都是有层次结构的,所以Figure类也需要进行这样的修改,也就是我们常说的嵌套。
我们可以认为,一个Figure只是我们要绘制图形的一部分而已,整个图形还有其他部分需要绘制,而这些其他部分是可以嵌套到Figure中的。也就是说我们可以在Figure上增加子Figure,这样一来Figure就可以设计成为一个自包含的类,也就是设计模式中的composite模式,其实就是一颗树形的数据结构。每当我们调用Figure的draw方法的时候,除了要drawSelf外,还要逐个调用子FIgure的draw方法,形成一个递归绘制的过程。
Figure还需要具备一个parent属性,用来指向它的父节点。在Figure增加子节点的时候需要改变parent的值,如果该子节点已经有了父节点,那就要让它原来的父节点把它剔除掉;删除节点的时候需要把parent置为null或者undefiend。
让我们改造一下Figure类:
let _lt = Symbol('左上角坐标Float32数组,0是x,1是y');
let _wh = Symbol('图形大小Float32数组,0是width,1是height');
export default class Figure {
constructor(p) {
p = p || {};
this[_lt] = new Float32Array(2);
this[_wh] = new Float32Array(2);
this.left = p['left'] || 0;
this.top = p['top'] || 0;
this.width = p['width'] || 0;
this.height = p['height'] || 0;
this.opacity = p['opacity'] || 1; // 新加入的透明度属性,默认为不透明
this.rotate = p['rotate'] || 0 // 新加入的旋转度数,默认为0
this.children = []; // 增加子figure数组
this.parent = null;
}
indexOf(figure) {
for (let i = 0; i < this.children.length; i++) {
if (figure == this.children[i]) return i;
}
return -1;
}
addChild(figure) {
if (figure.parent) {
figure.parent.removeChild(figure);
}
this.children.push(figure);
figure.parent = this;
}
removeChild(figure) {
let index = this.indexOf(figure);
if (index != -1) {
this.children.splice(index, 1);
figure.parent = null;
}
}
getGraph() {
if (this.parent == null) {
return this;
} else {
return this.parent.getGraph();
}
}
get left() {
return this[_lt][0];
}
set left(value) {
this[_lt][0] = value;
}
get top() {
return this[_lt][1];
}
set top(value) {
this[_lt][1] = value;
}
get width() {
return this[_wh][0];
}
set width(value) {
this[_wh][0] = value;
}
get height() {
return this[_wh][1];
}
set height(value) {
this[_wh][1] = value;
}
/**
* 对外调用的接口
* @param ctx
*/
draw(ctx) {
ctx.save(); // 保存之前状态,然后就可以任意修改了,最后恢复一下就可以保证在这个figure之外的其他地方状态不变
ctx.globalAlpha = this.opacity; // 设置透明度
// 我们默认以中心旋转
let center = {x: this.left + this.width / 2, y: this.top + this.height / 2};
ctx.translate(center.x, center.y);
ctx.rotate(this.rotate * Math.PI / 180); // 把角度转成弧度
ctx.translate(-center.x, -center.y);
this.drawSelf(ctx);
this.drawChildren(ctx); // 绘制完自己后再绘制子Figure
ctx.restore(); // 恢复到之前状态
}
/**
* 子Figure的绘制
* @param ctx
*/
drawChildren(ctx) {
for (let i = 0; i < this.children.length; i++) {
let childFigure = this.children[i];
childFigure.draw(ctx);
}
}
/**
* 具体绘制自己方法
* @param ctx
*/
drawSelf(ctx) {
}
}
注意,我们在改变了Figure的状态后(旋转拉伸等)并没有恢复之前状态,而是直接调用drawChildren,所以Figure的状态是基于其父Figure状态的,这也正是我们想要的
那刚才我给的case就可以这样做了: 先定义一个较大Rectangle,然后再定义另一个较小的Rectangle,将这个较小的Rectangle作为figure加入到较大的Rectangle中,一旦较大的Rectangle(父figure)发生移动、旋转、拉伸,其子节点也会发生相应的变化且保持其在父节点的相对位置不变,比如以下代码:
import Rectangle from "./example/Rectangle";
let canvas = wx.createCanvas();
let ctx = canvas.getContext('2d');
let rect = new Rectangle({
left: 100,
top: 100,
width: 400,
height: 400,
color: 'white',
rotate: 45
});
// rect里嵌套了另一个rect
let childRect = new Rectangle({
left: 10,
top: 10,
width: 200,
height: 200,
color: 'red',
opacity:0.5
});
rect.addChild(childRect);
rect.draw(ctx);
rect具有一个子节点childRect,并且rect发生了旋转,childRect也跟着rect(它的父节点)一起进行了变换:
现在回过头再抽象地看下canvas,也就是我们的顶层画布,其实也是一个Figure啊,只是这个Figure的大小和左上角跟最顶层画布重合并且和canvas一样大,而我们刚才在canvas上画的那些矩形、圆形只是这个顶层Figure的一些子Figure而已。
那我们为什么不设计一个类来代表这个最顶层的画布呢:
import Figure from "./Figure";
export default class Graph extends Figure {
constructor(canvas) {
super();
this.canvas = canvas;
this.left = 0;
this.top = 0;
this.width = canvas.width;
this.height = canvas.height;
this.ctx = canvas.getContext('2d');
}
refresh() {
this.ctx.clearRect(0,0,this.width,this.height);
this.draw(this.ctx);
}
get parent(){
return null;
}
set parent(p){}
}
顶层画布是不具备parent的,所以这里对parent属性进行了修改。Graph维护了canvas2d的context,并具备一个独立的refresh方法,用来重新绘制整个canvas。
既然有了这个顶层类,那我们刚才的代码可以改成:
import Rectangle from "./example/Rectangle";
import Graph from "./example/Graph";
let canvas = wx.createCanvas();
let graph = new Graph(canvas);
let rect = new Rectangle({
left: 100,
top: 100,
width: 400,
height: 400,
color: 'white',
rotate: 45
});
let childRect = new Rectangle({
left: 10,
top: 10,
width: 200,
height: 200,
color: 'red',
opacity:0.5
});
rect.addChild(childRect);
graph.addChild(rect);
graph.refresh();
既然有了层级结构,那下面一部分就要好讲了。
到底用哪个坐标系?
我们习惯于在绘制的时候都处在所在画布的坐标系(或者说是在绘制图形所在的父节点坐标系中),比如lineTo(x,y),这里的x和y都是基于其所在画布的,纵观网上各种教程啊demo,都是这种习惯,很少提及绘制的时候使用的是自身的坐标系,可能是不太符合我们惯有的思维习惯。
我们刚才设计的Figure类,具有left和top两个坐标属性,我们习惯的认为当我们要去实现绘制方法的时候,left和top就是我们要绘制图形的左上角坐标。实际上不是,我们要绘制的图形的左上角坐标其实是(0,0),left和top只是这个图形在其父Figure坐标系中的位置而已,可能说的有点绕。
我们一开始设计出Figure类的目的就是想让绘制图形对象化,那么这个对象化的图形在绘制自身的时候,我们就应该将它所在区域看成一个以(0,0)作为原点(左上角),(width,height)作为右下角的这么一个区域,而我们的所有绘制操作都是在这个坐标系中完成的,比如之前Rectangle的绘制方法:
drawShape(ctx){
ctx.rect(this.left,this.top,this.width,this.height);
}
就应该改成:
drawShape(ctx){
ctx.rect(0,0,this.width,this.height);
}
但这种修改的前提是要在绘制操作调用之前切换坐标系,而这个操作在Figure类里是没有的,我们现在加上:
draw(ctx) {
ctx.save(); // 保存之前状态,然后就可以任意修改了,最后恢复一下就可以保证在这个figure之外的其他地方状态不变
ctx.translate(this.left, this.top); // 先切换到当前坐标系
ctx.globalAlpha = this.opacity; // 设置透明度
// 我们默认以中心旋转,坐标系发生变化后,中心坐标要以(0,0)为左上角计算
let center = {x: this.width / 2, y: this.height / 2};
ctx.translate(center.x, center.y);
ctx.rotate(this.rotate * Math.PI / 180); // 把角度转成弧度
ctx.translate(-center.x, -center.y);
this.drawSelf(ctx);
this.drawChildren(ctx); // 绘制完自己后再绘制子Figure
ctx.restore(); // 恢复到之前状态
}
看上去好像使用哪个坐标系都差不多,实际上使用自身坐标系来绘制是最好的,这样一来left和top并不干扰我们的实际绘制编码,而且更符合Figure的定义,即left和top只是描述该Figure的位置而已,和怎么去绘制这个Figure是没有关系的,此外在后续的文章里会慢慢体现出它的好处。
同样Figure的width和height其实也是只是描述该图形的大小而已,实际上这两个值在Figure子类实现绘制方法的时候并不能影响绘制结果,只能认为width和height这两个属性是一个约束值。这里就要涉及到ctx.clip方法(区域剪切),以及bounds判断等。这些以后再提
小结
这个Figure看上去很简单,实际上是大多数2d的graph程序都是这样设计的,而且是一个核心类。即使从canvas 2d迁移到webgl上,这个类也无需作为太多修改。
对此各位看官有什么看法呢?欢迎到其他博主文章下留言。
作业
按照我给出的Figure类,设计一个专门绘制图片的类,命名为FigureImage。(ctx有一个drawImage方法,根据这个方法来实现即可)