canvas 打砖块

755 阅读4分钟

1.文件目录

目录如下:

image-20211123104145955.png

  • draw.js——canvas绘图相关逻辑
  • event.js——发布/订阅 工具
  • globalVariable.js——存放一些全局变量
  • jsx.js——为了能使用jsx等语法,createElement, render, useState的简单实现
  • levelInfo.js——关卡信息

2.展示

2.1 截图

2.2 在线地址

www.lujiajian.xyz/html2/hit-b…

2.3 完整代码

github.com/lujiajian66…

3.游戏主要逻辑

3.2 canvas / css

因为涉及到小球分裂和道具的掉落,会生成大量节点,这个情况下选用canvas提高性能

3.3 图层

因为canvas没有图层的概念,每次都是先按范围擦除上一次的绘画,然后再重新绘画。所以如果所有的绘图都交给一个canvas完成,就会做重复的工作。

绘画的物体一共分为5类,砖块、球拍、道具和球,以及开始/游戏结束等文案。文案类型比较简单,直接用html处理即可。剩余的4类在canvas内绘画,这4类分为经常需要重绘的(球拍、道具、球)和不经常需要重绘的(砖块)。一个canvas单独负责画砖块,一个canvas负责画其他

3.4 帧数

游戏本质就是不停的绘画,需要控制绘画的次数。这里有requestAnimationFrame(浏览器在下次重绘之前触发) 和requestIdleCallback(浏览器空闲触发)这两个api,当requestIdleCallback触发次数远远少于requestAnimationFrame触发次数的时候,使用requestAnimationFrame比较好。因为运行次数再多,浏览器不刷新你也看不出来。经测试浏览器还是不太空闲,所以还是用requestIdleCallback,减少无效的调用

3.5 碰撞

3.5.1 碰撞检测

游戏的核心是检测小球和球拍/砖块之间的碰撞。这里网上有许多高端的算法,多边投影,向量等等,这里采用最朴素的计算方式。先找到砖块距离小球最近的一个点,然后计算圆心和这个点的距离。最接近的点伪代码如下:

if (circle.y > 砖块里最大的y) {
  targetPoint.y = 砖块里最大的y
} else if (circle.y < 砖块里最小的y) {
  targetPoint.y = 砖块里最小的y
} else {
  targetPoint.y = circle.y
}
if (circle.x > 砖块里最大的x) {
  targetPoint.x = 砖块里最大的x
} else if (circle.x < 砖块里最小的x) {
  targetPoint.x = 砖块里最小的x
} else {
  targetPoint.x = circle.x
}

在小球运动的过程中,每一帧都要对小球进行碰撞检测。但是砖块有很多,球也可能有很多,所以不能对所有的砖块作遍历。所以这里用砖块右上角的xy坐标作为map的key,做一次分类。这样小球只需要根据自身所在的坐标,在map里面找到对应的小球做碰撞检测就行,代码如下:

// 砖块分类
function classifyBricks (brickList) {
  const bricksMap = {}
  for (let i = 0; i < brickList.length; i++) {
    const brick = brickList[i]
    if (bricksMap[brick.x] === undefined) {
      bricksMap[brick.x] = {}
    }
    if (bricksMap[brick.x][brick.y] === undefined) {
      bricksMap[brick.x][brick.y] = brick
    }
  }
  return bricksMap
}

// 找出小球附近的格子
function findArroundBricks (circle) {
  const top = circle.y - circle.r
  const bottom = circle.y + circle.r
  const left = circle.x - circle.r
  const right = circle.x + circle.r
  const startBrickY = top - top % BRICK_HEIGHT
  const endBrickY = bottom - bottom % BRICK_HEIGHT
  const startBrickX = left - left % BRICK_WIDTH
  const endBrickX = right - right % BRICK_WIDTH
  const res = []
  for (let x = startBrickX; x <= endBrickX; x += BRICK_WIDTH) {
    for (let y = startBrickY; y <= endBrickY; y += BRICK_HEIGHT) {
      res.push({
        x,
        y,
        height: BRICK_HEIGHT,
        width: BRICK_WIDTH
      })
    }
  }
  return res
}
const arroundBricks = findArroundBricks(circle)
// 过滤没有砖块的格子
arroundBricks.filter((brick) =>
      brickClassifyMap[brick.x] &&
      brickClassifyMap[brick.x][brick.y] &&
      brickClassifyMap[brick.x][brick.y].show

3.5.2 碰撞面检测

小球碰撞后,需要对x,y方向的对应速度做处理。如果是x轴方向的碰撞,x取反;y轴亦然。所以需要知道到底是哪个方向发生了碰撞。判断的方法就是计算出碰撞时圆心和砖块中心组的向量和x轴的夹角,如果大于45度,那就是y轴方向,反之x轴方向。但是因为碰撞的时候已经重叠了,这个时候的圆心和砖块中心甚至有可能是重叠的。所以我们可以退而求其次用上一帧(尚未碰撞)的圆心计算。夹角计算代码

function getRadian (point1, point2) {
  // 夹角
  const vector1 = {
    x: point1.x - point2.x,
    y: point1.y - point2.y
  }
  const vector2 = {
    x: vector1.x > 0 ? 1 : -1,
    y: 0
  }
  // 向量的乘积
  const productValue = (vector1.x * vector2.x) + (vector1.y * vector2.y)
  // 向量1的模
  const vector1Value = Math.sqrt(vector1.x * vector1.x + vector1.y * vector1.y)
  // 向量2的模
  const vector2Value = Math.sqrt(vector2.x * vector2.x + vector2.y * vector2.y)
  // 余弦公式
  const cosValue = productValue / (vector1Value * vector2Value)
  return cosValue
}

vector1.x > 0 的判断是为了让夹角小于90度,应该不判断也可以。但是我余弦忘的7788了,超过90度我忘了怎么分析了

3.5.3 碰撞后归位

因为小球位移有,很大可能会和砖块重叠,这个时候需要把小球移动到没重叠的位置。如果不归为继续移动,会影响后续帧的碰撞面的判断,也有可能会一直卡在小球内部出不来造成鬼畜抖动。归为的逻辑就是根据碰撞面和小球速度的方向,把对应的坐标往后挪一点。伪代码:

// canvas 左上方是(0,0), y轴越往下,y越大,x越往右,x越大
if (x方向碰撞) {
  向右运动 ? (x - circle.r) : (x + 砖块宽度 + circle.r)
} else if (y方向碰撞) {
  向下运动 ? (y - circle.r) : (y + 砖块高度 + circle.r)
}

3.6 分裂小球

吃到分裂道具后,可以把一个球变成三个球。这里用百度方法,把小球的xy速度组成的向量旋转一下,然后增加到小球列表里面就行了。

// 逆时针旋转 deg 度的向量转换方法
function rotateVector (vector, deg) {
  const xSpeed = vector.x * Math.cos(deg) - vector.y * Math.sin(deg)
  const ySpeed = vector.y * Math.cos(deg) + vector.x * Math.sin(deg)
  return { xSpeed, ySpeed }
}

3.7 球拍移动

因为在电脑上按左右按钮控制移动,如果按一下立马把球拍移动,会有僵硬感。所以需要一些缓动效果。即点击一下右,第一帧向右移动10个单位,第二帧向右移动9个单位。。。。代码如下

function drawRacket ({ ctx, screenWidth, racket }) {
  const stepLength = racket.stepLength--
  if (stepLength <= 0) racket.stepLength = 0
  ctx.beginPath()
  ctx.fillStyle = 'red'
  const targetX = racket.x + racket.xVerctor * stepLength
  const rightBounds = screenWidth - racket.width
  const leftBounds = 0
  if (targetX > rightBounds || targetX < leftBounds) {
    racket.xVerctor = 0
    // point.xVerctor *= -1
  }
  racket.x = Math.max(leftBounds, Math.min(rightBounds, targetX))
  ctx.rect(racket.x, racket.y, racket.width, racket.height)
  ctx.fill()
}

缓动不仅能取消僵硬感,还能用来计算赋予小球的x方向的速度。就跟乒乓球一样,打球的时候,切球会赋予小球不同方向的速度。stepLength / maxStepLength * xVerctor 即为赋予小球的x方向的速度

3.8 删除小球/砖块/道具

因为同一帧可能会有很多小球/砖块/道具被删除,直接用splice删除会导致数组的多次变更。所以删除的时候直接给show属性赋予false,然后 获取渲染列表的时候做一次过滤操作。如

function drawProps (params) {
  ...
  param.propList = propList.filter((prop) => prop.show !== false)
  param.propList.forEach((prop) => {
    drawSingleProp(ctx, {
      prop,
      screenHeight,
      racket,
      remove: () => {
        prop.show = false
      }
    })
  })
}
function drawSingleProp (ctx, { prop, screenHeight, remove, racket }) {
  ...
  const res = checkIntersect(racket, prop, true).hasIntersect
  if (res) {
		...
    remove()
    ...
  }
  if (targetY > screenHeight) {
    remove()
  }
}