1.文件目录
目录如下:
- 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 完整代码
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()
}
}