【连载】GAMEBOY Advance模拟器云化(一)

589 阅读5分钟
GBA真的是童年的回忆呢,明明都是单机游戏,可肝起来的程度一点也不必现在的网游差。可以为了获得混沌戒指,不停的在恶魔城中一刷二刷三刷,也可以带着小火龙满世界转悠,去打败每一个道场,当然更少不了在瓦里奥魔性的笑声中迎接一个又一个的挑战。

  百度百科对GameBoy Advance的评价,个人认为基本符合那个时期的实际情况。

GBA的热销大大增强了第三方软件商和玩家对任天堂的信赖,连一贯强硬的Square也无法漠视GBA的利润诱惑,任天堂势必会竭力劝说那些纷拥而来想为GBA开发游戏的厂商同时兼顾NGC。GBA也使任天堂构筑了温馨的后花园处于进可攻退可守的有利局面。 柯纳米和卡普空等把多数精力都投入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

欢迎大家发起提交或留言交流。