移动端NES网页模拟器(3)
前言
前面2个章节已经封装好了摇杆和NES虚拟按键,现在配合jsnes这个包来完成一个移动端版的NES模拟器。
这是插件的github地址:bfirsh/jsnes
这个包可以直接拿来用,但是没有适配移动端。他通过事件监听,判断evt.keyCode属性来判断用户的输入信息。在移动端只要进行事件监听,生成一个带有keyCode属性的evt,然后将evt这个对象传递给相关的事件回调即可,唯一要做的就是要为不同的按钮匹配相应的keyCode,才能让游戏动起来。
1.jsnes牛刀小试
在下载完jsnes的包之后,打开example文件夹的html文件,就可以看到使用方法了
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Embedding Example</title>
<!-- 模拟器的包 -->
<script type="text/javascript" src="https://unpkg.com/jsnes/dist/jsnes.min.js"></script>
<!-- 模拟器使用说明 -->
<script type="text/javascript" src="nes-embed.js"></script>
<script>window.onload = function(){nes_load_url("nes-canvas", "InterglacticTransmissing.nes");}</script>
</head>
<body>
<div style="margin: auto; width: 75%;">
<canvas id="nes-canvas" width="256" height="240" style="width: 100%"/>
</div>
<p>DPad: Arrow keys<br/>Start: Return, Select: Tab<br/>A Button: A, B Button: S</p>
</body>
</html>
其中最重要的是nes-embed.js,这是截取一部分重要的代码出来
入口函数:封装了一个函数,传入canvas元素的标识和rom的url地址,通过ajax来获取到对应url的Rom数据
function nes_load_url(canvas_id, path){
//
nes_init(canvas_id);
var req = new XMLHttpRequest();
req.open("GET", path);
req.overrideMimeType("text/plain; charset=x-user-defined");
req.onerror = () => console.log(`Error loading ${path}: ${req.statusText}`);
req.onload = function() {
if (this.status === 200) {
//装载Rom数据
nes_boot(this.responseText);
} else if (this.status === 0) {
// Aborted, so ignore error
} else {
req.onerror();
}
};
req.send();
}
键盘事件监听,调用keyboard()函数
document.addEventListener('keydown', (event) => {keyboard(nes.buttonDown, event)});
//'keyup'用来取消动作 对于AB键,没有他则无法进行下一轮操作 对于方向键,没有他则游戏角色会一直朝一个方向走下去
document.addEventListener('keyup', (event) => {keyboard(nes.buttonUp, event)});
keyboard()函数:对事件对象的keyCode进行判断,在这里自定义按键
function keyboard(callback, event){
var player = 1; //1表示1p 2表示2p
switch(event.keyCode){
case 38: // UP 87代表W
callback(player, jsnes.Controller.BUTTON_UP); break;
case 40: // Down 83代表S
callback(player, jsnes.Controller.BUTTON_DOWN); break;
case 37: // Left
callback(player, jsnes.Controller.BUTTON_LEFT); break;
case 39: // Right
callback(player, jsnes.Controller.BUTTON_RIGHT); break;
case 65: // 'a' - qwerty, dvorak
case 81: // 'q' - azerty
callback(player, jsnes.Controller.BUTTON_A); break;
case 83: // 's' - qwerty, azerty
case 79: // 'o' - dvorak
callback(player, jsnes.Controller.BUTTON_B); break;
case 9: // Tab
callback(player, jsnes.Controller.BUTTON_SELECT); break;
case 13: // Return
callback(player, jsnes.Controller.BUTTON_START); break;
default: break;
}
}
这是我自己的设定,用WSAD代表上下左右,JK代表BA,空格代表选择键 回车代表开始键
function keyboard(callback, event){
var player = 1;
//console.log(event.keyCode)
switch(event.keyCode){
case 87: // UP - W
callback(player, jsnes.Controller.BUTTON_UP); break;
case 83: // Down -- S
callback(player, jsnes.Controller.BUTTON_DOWN); break;
case 65: // Left -- A
callback(player, jsnes.Controller.BUTTON_LEFT); break;
case 68: // Right -- D
callback(player, jsnes.Controller.BUTTON_RIGHT); break;
case 75: // K
callback(player, jsnes.Controller.BUTTON_A); break;
case 74: // J
callback(player, jsnes.Controller.BUTTON_B); break;
case 32: // 空格
callback(player, jsnes.Controller.BUTTON_SELECT); break;
case 13: // 回车
callback(player, jsnes.Controller.BUTTON_START); break;
default: break;
}
}
另外这个插件有2个位置需要优化,其一:AudioContext需要兼容处理
//93行
var audio_ctx = new window.AudioContext();
改成
var contextClass =(window.AudioContext || window.webkitAudioContext || window.mozAudioContext || window.oAudioContext || window.msAudioContext);
var audio_ctx = new contextClass();
其二:某些浏览器打开时提示:
The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page
查阅资料得知:谷歌浏览器从71版本开始就开启了谷歌的安全策略,因此导致的声音不能自动播放,必须在用户有了操作之后才可以播放音乐。可以设置一个按钮,在按钮的点击事件中调用
document.querySelector('#btn_load').onclick = function(){
//加载游戏
nes_load_url("nes-canvas", "./roms/超级玛丽.nes")
//隐藏加载按钮
this.style.display = 'none'
}
2.NES模拟器完整代码
HTML文件
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- nes_btn样式文件 -->
<link rel="stylesheet" href="./css/nes_btn.css">
<!-- 该页面样式文件 -->
<link rel="stylesheet" href="./css/index.css">
<title>NES网页模拟器</title>
</head>
<body>
<div id="show_box">
<h3 id="name">热血格斗</h3>
<div class="canvas_box">
<h3 id="btn_load">点此加载游戏</h3>
<canvas id="nes-canvas" width="256" height="240" style="width: 100%"/>
</div>
</div>
<div id="control_box">
<!-- 摇杆容器 -->
<div id="direction"></div>
<!-- 按键容器 -->
<div id="user_btn_box"></div>
</div>
</body>
</html>
<script src="./js/jsnes.min.js"></script>
<script src="./js/nes-embed.js"></script>
<!-- nes_btn插件 -->
<script src="./js/nes_btn.js"></script>
<!-- 摇杆插件 -->
<script src="./js/nipplejs.min.js"></script>
<script src="./js/joystick.js"></script>
<script>
window.onload = function(){
//监听加载按钮
document.querySelector('#btn_load').onclick = function(){
//加载游戏
nes_load_url("nes-canvas", "./roms/超级玛丽.nes")
//隐藏加载按钮
this.style.display = 'none'
}
//实例化摇杆
var joystick = new Joystick({
el:"#direction",//容器
color:" royalblue",//摇杆颜色
size:100,//摇杆大小
isFourBtn:false,//8键模式
keyCodes:[87, 83, 65, 68],//绑定 上下左右 到 WSAD键
btn_down_fn:(event) => {keyboard(nes.buttonDown, event)},//按下时的回调
btn_up_fn:(event) => {keyboard(nes.buttonUp, event)},//释放时的回调
relative:true,//默认为true 会将容器设置为相对定位
})
joystick.init()
//实例化NES按钮
var nesBtn = new VirtualNesBtn({
el:"#user_btn_box", //容器
btn_down_fn:(event) => {keyboard(nes.buttonDown, event)},//虚拟按钮按下时的回调 参数evt
btn_up_fn:(event) => {keyboard(nes.buttonUp, event)},//虚拟按钮弹起时的回调 参数evt
keyCodes:[32,13,74,75] //按顺序分别是 select start b a
})
//NES按钮实例初始化
nesBtn.init()
}
</script>
css/nes_btn.css
*{
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
}
html,body{
padding:0;
margin: 0;
}
#show_box{
position: relative;
width:80%;
max-width: 512px;
text-align: center;
margin: 20px auto;
}
#show_box .canvas_box{
background: url(../imgs/unload_bg.jpg);
background-size: 100% 100%;
}
#show_box #btn_load{
position: absolute;
color:#FF4500;
left:50%;
top:50%;
transform: translate(-50%,-50%);
cursor: pointer;
}
#control_box {
width:100%;
padding-top: 30px;
padding-bottom: 40px;
bottom:0;
left:0;
position: fixed;
overflow: hidden;
display: flex;
justify-content: space-around;
}
#control_box #direction{
width:40vw;
height:40vw;
position: relative;
}
#control_box #user_btn_box{
width:48%;
display: flex;
flex-wrap: wrap;
justify-content: space-around;
align-content: space-around;
}
css/index.css
*{
-webkit-touch-callout:none;
-webkit-user-select:none;
-khtml-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
}
html,body{
padding:0;
margin: 0;
}
#show_box{
position: relative;
width:80%;
max-width: 512px;
text-align: center;
margin: 20px auto;
}
#show_box .canvas_box{
background: url(../imgs/unload_bg.jpg);
background-size: 100% 100%;
}
#show_box #btn_load{
position: absolute;
color:#FF4500;
left:50%;
top:50%;
transform: translate(-50%,-50%);
cursor: pointer;
}
#control_box {
width:100%;
padding-top: 30px;
padding-bottom: 40px;
bottom:0;
left:0;
position: fixed;
overflow: hidden;
display: flex;
justify-content: space-around;
}
#control_box #direction{
width:40vw;
height:40vw;
position: relative;
}
#control_box #user_btn_box{
width:48%;
display: flex;
flex-wrap: wrap;
justify-content: space-around;
align-content: space-around;
}
js/nes_btn.js
function VirtualNesBtn(opt){
//接收容器的标识
this.el = opt.el
//接收回调
this.btn_down_fn = opt.btn_down_fn //fn
this.btn_up_fn = opt.btn_up_fn //fn
//保存按钮信息
this.btns_info = []
//为5个按钮添加带数字后缀的 id
this.btns_info = [{},{},{},{},{}]
this.btns_info[0].id = 'btn_select_' + Math.floor(Math.random() * 100000)
this.btns_info[1].id = 'btn_start_' + Math.floor(Math.random() * 100000)
this.btns_info[2].id = 'btn_ab_' + Math.floor(Math.random() * 100000)
this.btns_info[3].id = 'btn_b_' + Math.floor(Math.random() * 100000)
this.btns_info[4].id = 'btn_a_' + Math.floor(Math.random() * 100000)
//设定5个按钮的文本名称 name
this.btns_info[0].name = 'SELECT'
this.btns_info[1].name = 'START'
this.btns_info[2].name = 'B + A'
this.btns_info[3].name = 'B'
this.btns_info[4].name = 'A'
//配置5个按钮的 keycode 默认为 空格 回车 J K
this.btns_info[0].keyCode = opt.keyCodes && opt.keyCodes[0] || 32
this.btns_info[1].keyCode = opt.keyCodes && opt.keyCodes[1] || 13
this.btns_info[2].keyCode = 'macro_key'
this.btns_info[3].keyCode = opt.keyCodes && opt.keyCodes[2] || 74
this.btns_info[4].keyCode = opt.keyCodes && opt.keyCodes[3] || 75
}
//初始化 创建虚拟按钮
VirtualNesBtn.prototype.init = function(opt){
var me = this
//创建5个按键
var btn_select = document.createElement('span')
var btn_start = document.createElement('span')
var btn_ab = document.createElement('span')
var btn_b = document.createElement('span')
var btn_a = document.createElement('span')
//为5个按键设置css类名
btn_select.classList.add('btn','btn-select')
btn_start.classList.add('btn','btn-start')
btn_ab.classList.add('btn','btn-ab')
btn_b.classList.add('btn','btn-b')
btn_a.classList.add('btn','btn-a')
//为5个按键设置id
btn_select.id = me.btns_info[0].id
btn_start.id = me.btns_info[1].id
btn_ab.id = me.btns_info[2].id
btn_b.id = me.btns_info[3].id
btn_a.id = me.btns_info[4].id
//为5个按键设置 文本
btn_select.innerText = me.btns_info[0].name
btn_start.innerText = me.btns_info[1].name
btn_ab.innerText = me.btns_info[2].name
btn_b.innerText = me.btns_info[3].name
btn_a.innerText = me.btns_info[4].name
//创建容器,并将5个按钮插入其中
var nes_btn_box = document.createElement('div')
nes_btn_box.classList.add('nes_btn_box')
nes_btn_box.appendChild(btn_select)
nes_btn_box.appendChild(btn_start)
nes_btn_box.appendChild(btn_ab)
nes_btn_box.appendChild(btn_b)
nes_btn_box.appendChild(btn_a)
//插入到目标容器中
var target = document.querySelector(me.el)
target.appendChild(nes_btn_box)
//设置touch事件监听
target.addEventListener('touchstart',(evt) => {
//阻止默认事件,防止快速点击时页面放大
evt.preventDefault()
//判断点中的是否是虚拟按钮
var is_nes_btn = me.btns_info.some(function(item){
return item.id === evt.target.id
})
if(is_nes_btn){
//添加高亮
evt.target.classList.add('isTouch')
//处理此次点击
me.handleBtn(evt,'btn_down')
}
})
target.addEventListener('touchend',(evt) => {
//判断点中的是否是虚拟按钮
var is_nes_btn = me.btns_info.some(function(item){
return item.id === evt.target.id
})
if(is_nes_btn){
//移除高亮
evt.target.classList.remove('isTouch')
//处理此次点击
me.handleBtn(evt,'btn_up')
}
})
}
//对虚拟按键的id进行判断,返回要绑定的 keyCode
VirtualNesBtn.prototype.getCode = function(id){
var me = this
//1.根据id查到按钮信息在数组中的下标
var index = me.btns_info.findIndex(function(item){
return item.id === id
})
//2.根据下标找到对应的keyCode
return me.btns_info[index].keyCode
}
//对按键进行处理
VirtualNesBtn.prototype.handleBtn = function(evt,type){
var me = this
//1.找到keycode
var keyCode = me.getCode(evt.target.id)
//2.根据keycode判是否是宏按键
if(keyCode === 'macro_key'){
//要触发2个按键
var evt_tem = {}
var evt_tem2 = {}
evt_tem.keyCode = me.btns_info[3].keyCode
evt_tem2.keyCode = me.btns_info[4].keyCode
if(type === 'btn_down'){
me.btn_down_fn && me.btn_down_fn(evt_tem)
me.btn_down_fn && me.btn_down_fn(evt_tem2)
}else{
me.btn_up_fn && me.btn_up_fn(evt_tem)
me.btn_up_fn && me.btn_up_fn(evt_tem2)
}
}else{
//添加keyCode属性
evt.keyCode = keyCode
//不是宏按键则执行相应的回调
if(type === 'btn_down'){
me.btn_down_fn && me.btn_down_fn(evt)
}else{
me.btn_up_fn && me.btn_up_fn(evt)
}
}
}
js/joystick.js
//此插件依赖nipplejs.min.js
//by https://gitee.com/lianlizhou
function Joystick(opts){
//默认配置
var position = {left:"50%", top:"50%"}
//保存传入的配置信息
this.el = opts && opts.el
this.color = opts && opts.color || 'white'
this.size = opts && opts.size || 100
this.isFourBtn = opts && opts.isFourBtn ? true : false //默认4键模式 否则8键模式(左上/左下/右上/右下)
this.keyCodes = opts && opts.keyCodes || [87, 83, 65, 68] //按顺序是上下左右 默认WSAD
this.btn_down_fn = opts && opts.btn_down_fn //fn 按下时的回调
this.btn_up_fn = opts && opts.btn_up_fn //fn 释放时的回调
this.relative = opts && opts.relative || true //默认将容器设置为相对定位
//生成配置信息 这里配置的参数将传给nipplejs.min.js
this.opts = {
zone:document.querySelector(this.el), //用户设置的容器
mode: 'static',//模式,static就是摇杆中心固定
position:{left:'50%',top:'50%'},//让摇杆容器定位到用户设置容器的正中心
color:this.color,//摇杆的颜色 包括back和front nipplejs默认白色,通过背景色实现
size:this.size,//摇杆容器back元素的大小,front是他的一半,默认100
}
//记录上一次按键方向
this.direction = null //手势释放时重新设为null
}
Joystick.prototype.init = function(){
var me = this
//如果用户不阻止,则将用户容器设为相对定位 要在实例创建前设置
if(me.relative) document.querySelector(me.el).style.position = 'relative'
//创建nipplejs实例
var manager = nipplejs.create(me.opts)
//事件监听
manager.on('start',function(evt,data){
})
manager.on('move',function(evt,data){
//数据交给其他方法处理
me.onMove && me.onMove(data)
})
manager.on('end',function(evt,data){
me.onEnd && me.onEnd()
})
//阻止默认事件,防止快速点击时页面缩放
document.querySelector(me.el).addEventListener('touchstart',function(evt){
evt.preventDefault()
})
}
Joystick.prototype.onMove = function(data){
var me = this
//通过distance属性是否存在判断此次操作是否有效
if(!data.distance) return
//获取最新方向信息
var now_direction = me.getDirection(data)
//处理方向信息
me.handleDirection(now_direction,me.direction) //新方向 上一个方向
//更新按键方向
me.direction = now_direction
}
Joystick.prototype.onEnd = function(){
var me = this
//1.获取要处理的keyCode数组 并调用方法将相关按键释放
me.handleCodeArr('up',me.getCodeArr(me.direction)) //up or down
//2.重置方向信息
me.direction = null
}
Joystick.prototype.getDirection = function(data){
var me = this
//判断是4键模式还是8键模式
if(me.isFourBtn){
//4键模式 直接返回
return data.direction.angle
}else{
//8键模式 根据角度值返回对应的方向
return me.transformDirection(data.angle.degree)
}
}
//用于8键模式 将角度转换成方向
Joystick.prototype.transformDirection = function(degree){
//8个方向平方360度 每个方向45度
//右上 22.5 - 76.5
//上 76.5 - 112.5
//左上 112.5 - 157.5
//左 157.5 - 202.5
//左下 202.5 - 247.5
//右下 247.5 - 292.5
//右 >292.5 <=22.5
if(degree > 292.5){
//右
return 'right'
}else if(degree > 247.5){
//右下
return 'right_down'
}else if(degree > 202.5){
//左下
return 'left_down'
}else if(degree > 157.5){
//左
return 'left'
}else if(degree > 112.5){
//左上
return 'left_up'
}else if(degree > 76.5){
//上
return 'up'
}else if(degree > 22.5){
//右上
return 'right_up'
}else{
//右
return 'right'
}
}
//将相关方式信息转换为keyCode,并放入数组中
Joystick.prototype.handleDirection = function(new_direction,old_direction){
var me = this
//old_direction可能为null 但new_direction绝对有值
//当old_direction时,说明用户刚开始点击,此时需要将相应的keyCode传给btn_down_fn执行
if(old_direction === null){
var code_arr = me.getCodeArr(new_direction)
me.handleCodeArr('down',code_arr)
}
//当old_direction不为null,说明用户正在滑动 如果此时新旧方向不一致,则要更新按键状态
if(old_direction !== null && new_direction !== old_direction){
var old_arr = me.getCodeArr(old_direction)
var new_arr = me.getCodeArr(new_direction)
//找出已经发生改变的方向 例如 右上 -> 右下 需要将'上'取消掉,同时将'下'按下
//遍历新数组的元素,对比该元素是否存在旧数组中,如果不存在,即可得到 按下的 code_arr
var down_arr = new_arr.filter( code => {
return !old_arr.includes(code)
})
me.handleCodeArr('down',down_arr)
//遍历旧数组的元素,对比该元素是否存在新数组中,如果不存在,即可得到 释放的 code_arr
var up_arr = old_arr.filter( code => {
return !new_arr.includes(code)
})
me.handleCodeArr('up',up_arr)
}
}
//将方向信息转换为keyCode后,以数组形式返回
Joystick.prototype.getCodeArr = function(direction){
var me = this
switch(direction){
case 'up':return [me.keyCodes[0]];break;
case 'down':return [me.keyCodes[1]];break;
case 'left':return [me.keyCodes[2]];break;
case 'right':return [me.keyCodes[3]];break;
case 'right_up':return [me.keyCodes[3], me.keyCodes[0]];break;
case 'right_down':return [me.keyCodes[3], me.keyCodes[1]];break;
case 'left_up':return [me.keyCodes[2], me.keyCodes[0]];break;
case 'left_down':return [me.keyCodes[2], me.keyCodes[1]];break;
default:break;
}
}
Joystick.prototype.handleCodeArr = function(type,arr){
//type为up or down
//arr为需要处理的包含keyCode的数组
var me = this
var fn = me.btn_down_fn //默认为按下时的回调
if(type !== 'down'){
//如果不是down 说明是手势释放 需要调用释放按键的回调
fn = me.btn_up_fn
}
//遍历数组中的keyCode 逐个处理
for(var i=0;i<arr.length;i++){
//对keyCode进行包裹后
fn && fn(me.package(arr[i]))
}
}
//对keyCode进行封装
Joystick.prototype.package = function(keyCode){
var evt = {}
evt.keyCode = keyCode
return evt
}