前言
来张博人传漫画截图镇一镇,毕竟世间万物唯独博人传燃不起来,啥都能燃,希望文章也能燃起来!哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈!
最近在写一个用纯CSS+JS的打飞机小游戏,期间涉及了碰撞检测的机制,但我发现唯一的检测方式就是遍历,如果物体实例(比如飞机的子弹)多了,岂不是一碰撞就卡死?
当然啦,也有很多种方法进行筛选优化,且现在的物理引擎的碰撞检测算法早就已经非常成熟了。
于是我在洗澡的时候就想到了一个诡异的碰撞检测机制,就马不停蹄地熬了个大夜,做了个简易的demo。
当然我只是个前端切图仔,没有接触过什么大型算法或者物理引擎原理,也不知道这种操作在更大的模型下是否会更优,或者是否有人曾经提出过甚至实现过,在这里仅仅是分享我个人的yy和实现。
Demo效果
原理
原始的操作是遍历所有实例并计算边界和中心点的距离,是否就可以利用存储性能来换取计算性能呢?以像素为单位是否能够更精确?我无从得知,也没那么大能力去实践,所以就只能抱着无数疑惑去做个Demo给大伙康康,莫见笑。
这里边涉及的各种矩形、圆形和多边形的碰撞检测算法就不继续赘述了。因为在这里不需要这些难以理解的碰撞算法(对我个人而言,特别是多边形的检测,看到都头大)。
这里所遵循的只有一条规则:
- 为每个像素创建一个内存实例,记录占有了该像素的物体实例ID
相信大家都能猜到了,这里的碰撞检测其实就是用像素实例记录的物体实例ID表进行碰撞检测,只要为true的,则都表示互相碰撞了!
通用常量/变量
先把用到的变量列出来,以免后面各位看官看不太明白
let WindowWidth = window.innerWidth // 屏幕宽度
let WindowHeight = window.innerHeight // 屏幕高度
let WindowArea = WindowWidth * WindowHeight // 屏幕面积
const ScreenScale = 1 // 屏幕缩放比
const BlockColProto = document.getElementsByClassName("blockColumn")[0] // 像素列原型Dom
const ScreenDom = document.getElementsByTagName("body")[0] // 容器Dom
const ScreenRect = ScreenDom.getBoundingClientRect() // 容器rect数据
const BlockProto = document.getElementsByClassName("block")[0] // 像素块原型Dom
// {id, blockDom, originColor, objectsId, objectColors}
let Blocks = {} // 像素块实例Map
const BlockNum = 10000 // 像素块大致数量(只是大概,根据该值转换出的容器宽高比而得出的横向纵向的像素块数量,不一定为整数,此时会向下取整,保证像素块数量为整数,而多出来不足一个像素块的小数位,则会将其多余的宽高平均到其余每一个像素块中,便于恰好完整覆盖整个容器,因此 最终像素块数量 ≤ BlcokNum)
let PosProportion = Math.sqrt(BlockNum / WindowArea) // 容器宽高转换比例
let ScreenRowBlockNum = Math.floor(PosProportion * WindowWidth) // 容器横向像素块数量
let ScreenColumnBlockNum = Math.floor(PosProportion * WindowHeight) // 容器纵向像素块数量
let BlockWidth = Math.floor(WindowWidth / ScreenRowBlockNum) // 像素块宽度
let BlockHeight = Math.floor(WindowHeight / ScreenColumnBlockNum) // 像素块高度
// 相对window的容器内最大和最小X和Y坐标值
let WindowForScreenMinX = ScreenRect.left
let WindowForScreenMinY = ScreenRect.top
let WindowForScreenMaxX = ScreenRect.left + BlockWidth * ScreenRowBlockNum
let WindowForScreenMaxY = ScreenRect.top + BlockHeight * ScreenColumnBlockNum
// 为了方便观测设置像素块的颜色
const BlackColor = 'rgb(0, 0, 0)'
const WhiteColor = 'rgb(255, 255, 255)'
const MouseObjectName = 'mouse' // 匹配鼠标的物体对象名
let MouseObjectId = '' // 匹配鼠标的物体实例Id
const Objects = {} // 物体实例Map
第一步:创建像素参考系
当然不可能直接用html的像素作为参考系,像素太大了,先不说html的性能限制,对于一个没有任何优化的粗糙demo来说,像素点太多的情况下肯定会卡死,于是就创建了一个按屏幕比例构建的像素参考系:
// 清空Screen
const clearScreen = () => {
// 移除所有像素块Dom
for (let key in Blocks) {
Blocks[key].blockDom.remove()
}
// 清空像素块实例对象
Blocks = {}
}
// 创建screen
const createScreen = () => {
// 清除原来的screen
clearScreen()
// 重新获取基础数据
WindowWidth = window.innerWidth
WindowHeight = window.innerHeight
WindowArea = WindowWidth * WindowHeight
PosProportion = Math.sqrt(BlockNum / WindowArea)
ScreenRowBlockNum = Math.floor(PosProportion * WindowWidth)
ScreenColumnBlockNum = Math.floor(PosProportion * WindowHeight)
BlockWidth = (WindowWidth / ScreenRowBlockNum)
BlockHeight = (WindowHeight / ScreenColumnBlockNum)
// console.log(WindowWidth, WindowHeight)
// console.log(ScreenColumnBlockNum, ScreenRowBlockNum)
// console.log(BlockWidth, BlockHeight)
// console.log(BlockWidth*ScreenRowBlockNum, BlockHeight*ScreenColumnBlockNum)
// 构建像素块实例对象
for (let r = 0; r < ScreenRowBlockNum; r++) {
const colDom = BlockColProto.cloneNode(true)
colDom.style.display = "flex"
for (let c = 0; c < ScreenColumnBlockNum; c++) {
const BlockItem = BlockProto.cloneNode(true)
BlockItem.style.width = `${BlockWidth}px`
BlockItem.style.height = `${BlockHeight}px`
BlockItem.style.display = `block`
let originColor = ''
// 为方便观测,为像素块交错渲染黑白两色
if (c % 2) {
if (r % 2) originColor = BlackColor
else originColor = WhiteColor
} else {
if (r % 2) originColor = WhiteColor
else originColor = BlackColor
}
BlockItem.style.backgroundColor = originColor
const id = `${r}_${c}`
BlockItem.setAttribute("id", id)
Blocks[id] = {
id,
blockDom: BlockItem,
originColor,
objectsId: {}
}
colDom.appendChild(BlockItem)
}
document.body.appendChild(colDom)
}
WindowForScreenMinX = ScreenRect.left
WindowForScreenMinY = ScreenRect.top
WindowForScreenMaxX = ScreenRect.left + BlockWidth * ScreenRowBlockNum
WindowForScreenMaxY = ScreenRect.top + BlockHeight * ScreenColumnBlockNum
}
第二步:构建物体实例对象
定义物体原型对象
// 物体原型
const ObjectsProto = {
mouse: {
zIndex: 100, // 物体层级
centerRelativePos: { x: 2, y: 2 }, // 坐标从0起数,相对物体本身的宫格内的坐标
coverRelativeRowPosStr: [
'01010',
'11111',
'11111',
'01110',
'00100'], // 该物体的宫格二进制表示,1表示在占据的该宫格的像素块,0表示未占据
color: 'rgb(255, 0, 0)', // 为方便和突出算法机制展示,暂时仅支持单色
// 物体碰撞回调
crashCallback: function (crashObjKey) {
console.log(`${this.id}: ${crashObjKey}和我碰撞了`)
showTips({text: `${this.id}: ${crashObjKey}和我碰撞了`})
},
},
test1: {
zIndex: 99,
centerRelativePos: { x: 2, y: 2 }, // 坐标从0起数,相对图形本身的宫格内的坐标
coverRelativeRowPosStr: ['01010', '11111', '11111', '01110', '00100'], // 5*5宫格
color: 'rgb(0, 0, 255)',
crashCallback: function (crashObjKey) {
console.log(`${this.id}: ${crashObjKey}和我碰撞了`)
// showTips({text: `${this.id}: ${crashObjKey}和我碰撞了`})
},
}
}
构建物体实例对象
// 构建物体实例对象
const createObject = (objName, crashCb) => {
// 生成物体实例唯一ID
const id = `${objName}_${Math.random().toString(36).slice(2)}`
Objects[id] = {
...JSON.parse(JSON.stringify(ObjectsProto[objName])),
id,
currentCenterPosKey: '', //
lightingPos: [], // ["x_y", ...]
crashObjects: {},
}
// 碰撞回调函数( Function.call用于将this指向变更,此处变为物体实例对象本身 )
crashCallback = (crashObjKey) => {
if (crashCb) {
crashCb.call(Objects[id], crashObjKey)
return
}
ObjectsProto[objName].crashCallback.call(Objects[id], crashObjKey)
}
Objects[id].crashCallback = crashCallback
return id
}
第三步:在像素参考系中绘制物体
const getObjectRelativePos = (objId) => {
const { centerRelativePos, coverRelativeRowPosStr } = Objects[objId]
const centerX = centerRelativePos.x // 相对物体宫格内的X坐标
const centerY = centerRelativePos.y // 相对物体宫格内的Y坐标
const lightPos = [] // 用于存储该物体所占的像素块相对screen的坐标
const centerPos = { x: 0, y: 0 } // 用于存储该物体中心点所占的像素块相对screen的坐标
// 计算获取lightPos的所有坐标值
for (let r = 0; r < coverRelativeRowPosStr.length; r++) {
const col = coverRelativeRowPosStr[r].split('')
for (let c = 0; c < col.length; c++) {
const x = c - centerY
const y = r - centerX
// 列 = x , 行 = y
if (col[c] === '1') lightPos.push({ x, y })
}
}
return lightPos
}
// 获取像素块当前应该展示的颜色
const getBlockColor = (pos) => {
const { originColor, objectsId } = Blocks[pos]
let bcolor = originColor
let zi = 0
// 像素块使用哪个物体的渲染颜色,取决于占有该像素块的物体的最大层级
for (let okey in objectsId) {
if (objectsId[okey]) {
const { zIndex, color } = Objects[okey]
if (zIndex > zi) {
zi = zIndex
bcolor = color
}
}
}
return bcolor
}
// 清除物体移动后已失去的像素块的颜色
const clearObjectLightingPos = (objKey, ignorePos = {}) => {
// ignorePos为可忽略的点集合,减少需要处理的像素块,提高像素块处理的精确度
// 遍历当前物体实例已占有的像素块,并过滤掉已经不再占有的像素块坐标key
Objects[objKey].lightingPos = Objects[objKey].lightingPos.filter(pos => {
if (ignorePos[pos]) return true
let { blockDom, originColor, objectColors } = Blocks[pos]
// 在像素块的占有者列表中,将该物体实例置false
Blocks[pos].objectsId[objKey] = false
// 重新渲染像素块颜色
blockDom.style.backgroundColor = getBlockColor(pos)
return false
})
}
// 基于单个像素块的碰撞检测
const crashCheck = (objKey, pos) => {
const { objectsId } = Blocks[pos]
const crashObjectsKey = [] // 用于记录被当前物体实例碰撞的物体实例列表
// 遍历占有当前像素格的物体标记
for (let okey in objectsId) {
// 跳过自身
if (okey === objKey) continue
// 当前像素格内的该物体标记同为true,则表示当前物体与该物体发生了碰撞
if (objectsId[okey]) {
crashObjectsKey.push(okey)
continue
}
}
return crashObjectsKey
}
const lightObjectBlock = (objKey, pos) => {
const { color, zIndex, currentCenterPosKey, centerRelativePos, crashObjects, crashCallback } = Objects[objKey]
// 转化当前物体的宫格二位数据
const lightPos = getObjectRelativePos(objKey)
// 当前物体实例中心点相对screen的坐标Key
const centerPosKey = `${pos.x - centerRelativePos.x}_${pos.y - centerRelativePos.y}`
// 中心点未变化,忽略本次处理
if (currentCenterPosKey === centerPosKey) return
// 更新当前物体实例的中心点相对screen的坐标
Objects[objKey].currentCenterPosKey = centerPosKey
const clearIgnorePos = {} // 用于记录新的物体实例坐标Map,并作为clearObjectLightingPos中无需处理的坐标过滤Map
const crashObjectsKey = new Set() // 整个物体实例所占的所有像素块,仅有一个像素块能够触发碰撞反馈,即只触发一次碰撞检测
// 点亮像素块,遍历每个像素块的碰撞情况
lightPos.forEach(rpos => {
const lightX = pos.x + rpos.x
const lightY = pos.y + rpos.y
const blockPos = `${lightX}_${lightY}`
// 当前screen坐标在screen可视范围(存在该像素块)
if (Blocks[blockPos]) {
const { blockDom } = Blocks[blockPos]
// 像素块占有者列表中,将该物体实例置为true
Blocks[blockPos].objectsId[objKey] = true
// 当前获取像素块应该展示的颜色
const newColor = getBlockColor(blockPos)
// 若新颜色与旧颜色一致,则无需渲染,减少渲染性能损耗
if (newColor !== blockDom.style.backgroundColor) {
blockDom.style.backgroundColor = newColor
}
// 将新占有的坐标Key记录进物体实例中
Objects[objKey].lightingPos.push(blockPos)
clearIgnorePos[blockPos] = true
// *碰撞检测
crashCheck(objKey, blockPos).forEach(okey => {
// 已经触发过碰撞检测的,则返回(已碰撞的前提下再次发生碰撞,仅触发一次碰撞回调)
// 物体实例当次的新坐标已触发过碰撞回调
if (crashObjectsKey.has(okey)) return
// 物体实例当次的新坐标未触发过碰撞回调,且为新碰撞的物体实例,则应执行碰撞回调
if (!crashObjects[okey]) {
// 当前物体实例的碰撞回调
crashCallback(okey)
// 被碰撞物体实例的碰撞回调
Objects[okey].crashCallback(objKey)
// 两个物体实例中,互相将各自碰撞的物体实例置为true
Objects[objKey].crashObjects[okey] = true
Objects[okey].crashObjects[objKey] = true
}
// 将当前被碰撞物体实例ID放入crashObjectsKey,表示当次碰撞已触发过碰撞
crashObjectsKey.add(okey)
})
}
})
// 重新渲染该物体实例已不再占有的像素块
clearObjectLightingPos(objKey, clearIgnorePos)
// 当次位移导致已脱离碰撞状态的被碰撞物体,两者相互把对方的物体实例置为false
for (let okey in Objects[objKey].crashObjects) {
if (!crashObjectsKey.has(okey)) {
Objects[objKey].crashObjects[okey] = false
Objects[okey].crashObjects[objKey] = false
}
}
}
// window坐标与screen坐标的相互转换
const translatePos = (pos, reverse = false) => {
// screen => window (由于小数误差问题,暂时并未能准确计算)
if (reverse) {
return { x: (pos.x + 1) * (BlockWidth * ScreenScale) + ScreenRect.left, y: (pos.y + 1) * (BlockHeight * ScreenScale) + ScreenRect.top }
}
// window => screen
return { x: Math.ceil((pos.x - ScreenRect.left) / (BlockWidth * ScreenScale)) - 1, y: Math.ceil((pos.y - ScreenRect.top) / (BlockHeight * ScreenScale)) - 1 }
}
// 绘制物体
const drawObject = (objectKey, originPos) => {
// 将相对window的坐标,转换为相对screen的坐标
const { x, y } = translatePos(originPos)
const pos = `${x}_${y}`
// 当前screen坐标在screen可视范围(存在该像素块)
if (Blocks[pos]) {
// 渲染物体实例
lightObjectBlock(objectKey, { x, y })
}
}
其他
为了演示,为另一个物体提供一个自动移动的函数
// 获取一个Screen范围内的相对Screen的随机坐标
const getRandomPos = () => {
return { x: WindowForScreenMinX + Math.random() * (WindowForScreenMaxX - WindowForScreenMinX), y: WindowForScreenMinY + Math.random() * (WindowForScreenMaxY - WindowForScreenMinY) }
}
// 将坐标Key转换为坐标 "x_y" => {x, y}
const getPosFromPosKey = (posKey = '') => {
const arr = posKey.split('_')
return { x: parseInt(arr[0]), y: parseInt(arr[1]) }
}
// 获取物体实例中心的相对Screen的坐标
const getObjectCenterPos = (objKey) => {
return getPosFromPosKey(Objects[objKey].currentCenterPosKey)
}
// 八个方向的相对移动单位坐标 (由于小数误差问题,暂时并未能准确设置)
// ↖ ↑ ↗ ← → ↙ ↓ ↘
const moveSides = [{ x: -0.5, y: 0.5 }, { x: 0.5, y: 0.5 }, { x: 1.5, y: 0.5 }, { x: -0.5, y: 1.5 }, { x: 1.5, y: 1.5 }, { x: -0.5, y: 2.5 }, { x: 0.5, y: 2.5 }, { x: 2, y: 2.5 }]
// 获取一个随机方向的相对Window的移动坐标
const getOneRandomSidePos = (objKey) => {
// 获取随机移动方向
const randomSide = moveSides[Math.floor(Math.random() * moveSides.length)]
// // 计算该物体实例的中心点在该方向相对screen的新坐标
let { x, y } = getObjectCenterPos(objKey)
// 计算该方向相对window的新坐标
return translatePos({ x: x + randomSide.x, y: y + randomSide.y }, true)
}
// 物体随机移动一个方向的单位坐标
const randomMoveObject = (objKey) => {
// 定时移动
setInterval(() => {
const e = getOneRandomSidePos(objKey)
drawObject(objKey, e)
}, 500)
}
开始渲染
分别执行参考系构建,及物体实例创建函数,并添加一些对应的监听器即可。
btw:在这里还加了个resize,方便在调整适应不同的视窗大小随时变化参考系的resize监听。
// 创建screen
createScreen()
// 创建鼠标物体实例并记录ID
MouseObjectId = createObject(MouseObjectName)
// 创建Test1物体实例并记录ID
const test1 = createObject('test1')
// 渲染Test1物体实例
drawObject(test1, getRandomPos())
// 随机移动Test1物体实例
randomMoveObject(test1)
// 鼠标物体实例跟随鼠标移动并渲染
window.addEventListener('mousemove', (e) => drawObject(MouseObjectId, e))
// 监听window尺寸变化,同步更新构建Screen
window.addEventListener('resize', createScreen)
总结
目前Demo仅用二维参考系来展示,因为三维实在太复杂了!对于复杂模型也没有做兼容,图形的构建仅限于用01的二进制模型来替代。
除此之外,由于像素格太多的话,整个网页的性能就会大幅下降,也不清楚是HTML的性能限制,还是这个机制实在太拉胯了呢?当然也有可能是因为纯CSS+JS的缘故,换成Canvas会不会就好很多呢?
其实我更想知道,大家的猜想又是怎么样的呢?
奇思妙想就是这些,虽然不是很复杂,只是一个简单的yy思路,但并未参考和搜索任何资料,对我个人而言确实是原创,如有雷同纯属巧合,如果有幸和哪些大佬的想法相碰,实属荣幸!
如果能给各位大佬一点小小的启发那最好不过了,但我主要目的依然是在这里记录在下粗鄙的yy思路,同时来向各位大佬学习。
(转载请注明来源)