这次做一个终极的练习,先看一下最后的效果。
一个不停奔跑的小人,点击鼠标后会让他跑到目的地,并且呈现不同的角度。下面来看一下如何一步步来实现它的。
准备
网上下载了一张图片,其中包含了小人面向不同角度奔跑的各个分解动作。
新建一个html文件,放入常规的canvas元素以及script标签。
第0版
这一版的目的是在canvas上画出素材里的其中一个小人。其中,事先经过了测量,每一个小人横向占70像素,纵向占92像素。
这里主要用到了CanvasRenderingContext2D的drawImage方法,特别是这个方法的九参数版本。
void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
// image 绘制到上下文的元素
// sx 需要绘制到目标上下文中的,image的矩形(裁剪)选择框的左上角 X 轴坐标。
// sy 需要绘制到目标上下文中的,image的矩形(裁剪)选择框的左上角 Y 轴坐标。
// sWidth 需要绘制到目标上下文中的,image的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的sx和sy开始,到image的右下角结束。
// sHeight 需要绘制到目标上下文中的,image的矩形(裁剪)选择框的高度。
// dx image的左上角在目标canvas上 X 轴坐标。
// dy image的左上角在目标canvas上 Y 轴坐标。
// dWidth image在目标canvas上绘制的宽度。 允许对绘制的image进行缩放。 如果不说明, 在绘制时image宽度不会缩放。
// dHeight image在目标canvas上绘制的高度。 允许对绘制的image进行缩放。 如果不说明, 在绘制时image高度不会缩放
完成后效果如下图,目前代码很简单,节约篇幅,就不具体贴了。
第1版
这一版的目的是要让小人奔跑起来,但位置可以不变。也就是利用人的视觉停留,在canvas上不停地重绘小人,给人以奔跑的感觉。
这里以及后面的代码都做了一个假设就是requestAnimationFrame是被系统稳定调用的,没有阻塞的情况发生,这样子就简化了代码,不需要根据两次requestAnimationFrame调用的时差来进行一些计算。
const image = new Image()
image.src = 'walking.jpeg'
let index = 0 //计数,每次渲染时+1
const HSTEP = 70 //横向每帧间隔距离
const VSTEP = 92 //纵向每帧间隔距离
let lastTime = 0 //记录上次时间戳
image.onload = () => {window.requestAnimationFrame(walking) //启动动画
}
function walking(timestamp) {if (timestamp - lastTime > 80) { //满时差后渲染lastTime = timestampcontext.drawImage(image, HSTEP * (index++ % 8), VSTEP * 2, HSTEP, VSTEP, 0, 0, HSTEP, VSTEP)}window.requestAnimationFrame(walking)
}
里面有个魔鬼数字VSTEP * 2
这个2是从上向下的第三组图片,也即是小人面向正右方的分解动作。
第2版
这次在上一版的基础上增加了小人的坐标移动。所要多做的仅仅是在drawImage方法参数中改变画图位置的坐标。
const image = new Image()
image.src = 'walking.jpeg'
let index = 0 //计数,每次渲染时+1
const HSTEP = 70 //横向每帧间隔距离
const VSTEP = 92 //纵向每帧间隔距离
const MOVESTEP = 10 //每帧移动距离
let lastTime = 0 //记录上次时间戳
let xPos = 0 // x坐标位置
image.onload = () => {window.requestAnimationFrame(walking) //启动动画
}
function walking(timestamp) {if (timestamp - lastTime > 80) { //满时差后渲染lastTime = timestampif (xPos < canvas.width - HSTEP) {xPos = index * MOVESTEP}context.clearRect(0, 0, canvas.width, canvas.height)context.drawImage(image, HSTEP * (index++ % 8), VSTEP * 2, HSTEP, VSTEP, xPos, 0, HSTEP, VSTEP)}window.requestAnimationFrame(walking)
}
第3版
这次也是小步迭代,增加了小人纵向移动,换了另一个角度的动作。
const image = new Image()
image.src = 'walking.jpeg'
let index = 0 //计数,每次渲染时+1
const HSTEP = 70 //横向每帧间隔距离
const VSTEP = 92 //纵向每帧间隔距离
const MOVESTEP = 10 //每帧移动距离
let lastTime = 0 //记录上次时间戳
let xPos = 0 // x坐标位置
let yPos = 0 // y坐标位置
image.onload = () => {window.requestAnimationFrame(walking) //启动动画
}
function walking(timestamp) {if (timestamp - lastTime > 80) { //满时差后渲染lastTime = timestampif (xPos < canvas.width - HSTEP) {xPos = index * MOVESTEP}if(yPos < canvas.height - VSTEP) {yPos = index * MOVESTEP}context.clearRect(0, 0, canvas.width, canvas.height)context.drawImage(image, HSTEP * (index++ % 8), VSTEP * 5, HSTEP, VSTEP, xPos, yPos, HSTEP, VSTEP)}window.requestAnimationFrame(walking)
}
魔鬼数字换成了5哈。
最终版
先定义个class用来存放x、y轴的坐标,这个类的两个实例分别对应了当前的坐标和目的地的坐标。
class Position {constructor(x, y) {this.x = xthis.y = y}
}let currentPos = new Position(0, 0) //当前位置
let targetPos = new Position(0, 0) //目标位置
移动时的相关新建变量有
let angle = 0
let movingAngleType = 2 //角度类型一共8种
let xMoveStep //x轴每次移动距离
let yMoveStep //y轴每次移动距离
其中angle
在鼠标点击时,即可通过当前位置和鼠标点击位置计算得出。movingAngleType
就对应了八种奔跑角度的分解动作。xMoveStep
和yMoveStep
是每次渲染时x轴和y轴移动的距离,跟anglue
一样,也只要在点击的时候计算出来就行。
每次渲染时,计算目标点和当前点的距离差,如果大于步进,就加上步进,如果小于,就将目标点位置赋给当前点。
剩下的事情就是计算了,这一次好好的把三角函数复习了一遍。这个例子中的角度是在-90° ~ 270°之间。Math.asin
计算结果是在-π ~ π 之间的,而canvas的坐标轴原点位于左上角,并且根据位置不同要如下计算
if (targetPos.x < currentPos.x) {angle = Math.PI - angle
}
上面这段代码就把360°的坐标轴分在了-90° ~ 270°之间,而不是0 ~ 360°,影响到了移动角度类型,也即是从上至下的八个动作的选取。-75° ~ -15°是右下角度,-15° ~ 15°是右角度,依次类推。
完整的代码如下
class Position {constructor(x, y) {this.x = xthis.y = y}
}
const canvas = document.getElementById('canvas')
const context = canvas.getContext('2d')
const image = new Image()
image.src = 'walking.jpeg'
let index = 0 //计数,每次渲染时+1
const HSTEP = 70 //横向每帧间隔距离
const VSTEP = 92 //纵向每帧间隔距离
const MOVESTEP = 10 //每帧移动距离
const TIMESTAMP_THRESHOLD = 80 // 时差阈值
const FRAMES_LENGTH = 8
let lastTime = 0 //记录上次时间戳
let currentPos = new Position(0, 0) //当前位置
let targetPos = new Position(0, 0) //目标位置
let angle = 0
let movingAngleType = 2 //角度类型一共8种
let xMoveStep //x轴每次移动距离
let yMoveStep //y轴每次移动距离
image.onload = () => {window.requestAnimationFrame(walking) //启动动画
}
function windowToCanvas(canvas, x, y) {var bbox = canvas.getBoundingClientRect();var style = window.getComputedStyle(canvas);return {x: (x - bbox.left - parseInt(style.paddingLeft) - parseInt(style.borderLeft))* (canvas.width / parseInt(style.width)),y: (y - bbox.top - parseInt(style.paddingTop) - parseInt(style.borderTop))* (canvas.height / parseInt(style.height))};
}
function walking(timestamp) { //抽象成:在什么位置,画什么图,完全由状态数据决定 UI = f(state)if (timestamp - lastTime > TIMESTAMP_THRESHOLD) { //满时差后渲染lastTime = timestamp//计算步进,MOVESTEP要投射到x轴和y轴上// 这里xMoveStep一开始没取绝对值,调试了好久if (Math.abs(targetPos.x - currentPos.x) > Math.abs(xMoveStep)) { currentPos.x += xMoveStep} else {currentPos.x = targetPos.x}if (Math.abs(targetPos.y - currentPos.y) > Math.abs(yMoveStep)) {currentPos.y += yMoveStep} else {currentPos.y = targetPos.y}context.clearRect(0, 0, canvas.width, canvas.height)context.drawImage(image, HSTEP * (index++ % FRAMES_LENGTH), VSTEP * movingAngleType,HSTEP, VSTEP, currentPos.x, currentPos.y, HSTEP, VSTEP)}window.requestAnimationFrame(walking)
}canvas.addEventListener('click', e => {const position = windowToCanvas(canvas, e.clientX, e.clientY)targetPos.x = position.x - HSTEP / 2 //鼠标点击点为人物的中心,所以减去一半宽度targetPos.y = position.y - VSTEP / 2 //鼠标点击点为人物的中心,所以减去一半高度//计算angleangle = Math.asin((targetPos.y - currentPos.y) / Math.sqrt(Math.pow(targetPos.y - currentPos.y, 2)+ Math.pow(targetPos.x - currentPos.x, 2)))if (targetPos.x < currentPos.x) {angle = Math.PI - angle}xMoveStep = MOVESTEP * Math.cos(angle) //只用计算一次yMoveStep = MOVESTEP * Math.sin(angle) //判断哪种角度的奔跑if(angle >-5 * Math.PI /12 && angle < -Math.PI /12) {movingAngleType = 7 //右上} else if(angle >= -Math.PI / 12 && angle <= Math.PI / 12 ) {movingAngleType = 2 //右} else if(angle > Math.PI /12 && angle < 5 * Math.PI / 12) {movingAngleType = 5 //右下} else if(angle >= 5 * Math.PI /12 && angle <= 7 * Math.PI /12) {movingAngleType = 0 //下} else if(angle > 7 * Math.PI /12 && angle < 11 * Math.PI /12) {movingAngleType = 4 //左下} else if(angle >= 11 * Math.PI /12 && angle <= 13 * Math.PI /12) {movingAngleType = 1 //左} else if(angle > 13 * Math.PI /12 && angle < 17 * Math.PI /12) {movingAngleType = 6 //左上} else {movingAngleType = 3 //上}
})
代码贴到了github上
swordrain/runninggithub.com补充一下,其实还有一种思路是不用计算小人的坐标,而是移动canvas坐标轴,每次都只在(0, 0)处绘制小人即可。