GBA真的是童年的回忆呢,明明都是单机游戏,可肝起来的程度一点也不必现在的网游差。可以为了获得混沌戒指,不停的在恶魔城中一刷二刷三刷,也可以带着小火龙满世界转悠,去打败每一个道场,当然更少不了在瓦里奥魔性的笑声中迎接一个又一个的挑战。
百度百科对GameBoy Advance的评价,个人认为基本符合那个时期的实际情况。
GBA的热销大大增强了第三方软件商和玩家对任天堂的信赖,连一贯强硬的Square也无法漠视GBA的利润诱惑,任天堂势必会竭力劝说那些纷拥而来想为GBA开发游戏的厂商同时兼顾NGC。GBA也使任天堂构筑了温馨的后花园处于进可攻退可守的有利局面。 柯纳米和卡普空等把多数精力都投入GBA游戏的开发…游戏业界已经达成了如下的默契:掌机市场由任天堂GBA为主导。
GBA辉煌的过去以及灿烂的游戏这里就不展开了,重要的是我在逛GITHUB的时候发现了一款网页版GBA模拟器(不过现在仓库好像被作者删除了),这一下就重新唤起了曾经的记忆。下载下来简单的试用了一下,还真能运行,而且效果也不差。如果将其部署在我的小云上,岂不是可以随时随地在手机上重温过去的快乐时光了?
将GITHUB上的代码仓库下载后本地运作,可以在浏览器中看到这样的界面。
这是一份游戏列表,点击上面的链接即可启动JS版的GBA模拟器。
是的,就是这样简单直接,需要用键盘才能操作。可能原作者也没想过要在触摸设备上使用吧。不过没关系,原作者没有提供,那么我就来二创吧。
控制界面设计
GBA的按键主要分为【方向键】、【AB功能键】以及【菜单键】。方向键是经典的十字按键,菜单键呢则主要提供【SELECT】和【START】功能,虽然并不是所有游戏都会用到,但也不可缺少,这两部分都比较好设计,按通常使用习惯放置就可以了。唯独功能键比较麻烦,因为除了常用的AB键,GBA还提供了LR键,位于游戏机顶部。有些模拟器会仿照实体LR键的位置将其放置在屏幕顶端,可是实际使用起来会非常别扭。因此,可以借鉴一下PS的十字功能键布局,将LR键和AB键放在一起。
考虑到横竖屏,手持的位置存在差异,因此设计了两种模式下不同的界面:
界面实现
本来对于横竖屏的自适应,一开始是打算利用BootStrap5网格布局来实现的,但是后来发现并不是那么方便(毕竟自己只是一名前端小菜鸟),因此换成依靠CSS来达成布局自适应。
界面HTML代码很简单,将按键与屏幕定义到不同div即可,如下:
<div class="gameboy">
<div class="screen-area">
<canvas class="display" id="display"></canvas>
</div>
<div class="logo">Ray Game</div>
<div class="controls">
<div class="dpad">
<div class="up"></div>
<div class="right"></div>
<div class="down"></div>
<div class="left"></div>
<div class="middle"></div>
</div>
<div class="kpad">
<div class="l">L</div>
<div class="r">R</div>
<div class="b">B</div>
<div class="a">A</div>
</div>
</div>
<div class="menu">
<div class="select">SELECT</div>
<div class="start">START</div>
</div>
</div>
剩下的就交给CSS吧(•̤̀ᵕ•̤́๑)ᵒᵏᵎᵎᵎᵎ,需要注意的是,不管是横屏竖屏,不管是3.5寸屏还是27寸屏,都希望将游戏显示区域最大化,毕竟游戏画面才是屏幕上呈现给用户最重要的信息。但是画布的尺寸要保持固定比例缩放,以避免画面出现拉伸变形。因此在CSS中通过按比例计算当前屏幕的宽(width)与高(height),来获得最佳显示大小。
height: min(100vh - 30px, 50vw - 5px);
width: min(200vh - 60px, 100vw - 10px);
其次利用@media ( orientation: portrait/landscape )属性实现各功能按键的位置根据屏幕方向自动更改。
@media (orientation: portrait ){
.gameboy .controls {
top: 60%;
}
.gameboy .controls .dpad,
.gameboy .controls .kpad {
width: calc(40vw - 24px);
height: calc(40vw - 24px);
}
}
@media ( orientation: landscape ){
.gameboy .controls {
top: 50%;
}
.gameboy .controls .dpad,
.gameboy .controls .kpad {
width: 84pt;
height: 84pt;
}
.gameboy .controls .dpad >*,
.gameboy .controls .kpad >*,
.gameboy .menu >*{
opacity: 0.3;
}
.gameboy .menu .select {
position: absolute;
left: calc(50% - 15vw - 80px);
margin: 0 auto;
}
.gameboy .menu .start {
position: absolute;
left: calc(50% + 15vw);
margin: 0 auto;
}
}
显示效果
按键事件映射
有了界面并不代表就可以直接使用了,该模拟器响应按键的最简处理逻辑如下:
{% mermaid %} graph TD start(["初始化模拟器"])-->register(["注册按键事件"]) register-->run["运行"] run --有按键发生--> isEmulatorKey{"是否是模拟器支持的按键"} isEmulatorKey --是--> onKey[触发模拟器按键] isEmulatorKey --否--> prevent[丢弃该事件] prevent --> run {% endmermaid %}
从上述逻辑流程中能看出,让模拟器支持触摸事件的关键在于,如何将触摸事件转换为模拟器认可的按键事件。首先需要让界面能知道有触摸发生,因此首先添加事件注册。
function registerGUIEvents() {
addEvent("keydown", document, keyDownEvent);
addEvent("keyup", document, keyUpEvent);
addEvent("unload", window, ExportSave);
Iodine.attachSpeedHandler(function (speed) {
document.title = games[location.hash.substr(1)] + " - " + speed;
});
registerJoyPadEvents("dpad", "div");
registerJoyPadEvents("kpad", "div");
registerJoyPadEvents("menu", "div");
}
function registerJoyPadEvents(parent, child){
let joypad = document.getElementsByClassName(parent)[0];
let keys = joypad.getElementsByTagName(child);
for(let i = 0; i < keys.length; i++){
addEvent("touchstart", keys[i], touchDownEvent);
addEvent("touchend", keys[i], touchUpEvent);
}
}
当捕获到触摸发生后,根据触摸发生的节点元素信息,将其转换为KeyEent。再经过原生的转换方式,最终会去调用模拟器的 KeyDown 和 KeyUp。从而实现对触摸的支持。
function keyDown(keyCode) {
for (var keyMapIndex = 0; (keyMapIndex | 0) < 10; keyMapIndex = ((keyMapIndex | 0) + 1) | 0) {
var keysMapped = keyZones[keyMapIndex | 0];
var keysTotal = keysMapped.length | 0;
for (var matchingIndex = 0; (matchingIndex | 0) < (keysTotal | 0); matchingIndex = ((matchingIndex | 0) + 1) | 0) {
if ((keysMapped[matchingIndex | 0] | 0) == (keyCode | 0)) {
Iodine.keyDown(keyMapIndex | 0);
}
}
}
}
function keyUp(keyCode) {
keyCode = keyCode | 0;
for (var keyMapIndex = 0; (keyMapIndex | 0) < 10; keyMapIndex = ((keyMapIndex | 0) + 1) | 0) {
var keysMapped = keyZones[keyMapIndex | 0];
var keysTotal = keysMapped.length | 0;
for (var matchingIndex = 0; (matchingIndex | 0) < (keysTotal | 0); matchingIndex = ((matchingIndex | 0) + 1) | 0) {
if ((keysMapped[matchingIndex | 0] | 0) == (keyCode | 0)) {
Iodine.keyUp(keyMapIndex | 0);
}
}
}
}
function touchDownEvent(e){
keyDown(translateTouchToKeyCode(e.target.className));
if (e.preventDefault) {
e.preventDefault();
}
}
function touchUpEvent(e){
keyUp(translateTouchToKeyCode(e.target.className));
}
function translateTouchToKeyCode(name){
let keyCode = 0;
switch(name){
case "a":
keyCode = KEY_A;
break;
case "b":
keyCode = KEY_B;
break;
case "select":
keyCode = KEY_Select;
break;
case "start":
keyCode = KEY_Start;
break;
case "right":
keyCode = KEY_Right;
break;
case "left":
keyCode = KEY_Left;
break;
case "up":
keyCode = KEY_Up;
break;
case "down":
keyCode = KEY_Down;
break;
case "r":
keyCode = KEY_R;
break;
case "l":
keyCode = KEY_L;
break;
}
return keyCode;
}
至此,就在原代码的基础上增加了触摸控制,可以放在小云上愉快的玩耍了。
未完待续
经过粗读代码,发现原作者的整体业务逻辑大致如下:
{% mermaid %} graph TD start(["打开页面"])-->download(["下载BIOS文件以及ROM文件"]) download-->init["初始化模拟器"] init --> timer["启动定时器"] timer --> run[运行] run --> process["数据处理"] process --> hasComplete{"当前时间段内的处理是否完成"} hasComplete --没有--> process hasComplete --完成--> run {% endmermaid %}
其中【运行】和【数据处理】部分模拟了GBA的CPU中断、DMA以及音频、视频处理。当中会不断的读取BIOS数据以及ROM数据。可能也是因为这个原因,原作者设计为先下载BIOS和ROM数据。
作为云化的模拟器,怎么能仅仅是提供一个界面,而实际数据读写全依赖本地呢?接下来,打算尝试将ROM的读取改为云端依需读取,而不是在本地加载完整ROM。
代码已更新至GITEE
欢迎大家发起提交或留言交流。