效果就如上图那样的,有个小球发射器,碰撞目标物体,碰撞了就化成星星,然后碰撞下一个,大概就像西西弗斯推石头那样周而复始。
项目地址: github.com/alasolala/c…
项目结构
整体框架和 连连看小游戏一样。
canvas的背景用的是渐变背景,模拟深蓝的天空。渐变背景的配色可以参考这个网站uigradients.com/#MoonPurple 这上面有很多渐变色方案。
#canvas{
display: block;
margin: 10px auto;
background-image: linear-gradient(#0F2027, #203A43, #2C5364);
}
实现
整个过程中主要有三个物体,小球、目标物体、星星,下面就分别来实现它们。
精灵
在制作动画的时候经常会看到“精灵”这个词,我的理解是,精灵是一种抽象,它表示一个物体,这个物体有自身属性和行为函数,你可以通过执行它的行为函数来改变它的自身属性,从而更新它的状态,比如位置、大小等。我们将要实现的三个物体都会基于精灵实现。
export default class Sprite {
constructor(name,painter,behaviors){ //精灵构造器接受三个参数:精灵的名称、绘制器及行为数组
if(name!==undefined) this.name = name;
if(painter!==undefined) this.painter = painter; //绘制精灵的绘制器
this.top = 0; //左上角的Y坐标
this.left = 0; //左上角的X坐标
this.width = 10; //宽度
this.height = 10; //高度
this.velocityX = 0; //水平速度
this.velocityY = 0; //垂直速度
this.visible = true; //是否可见
this.animating = false; //是否在运动中
this.behaviors = behaviors||[]; //行为数组
return this;
}
paint(ctx){
if(this.painter !== undefined && this.visible){ //绘制精灵
this.painter.paint(this, ctx);
}
}
update(ctx,time){
for(var i=0; i < this.behaviors.length; ++i){ //更新精灵状态,定义在行为数组中的行为都会被执行
this.behaviors[i].execute(this, ctx, time);
}
}
}
圆球发射器
圆球发射器是一个运动的小球,它的行为是在图像中运动,遇到左右边界回弹,遇到上下边界重置,回到初始状态,与目标物体相撞后变得不可见,直到爆炸完成后又回到初始状态。
它的绘制器如下:
export const BallPainter = {
//根据精灵的坐标和宽高绘制,其实颜色也可以由精灵定义,这里就一个小球,颜色就直接写了
//这里实现了绘制和位置大小等信息的解耦,比如需要绘制另外一个尺寸和位置的球,可以直接复用这个绘制器
paint(sprite,ctx){
var x = sprite.left + sprite.width/2,
y = sprite.top + sprite.height/2,
radius = sprite.width/2;
ctx.save();
ctx.beginPath();
ctx.arc(x, y,radius,0,Math.PI*2,false);
ctx.clip();
ctx.fillStyle = "#ff9966";
ctx.fill();
ctx.lineWidth=2;
ctx.strokeStyle='#ff5e62';
ctx.stroke();
ctx.restore();
}
}
Launcher类继承自Sprite类,扩展了重置和碰撞检测方法。它的行为函数是改变它的位置实现运动,并不断检测上下左右的边界碰撞情况。
import Sprite from "../base/sprite.js";
import { BallPainter } from "../base/painter.js"
import { Vector,Projection } from "../base/formula.js"
export default class Launcher extends Sprite{
constructor(startX,startY){
super('launcher', BallPainter,[
//behaviors数组中的每一个对象都一个execute方法
//time:execute方法执行的时刻,以毫秒为单位。
{
execute: (sprite, ctx, time) => {
if(sprite.lastMove === undefined) sprite.lastMove = time
let deltaTime = (time-sprite.lastMove) / 1000
sprite.left += sprite.velocityX * deltaTime;
sprite.top += sprite.velocityY * deltaTime;
//边界碰撞检测
//左右
if(sprite.left >= ctx.canvas.width - sprite.width || sprite.left <= 0){
sprite.velocityX = -sprite.velocityX
}
//上下
if(sprite.top <= -sprite.height || sprite.top > ctx.canvas.height){
sprite.resetAnimation()
sprite.resetLocation()
return
}
sprite.lastMove = time
}
}
]);
this.width = 30
this.height = 30
this.startX = startX - this.width / 2
this.startY = startY - this.height / 2
this.left = this.startX
this.top = this.startY
this.lastMove = undefined
this.collided = false
}
resetLocation(){
this.left = this.startX
this.top = this.startY
}
resetAnimation(){
this.lastMove = undefined
this.animating = false
}
//检测与目标物体的碰撞
checkCollision(target){
let circleX = this.left + this.width/2,
circleY = this.top + this.height/2;
let center = new Vector(circleX, circleY),
axes = [ ... target.axes]
//获取小球和最近的精灵顶点连线的平行轴
let minDistance, launcherAxes
target.points.forEach( p => {
let v = p.sub(center), d = v.getLength()
if(minDistance === undefined || minDistance > d){
minDistance = d;
launcherAxes = v.normalize();
}
});
axes.push(launcherAxes)
//将小球和目标分别投影在各个轴上,看投影是否重叠
for(let i=0; i<axes.length; i++){
//小球投影
let lmin = axes[i].dotProduct(center) - this.width/2,
lmax = axes[i].dotProduct(center) + this.width/2
let projection1 = new Projection(lmin,lmax)
//目标投影
let dp = []
target.points.forEach( p => {
dp.push(p.dotProduct(axes[i]))
})
let tmin = Math.min(... dp), tmax = Math.max(... dp)
let projection2 = new Projection(tmin, tmax)
if(!projection1.overlaps(projection2)){
return false
}
}
return true
}
}
目标物体
目标物体是选择雪碧图中的某一个物体。它的绘制器:
export class SpriteSheetPainter{
constructor(spritesheet,cells){
this.cells = cells||[]; //cells储存雪碧图中物体的位置和大小信息
this.cellIndex = 0; //当前物体索引
this.spritesheet = spritesheet //雪碧图
}
//要绘制的物体指向下一个
advance(){
if(this.cellIndex >= this.cells.length - 1){
this.cellIndex=0;
}else{
this.cellIndex++;
}
}
paint(sprite,ctx){
var cell=this.cells[this.cellIndex];
ctx.drawImage(this.spritesheet,cell.x,cell.y,cell.w,cell.h,sprite.left,sprite.top,sprite.width,sprite.height);
}
}
Target类也是继承自Sprite类,它的行为数组主要做两件事,一是切换雪碧图中下一个物体,二是改变它的位置信息,让它在画布一定范围内随机位置出现。扩展了顶点坐标和投影轴,用于碰撞检测。
import Sprite from "../base/sprite.js";
import { SpriteSheetPainter } from "../base/painter.js"
import { targetCells } from "../base/constant.js"
import { Vector } from "../base/formula.js"
export default class Target extends Sprite{
constructor(image){
super('target',new SpriteSheetPainter(image,targetCells),[
{
execute: (sprite,ctx,time) => {
sprite.painter.advance();
}
},
{
execute: (sprite,ctx,time) => {
sprite.resetLocation()
}
},
])
this.height = 40;
this.width = 40;
//每条边的法向量作为投影轴,这里比较简单,就是x、y轴
this.axes = [new Vector(0,1),new Vector(1,0)]
this.resetLocation()
}
resetLocation(){
this.top = parseInt(Math.random() * 200 + 30);
this.left = parseInt(Math.random() * 300 + 30);
this.points = [
new Vector(this.left, this.top),
new Vector(this.left + this.width, this.top ),
new Vector(this.left, this.top + this.height),
new Vector(this.left + this.width, this.top + this.height),
]
}
}
星星
五角星的绘制, 参考了liuyubobobo的五角星绘制方法。
export const StarPainter = {
paint(sprite,ctx){
ctx.save();
ctx.beginPath();
for(let i=0; i<5; i++){
ctx.lineTo(Math.cos((18 + i * 72) / 180 * Math.PI) * sprite.width / 2 + sprite.left,
-Math.sin((18 + i * 72) / 180 * Math.PI) * sprite.width / 2 + sprite.top);
ctx.lineTo(Math.cos((54 + i * 72) / 180 * Math.PI) * sprite.width / 4 + sprite.left,
-Math.sin((54 + i * 72) / 180 * Math.PI) * sprite.width / 4 + sprite.top)
}
ctx.closePath()
ctx.fillStyle = `rgba(${sprite.colors.join(",")}, ${sprite.alpha})`;
ctx.fill();
ctx.restore();
}
}
图形绘制完成后,就是让它动起来。这里模拟向四周散开的抛物线,初始速度this.speed
和速度方向this.angle
都是随机值。将初始速度分解到X和Y方向,X轴正方向到Y轴正方向是顺时针,this.angle
是X轴正方向 沿顺时针转到 速度方向的角度。
this.velocityX = Math.cos(this.angle) * this.speed
this.velocityY = Math.sin(this.angle) * this.speed
如果this.angle > Math.PI
,则 this.velocityY < 0
, 物体一开始将向上运动。
在后面的运动中,X方向的速度不变,Y方向有个向下的加速度,类似重力加速度,使物体的运动轨迹呈抛物线。
sprite.velocityY += sprite.gravity * deltaTime
sprite.left += sprite.velocityX * deltaTime;
sprite.top += sprite.velocityY * deltaTime;
Star类的行为方法主要也是改变它的位置信息,使它动起来。
import Sprite from "../base/sprite.js"
import { StarPainter } from "../base/painter.js"
import { colors } from "../base/constant.js"
class Star extends Sprite{ //继承自sprite类
constructor(startX, startY){
super("star", StarPainter,[
{
execute: (sprite, ctx, time) => {
if(sprite.lastMove === undefined) sprite.lastMove = time
let deltaTime = (time-sprite.lastMove) / 1000
sprite.velocityY += sprite.gravity * deltaTime
sprite.left += sprite.velocityX * deltaTime;
sprite.top += sprite.velocityY * deltaTime;
sprite.alpha -= 0.01
if(sprite.alpha <= 0){
sprite.visible = false
}
sprite.lastMove = time
}
}
])
this.left = startX
this.top = startY
this.width = 20
this.height = 20
this.speed = Math.random() * 180 + 50
this.angle = Math.random() * Math.PI * 2
this.velocityX = Math.cos(this.angle) * this.speed
this.velocityY = Math.sin(this.angle) * this.speed
this.gravity = 300
this.lastMove = undefined
this.alpha = 1
this.colors = colors[parseInt(Math.random() * 6)]
}
refresh(ctx,time){
this.update(ctx,time)
this.paint(ctx)
}
}
Painter对象就是一些可以互相交换着使用的绘制算法,在程序运行时,开发者可以将其设定给精灵对象。
这种特色表明,绘制器就是策略模式的一个实际用例。
碰撞检测
边界碰撞
边界碰撞比较简单,通过left/width 和 top/height 可以计算出小球左右边缘、上下边缘在画布中的位置。
//sprite.left + sprite.width >= ctx.canvas.width --->小球碰到画布右边界
//sprite.left <= 0 --->小球碰到画布左边界
if(sprite.left + sprite.width >= ctx.canvas.width || sprite.left <= 0){
sprite.velocityX = -sprite.velocityX
}
//sprite.top <= -sprite.height --->小球从画布上边界消失
//sprite.top > ctx.canvas.height --->小球从画布下边界消失
if(sprite.top <= -sprite.height || sprite.top > ctx.canvas.height){
sprite.resetAnimation()
sprite.resetLocation()
return
}
目标物体碰撞
目标物体是image图像绘制,它在canvas中的占位是矩形。所以碰撞其实是下面这样的。 这里我们用分离轴定理来检测两个物体是否相碰。分离轴定理可以检测两个凸多边形是否相碰。原理如下(图片来自《HTML5 Canvas核心技术——图形、动画与游戏开发》)。
操作分三步走:
- 1、确定投影轴。
- 2、分别计算两个物体在投影轴上的投影。
- 3、判断两个投影是否重叠。
只要在任意一条轴上找到相互分离的投影,就说明两个物体并未碰撞。
1、确定投影轴。
投影轴的数量等同于多边形的边数。比如说,上图中,三角形有3条边,而四边形有4条边,所以总共有7个投影轴。三角形的三个投影轴分别是垂直于三条边的,四边形同理。也就是说,找到多边形所有边的边向量,然后计算这些边向量的垂直向量就得到了投影轴的方向,因为要计算投影,所以将投影轴向量转化为单位向量。
多边形的边向量:已知该边的两个顶点坐标(a1, b1)、(a2, b2),边向量为(a1-a2, b1-b2)
当vector1=(x1,y1)
转动90度就得到它的垂直向量,用旋转矩阵可以算得垂直向量vector2=(y1,-x1)
化为单位向量:
let l = Math.sqrt(this.x * this.x + this.y * this.y)
this.x = this.x / l
this.y = this.y / l
2、计算多边形在投影轴上的投影。
要计算一个多边形在某个投影轴(也就是上面得到的单位向量)的投影,首先计算多边形各顶点向量与该单位向量的点乘,这一组点乘值中的最小值和最大值之间的范围就是投影。
let dp = []
target.points.forEach( p => {
dp.push(p.dotProduct(axes[i]))
})
let tmin = Math.min(... dp), tmax = Math.max(... dp)
let projection2 = new Projection(tmin, tmax)
3、判断两个投影是否重叠。
投影的两端,分别为min和max,当一个投影的min小于另一个的max时,那么前者的max必须大于后者的min它们才能重叠。
class Projection {
constructor(min, max){
this.min = min;
this.max = max;
}
overlaps(projection){
return this.min < projection.max && this.max > projection.min
}
}
在这个项目中,因为我们已经目标物体的边是分别平行于坐标轴,所以目标物体的投影轴可以简化为:
target.axes = [new Vector(0,1),new Vector(1,0)]
发射器是圆形,圆形与多边形相碰时,圆形的投影轴是圆心与距其最近的多边形顶点之间的连线的平行向量。
let minDistance, launcherAxes
target.points.forEach( p => {
let v = p.sub(center), d = v.getLength()
if(minDistance === undefined || minDistance > d){
minDistance = d;
launcherAxes = v.normalize();
}
});
碰撞检测
checkCollision(target){
let circleX = this.left + this.width/2,
circleY = this.top + this.height/2;
let center = new Vector(circleX, circleY),
axes = [ ... target.axes]
//获取小球和最近的精灵顶点连线的平行轴
let minDistance, launcherAxes
target.points.forEach( p => {
let v = p.sub(center), d = v.getLength()
if(minDistance === undefined || minDistance > d){
minDistance = d;
launcherAxes = v.normalize();
}
});
axes.push(launcherAxes)
//将小球和目标分别投影在各个轴上,看投影是否重叠
for(let i=0; i<axes.length; i++){
//小球投影
let lmin = axes[i].dotProduct(center) - this.width/2,
lmax = axes[i].dotProduct(center) + this.width/2
let projection1 = new Projection(lmin,lmax)
//目标投影
let dp = []
target.points.forEach( p => {
dp.push(p.dotProduct(axes[i]))
})
let tmin = Math.min(... dp), tmax = Math.max(... dp)
let projection2 = new Projection(tmin, tmax)
if(!projection1.overlaps(projection2)){
return false
}
}
return true
}
爆炸效果
当碰撞发生后,在目标物体的位置,初始化N个星星,星星呈抛物线运动,并且透明度逐渐减小,直到减小到小于等于0,视为一次爆炸完成。
export default class DrawStars {
constructor(startX,startY){
this.startX = startX
this.startY = startY
this.stars = this.initStars()
}
initStars(){
let arr = [], sum = Math.random()*10+10
for(let i = 0; i<sum; i++){
arr.push(new Star(this.startX, this.startY))
}
return arr
}
reset(x,y){
this.startX = x
this.startY = y
this.stars = this.initStars()
}
draw(ctx,time){
this.stars.forEach(star => {
star.refresh(ctx,time)
})
//只要任意一个透明度为0,就不可见,这里简化为第一个星星透明度为0就不可见
this.visible = this.stars[0].visible
}
}