本篇借助svg元素,实现一个较为完善的键盘按键的小游戏,针对键盘事件最常用的游戏控制场景,对上一篇介绍的js键盘事件有一个综合应用。尤其是后面多个按键同时按下的处理实现,欢迎支持或指正不足之处!
键盘控制的小游戏【键盘事件应用最多的场景】
键盘事件应用最多的场景就是游戏控制,通过预定义的按键,控制游戏对象的行为。
下面是一个使用箭头控制游戏对象的一个小demo,借助的是svg元素及translate属性。
HTML 和 CSS
html中添加一个带有正方形的 SVG :
<p>使用箭头控制小正方形</p>
<svg width="500px" height="500px" class="area" id="svg-stage">
<rect id="object1" x="20" y="10" width="20" height="20" fill="black" />
</svg>
css很简单,设置一个背景:
.area {
background-color: #da3434;
}
游戏思路和svg操作的关键点
实现思路
以下是实现的整体处理过程:
- 页面元素为 svg 及其内的 rect 小方块,通过键盘的箭头控制小方块位置的变化,实现移动。
- 以
svg
元素为游戏的场景平台,因此,要首先获取其宽高。 - 获取小方块在 svg 中的原始位置,即
rect
的 x、y属性。使用position
对象表示位置。 - 设置一个按键按下的移动速率
moveRate = 10
。 - 监听按键事件。根据不同的 箭头按键 设置位置的改变,即当前的
position
位置。 - 设置位置时,在场景边缘的判断处理。
- 更新小方块到当前位置。
svg
元素的 translate
是相对原始位置的位移,并且 其属性值必须为数字,否则会报错。
svg中的属性获取和设置
Element.getBoundingClientRect() 获取svg元素的大小
getBoundingClientRect()
返回一个元素的大小和相对视图的位置【其类型为DOMRect对象】。
let stage = document.getElementById("svg-stage");
const {
width: stageWidth,
height: stageHeight
} = stage.getBoundingClientRect();
getBoundingClientRect()
获取的元素属性包括:left, top, right, bottom, x, y, width 和 height。
通过 dom 操作获取的 svg 元素,无法像其它DOM元素一样通过 .with
/.height
属性获取宽高。
附:
有一种比较偏门的通过 svg dom 获取其宽高的方式,通过 svg.attributes
的获取在svg属性上设置的值,如下:
// <svg id="svgA" width="800px" height="500px" style="margin: 5px; border: 2px solid #3f8ce8;"></svg>
let svgA = document.getElementById("svgA");
console.log("height:" + svgA.attributes.height.value +
" width:" + svgA.attributes.width.value);
svg.attributes.height.value
属性值获取的是字符串格式的,如果用于其它计算处理,需要进行类型转换。
svg.attributes
用于获取在属性上设置的值;svg.style
用于获取在样式上设置的值。
比如,svg.style.height
/svg.style.width
可以获取设置的样式的宽高。
Element.getClientRects()
可用于获取 CSS 边框盒子的长方形边界(宽高位置等)。
SVGGraphicsElement.getBBox() 获取svg子元素在svg空间内的坐标位置
SVGGraphicsElement.getBBox()
用于获取svg内的元素,在当前svg空间内的位置和大小。
如下,获取 小方块 在svg内的原始位置。
let rectObj = document.getElementById("object1");
let {
x: originX,
y: originY
} = rectObj.getBBox();
getBBox()
返回在该方法调用时的,实际的元素盒子边界。即使该元素还没有U型渲染,并且该元素或父元素的转换(位移、旋转、倾斜等)均不影响其值。
getBBox()
与 getBoundingClientRect()
的不同在于, getBBox()
返回的是相对于SVG空间的位置,getBoundingClientRect()
返回的是相对于视图的位置。
getBBox() 在 Firefox 中的问题
getBBox()
在 Firefox 中有一个小问题,就是,Firefox 中,如果没有填充时,getBBox()
将获取一个空的DOMRect。
对于如果想要获取宽高大小,推荐使用 getBoundingClientRect()
获取。而获取相对于 SVG 的位置,还是使用 getBBox()
,Firefox 中的情况再特殊处理。
svg属性的设置 和 transform translate
svgele.setAttribute()
方法用于对svg元素进行属性的设置。
在 svg 元素中,transform 属性的 translate 位移不需要指定单位(比如像素xp),直接设置数值即可。如果加上单位将会报错或不起作用。
SVG元素自带的 transform 属性的 transform(tx[ ty]),使用多个参数是,可以使用逗号分隔,也可以仅使用空格分隔!如下两种方式均可:
transform="translate(30 12)"
transform="translate(30, 12)"
svg中元素的transform
,是相对于起始位置的偏移。
如下,通过 rectObj.setAttribute("transform", transform);
设置小方块在当前时刻的位置:
function refreshPosition() {
// translate 相对原始位置的位移
let translateX = position.x - originX;
let translateY = position.y - originY;
let transform = "translate(" + translateX + " " + translateY + ")";
// svg中 setAttribute 设置属性 translate 必须为数字类型,否则报错
rectObj.setAttribute("transform", transform);
}
js 实现方向键控制游戏对象位置变化
首先,声明几个对象:游戏对象的舞台边界、游戏对象的位置 和 移动速度。
let stage = document.getElementById("svg-stage");
//console.log(stage.getBoundingClientRect())
// 舞台的大小,确定边界
const {
width: stageWidth,
height: stageHeight
} = stage.getBoundingClientRect();
let rectObj = document.getElementById("object1");
// 获取原始位置
let {
x: originX,
y: originY
} = rectObj.getBBox();
// 小方块的位置
let position = {
x: originX,
y: originY
};
let moveRate = 10;
创建 updateYPosition
和 updateXPosition
函数,分别更新游戏对象的 Y 和 X 方向的位置,参数是移动的距离。
// 更新 y-axis 位置.
function updateYPosition(distance) {
position.y -= distance;
// 更新边缘的Y轴位置.
if (position.y < 0) {
position.y = stageHeight;
} else if (position.y > stageHeight) {
position.y = 0;
}
return true;
}
// 更新 x-axis 位置.
function updateXPosition(distance) {
position.x += distance;
// 更新边缘的X轴位置.
if (position.x < 0) {
position.x = stageWidth;
} else if (position.x > stageWidth) {
position.x = 0;
}
return true;
}
实现了位置更新,然后就需要把更新后的位置应用到游戏对象,创建 refreshPosition()
函数,使用 translate
移动游戏对象的位置。
function refreshPosition() {
// translate 相对原始位置的位移
let translateX = position.x - originX;
let translateY = position.y - originY;
let transform = "translate(" + translateX + " " + translateY + ")";
// svg中 setAttribute 设置属性 translate 必须为数字类型,否则报错
rectObj.setAttribute("transform", transform);
}
在 addEventListener()
方法中,添加对 keydown
事件的处理函数。相关的方向键按下时,就会更新并应用位置,实现对象随键盘的控制移动。
window.addEventListener("keydown", event=> {
let arrowKey = false;
if (event.code === "ArrowDown") {
arrowKey = updateYPosition(-moveRate);
} else if (event.code === "ArrowUp") {
arrowKey = updateYPosition(moveRate);
} else if (event.code === "ArrowLeft") {
arrowKey = updateXPosition(-moveRate);
} else if (event.code === "ArrowRight") {
arrowKey = updateXPosition(moveRate);
}
if (arrowKey) {
refreshPosition();
event.preventDefault();
}
}, true);
效果演示
如下,通过上下左右键盘按键实现小方块的移动:
同时按下多个按键的处理
如果你实际测试了上面的小游戏,就会发现有一个很大的问题。同时按下多个方向键时,只有最后一个按下的起作用。也就是,
该dome,无法处理同时按下多个按键的情况。
下面则看看,如何处理同时按下多个按键的情况。
实现关键点【事件与游戏逻辑分离】
需要记住的一点是,要将按键事件与动画或主游戏循环分离。也就是 触发的事件和游戏的逻辑处理要分开。
keydown、keyup事件是IO事件,它不应该处理游戏逻辑,这些IO事件可能以任何速率进来,在帧与帧之间可能会发生多次。并且,每个事件都应用逻辑处理是非常浪费的。更糟糕的是,如果涉及非常多的逻辑,可能导致玩家错过逻辑事件,因为按键事件已经在主循环处理新位置的情况下发生了。这也同样适用于鼠标(mouse
)和触摸(touch
)事件,应该只记录事件,并在游戏或动画内处理它们。
这第一段的描述很精彩,下面是英文原文,以供参考:
You need to disassociate the key events from the animation or main game loop. THe keydown, keyup events are IO events and should not handle game logic, they can come in at any rate, you may have many between frames and applying logic on each is just a waste of processing, and worse if you have more logic involved the player may miss logic events because the key events have proceeded without the main loop processing the new positions. The same goes for the mouse and touch events, you should only record the events and handle them within the game.
下面的多个按键同时按下的实现,就是基于 按键事件 与 游戏或动画逻辑 分离的前提。
多个按键实现一个功能的处理,也是这样的。通过记录事件,将按下的多个按键状态保存在一个记录对象中,多个按键记录均为true时,表示这多个按键都按下了,则就可以处理该多按键的逻辑方法。
【主要就是映射按下的down按键状态】
定义包含所有按键的按键状态对象
按键状态的作用,在于维护当前按下的按键,在更新游戏对象状态时,根据当前包含的按键,修改相应的游戏对象属性。
// 按键状态
let arrowKeyState={
"ArrowDown":false,
"ArrowUp":false,
"ArrowLeft":false,
"ArrowRight":false
}
计算机一次只能传递一个按键,通过多键值的对象实现对多个按键的追踪,该对象用于检查已经按下的一个或多个按键。
keydown
和keyup
事件设置按键的状态变化
// 记录按键状态
const keyEventLogger = function (e) {
e.preventDefault();
if(Object.keys(arrowKeyState).indexOf(e.code)>=0){
arrowKeyState[e.code] = e.type == 'keydown';
}
}
window.addEventListener("keydown", keyEventLogger);
window.addEventListener("keyup", keyEventLogger);
循环更新游戏对象的状态
对于游戏对象的位置变化,还是使用上面之前的设置。
不过设置对小方块位置的更新,放在requestAnimationFrame()
方法中。
// 多个按键的处理
requestAnimationFrame(function move(){
let arrowKey = false;
if (arrowKeyState["ArrowDown"]) {
arrowKey = updateYPosition(-moveRate);
}
if (arrowKeyState["ArrowUp"]) {
arrowKey = updateYPosition(moveRate);
}
if (arrowKeyState["ArrowLeft"]) {
arrowKey = updateXPosition(-moveRate);
}
if (arrowKeyState["ArrowRight"]) {
arrowKey = updateXPosition(moveRate);
}
arrowKey && refreshPosition();
requestAnimationFrame(move);
})
效果演示
如下,同时按下多个上下左右箭头按键,实现小方块倾斜移动的效果。并且可以测试,同时按下左右或上下键时,小方块将保持位置不变:
使用按键事件的注意项“小陷阱”
此部分主要参考自 How to detect if multiple keys are pressed at once using JavaScript? 里面的答案,并进行了整理。其中对按键处理中的小陷阱的介绍,非常值得关注。
下面所有示例中, key 状态对象的键的记录,使用的是 KeyboardEvent.key
。而不是上面示例中的 KeyboardEvent.code
【上面示例中对箭头按键的判断,也是推荐使用KeyboardEvent.key
】
优雅的判断多个key按键组合都按下
上面的判断是单独判断多个按键中是否有按下的。但也有很多情况是,多个按键在一起组合都按下,来执行某个功能。
关于这种其概况的判断,则通常可以是下面的形式。
if(keyState['Control'] && keyState['Shift'] && (keyState['C'] || keyState['c'])){
alert('Control Shift C 按键');
// do something...
}
可以将其修改为同时判断的一个函数,如下:
const detectKeys=(...keys)=>{
for(let k of keys){
if(!keyState[k]){
return false;
}
}
return true;
}
判断是否按下 Control Shift C
:
if(detectKeys(`Control`,`Shift`)){
if(detectKeys(`C`) || detectKeys(`c`)){
alert('Control Shift C 按键');
// do something...
}
}
保持跟踪 key 或 code
一个很好的习惯是,尤其是多个按键判断时,保持特定的顺序判断,不仅方便记住,而且在修改或重构代码时,不至产生困惑。
比如 CTRL+ENTER => keyState['Control'] && keyState['Enter']
,而不是使用 keyState['Enter'] && keyState['Control']
判断。
使用 if-else 的小陷阱
对于不同组合按键的判断,比如 Ctrl
+Shift
+Alt
+Enter
和 Ctrl
+ Enter
。将小组合放在大组合按键的后面,是正确的。否则,相似的小组合可能会覆盖大组合,导致判断错乱。
直接看下面的示例就能明白:
// 正确的:
if(keyState['Control'] && keyState['Shift'] && keyState['Enter']){ // CTRL+SHIFT+ENTER
// do something
}else if(keyState['Control'] && keyState['Enter']){ // CTRL+ENTER
// do something
}else if(keyState['Enter']){ // ENTER
alert('按下了Enter.');
// do something
}
// 错误的:
if(keyState['Control'] && keyState['Enter']){ // CTRL+ENTER
// do something
}else if(keyState['Control'] && keyState['Shift'] && keyState['Enter']){ // CTRL+SHIFT+ENTER
// do something
}else if(map[13]){ // ENTER
alert('按下了Enter.');
// do something
}
没有按下按键,但按键组合仍保持激活的小陷阱
当处理alert等弹窗,或其他任何使主窗口失去焦点的事情时(比如单按 Alt 键也会使主窗口失去焦点),都应该包含 keyState={}
代码,实现在条件完成后重置按键状态对象。
Alt 和其他按键组合按下时,不会使主窗口失去焦点。
这是因为,像 alert()
等使主窗口失去焦点的操作,将会导致 keyup
事件不触发,并且也将导致后续所有的按键事件失效,直到主窗口重新获得焦点。
if(keyState['Control'] && keyState['Enter']){ // CTRL+ENTER
alert('小心bug!');
// do something...
keyState = {}; // 重置按键状态
}
浏览器默认行为的小陷阱
因为浏览器有一些默认的组合按键行为,比如 Ctrl
+ D
激活书签窗口(bookmark
),或 遨游浏览器(maxthon
) 的 Ctrl
Shift
C
打开 skynote,或 ctrl
+ S
保存当前页面,或 ctrl
+ A
全选页面内容等。
return false
阻止浏览器默认行为
要想替代默认的浏览器行为,需要在 按键状态对象 keyState = {}
重置代码的后面,添加 return false
。这样防止用户对按键操作产生困惑或错乱。
如下,处理浏览器默认的Ctrl
+ D
按键功能:
if(keyState['Control'] && (keyState['D'] || keyState['d'])){ // CTRL+D
alert('不会出现添加书签!');
keyState = {};
return false;
}
e.preventDefault()
阻止默认行为
上面执行 return false
会直接返回当前函数的执行。如果不需要返回,或者需要执行后面的代码但是仅想阻止默认的行为,则推荐使用 e.preventDefault()
。