本文概述
小时候, 我没有机会玩一个真正的Gameboy, 而是在Windows上可用的Visual Boy Advance模拟器中玩。我记得花了几个小时来玩神奇的游戏, 例如Pokemon, WarioLand, Castlevania, More Pokemon等。今天, 作为开发人员, 我想在本文中与你分享如何使用GBA在Web浏览器中创建GBA模拟器。 js库。 GBA.js是从头开始编写的Game Boy Advance模拟器, 采用了Canvas和Web Audio之类的HTML5技术。它包括对音频, 保存游戏和暂停/继续功能的支持。它不使用任何插件, 并且旨在在最先进的Web浏览器上运行。它托管在GitHub上, 并根据2条款BSD许可提供。
该库是由Jeffrey Pfau编写的, 我们非常感谢他为开源世界做出的如此贡献。
你需要知道的
- 脚本本身模拟ROM是完全合法的, 但是ROM(游戏文件)的分发完全不合法。因此, 请勿将其用于商业目的或其他不良行为, 尤其是在完全被拒绝的国家(例如德国)中。本文的意图纯粹是教育性的。
- 你需要使用http或https为HTML文件提供服务, 不允许使用文件协议, 因为你将无法导入任何ROM来运行。
在这种情况下, 我们将向你展示如何轻松使用仿真器或逐步详细的指南, 以实现仿真器的基本结构。
A.易于实施
如果因为只想测试而不想知道如何逐步实现GBA仿真器(要添加哪些文件等), 则可以使用git在计算机中克隆存储库:
git clone https://github.com/endrift/gbajs.git
或者, 你可以下载项目的zip克隆, 然后将文件提取到所需的文件夹中。然后确保使用某些http / https本地服务器或Node.js, Apache等为gbajs文件夹提供服务, 因为如前所述, 你不能使用file://协议访问模拟器的索引文件。
例如, 我们使用Xampp简化了所有http故事, 并且可以使用localhost访问gbajs文件夹, 并且可以使用模拟器:
如果你对所有这些东西的基本工作方式以及如何自己实现这些东西感兴趣, 请遵循下一点。
B.分步实施
要在浏览器中创建仿真器, 我们将以与处理任何类型的网页相同的方式开始, 创建一些标记(包括一些JS文件), 然后在浏览器中将其打开:
1.创建, 下载和导入所需资产
创建具有所需标记的基本HTML文档, 即具有原始Gameboy屏幕宽度和高度的Canvas标签。在我们的例子中, 该文件将是emulator.html, 此外, 你显然希望拥有控制台上可用的最基本的操作按钮, 例如暂停, 音量控制等:
<!DOCTYPE html>
<html lang="en">
<head>
<title>GBA Rocks</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<!-- Screen of the GBA.js -->
<canvas id="screen" width="480" height="320"></canvas>
<!-- Start Controls -->
<div id="controls">
<!-- Start App controls -->
<h4>App Controls</h4>
<div id="preload">
<button id="select"> Select ROM file </button>
<input id="loader" type="file" accept=".gba" />
<button id="select-savegame-btn">Upload Savegame</button>
<input id="saveloader" type="file" />
</div>
<!-- End App controls -->
<br>
<!-- Start ingame controls -->
<h4>In-game controls</h4>
<div id="ingame" class="hidden">
<button id="pause">Pause game</button>
<button id="reset-btn">Reset</button>
<button id="download-savegame">Download Savegame File</button>
<div id="sound">
<p>Audio enabled</p>
<input type="checkbox" id="audio-enabled-checkbox" checked="checked" />
<p>Change sound level</p>
<input id="volume-level-slider" type="range" min="0" max="1" value="1" step="any" />
</div>
</div>
<!-- End ingame controls -->
</div>
<!-- End Controls -->
</body>
</html>
为了使仿真器能够正常工作, 你将需要加载大约17个包含所需代码的JavaScript文件(约200KB, 没有缩小)。这些文件可以从Github的GBA.js官方存储库下载。拥有文件后, 将它们包括在文档中。你可以根据需要更改文件夹的结构, 这只是一个使用原始项目的结构的示例, 但是建议保留它, 因为以后还会异步下载其他JS文件, 例如js / video文件夹中需要有worker.js文件, 否则模拟器将无法工作:
<script src="js/util.js"></script>
<script src="js/core.js"></script>
<script src="js/arm.js"></script>
<script src="js/thumb.js"></script>
<script src="js/mmu.js"></script>
<script src="js/io.js"></script>
<script src="js/audio.js"></script>
<script src="js/video.js"></script>
<script src="js/video/proxy.js"></script>
<script src="js/video/software.js"></script>
<script src="js/irq.js"></script>
<script src="js/keypad.js"></script>
<script src="js/sio.js"></script>
<script src="js/savedata.js"></script>
<script src="js/gpio.js"></script>
<script src="js/gba.js"></script>
<!--
This file is optional as it only is a function to load the ROM
But the function loadRom needs to exist !
-->
<script src="resources/xhr.js"></script>
如果要减少页面的加载时间, 可以随意缩小文件大小。如前所述, 可以省略xhr.js文件, 而是在文件内部添加允许你加载ROM的方法。 XMLHttpRequest将仅检索具有arraybuffer格式的文件, 以便以后使用JavaScript处理, 因此你可以直接从另一个文件或在文档中使用脚本标记将其包括在内:
/**
* Loads the ROM from a file using ajax
*
* @param url
* @param callback
*/
function loadRom(url, callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
callback(xhr.response)
};
xhr.send();
}
2.下载GBA bios.bin文件
然后, 在同一资源文件夹中, 确保包括GBA的bios.bin文件(可在存储库中找到), 该文件必须位于资源目录中, 因为稍后将使用异步请求将其包含在内。显然, 一旦添加了模拟器必需的脚本(步骤3), 你便可以更改bios.bin文件的路径, 以后再从该文件导入文件的路径。
如果你在项目上实现所有内容, 却忘记包含bios.bin文件, 你将开始担心为什么它不起作用以及很明显的原因(也许不是很多)。我们需要GBA的bios.bin文件来仿真ROM, 就像买车一样, 但是你没有启动它的钥匙。 BIOS(密钥)具有一组特定的指令, 可告诉仿真器(汽车)如何启动。因此, 除非你已下载BIOS, 否则仿真器将无法工作。
3.添加模拟器所需的脚本
仿真器所需的脚本用于处理初始化, 加载BIOS, 更改音量, 暂停和恢复仿真器等。其中大多数是在用户单击某个按钮(步骤4)而使用其他按钮时使用的在内部通过其他重要功能:
var gba;
var runCommands = [];
// Setup the emulator
try {
gba = new GameBoyAdvance();
gba.keypad.eatInput = true;
gba.setLogger(function (level, error) {
console.error(error);
gba.pause();
var screen = document.getElementById('screen');
if (screen.getAttribute('class') == 'dead') {
console.log('We appear to have crashed multiple times without reseting.');
return;
}
// Show error image in the emulator screen
// The image can be retrieven from the repository
var crash = document.createElement('img');
crash.setAttribute('id', 'crash');
crash.setAttribute('src', 'resources/crash.png');
screen.parentElement.insertBefore(crash, screen);
screen.setAttribute('class', 'dead');
});
} catch (exception) {
gba = null;
}
// Initialize emulator once the browser loads
window.onload = function () {
if (gba && FileReader) {
var canvas = document.getElementById('screen');
gba.setCanvas(canvas);
gba.logLevel = gba.LOG_ERROR;
// Load the BIOS file of GBA (change the path according to yours)
loadRom('resources/bios.bin', function (bios) {
gba.setBios(bios);
});
if (!gba.audio.context) {
// Remove the sound box if sound isn't available
var soundbox = document.getElementById('sound');
soundbox.parentElement.removeChild(soundbox);
}
} else {
var dead = document.getElementById('controls');
dead.parentElement.removeChild(dead);
}
}
function fadeOut(id, nextId, kill) {
var e = document.getElementById(id);
var e2 = document.getElementById(nextId);
if (!e) {
return;
}
var removeSelf = function () {
if (kill) {
e.parentElement.removeChild(e);
} else {
e.setAttribute('class', 'dead');
e.removeEventListener('webkitTransitionEnd', removeSelf);
e.removeEventListener('oTransitionEnd', removeSelf);
e.removeEventListener('transitionend', removeSelf);
}
if (e2) {
e2.setAttribute('class', 'hidden');
setTimeout(function () {
e2.removeAttribute('class');
}, 0);
}
}
e.addEventListener('webkitTransitionEnd', removeSelf, false);
e.addEventListener('oTransitionEnd', removeSelf, false);
e.addEventListener('transitionend', removeSelf, false);
e.setAttribute('class', 'hidden');
}
/**
* Starts the emulator with the given ROM file
*
* @param file
*/
function run(file) {
var dead = document.getElementById('loader');
dead.value = '';
var load = document.getElementById('select');
load.textContent = 'Loading...';
load.removeAttribute('onclick');
var pause = document.getElementById('pause');
pause.textContent = "PAUSE";
gba.loadRomFromFile(file, function (result) {
if (result) {
for (var i = 0; i < runCommands.length; ++i) {
runCommands[i]();
}
runCommands = [];
fadeOut('preload', 'ingame');
fadeOut('instructions', null, true);
gba.runStable();
} else {
load.textContent = 'FAILED';
setTimeout(function () {
load.textContent = 'SELECT';
load.onclick = function () {
document.getElementById('loader').click();
};
}, 3000);
}
});
}
/**
* Resets the emulator
*
*/
function reset() {
gba.pause();
gba.reset();
var load = document.getElementById('select');
load.textContent = 'SELECT';
var crash = document.getElementById('crash');
if (crash) {
var context = gba.targetCanvas.getContext('2d');
context.clearRect(0, 0, 480, 320);
gba.video.drawCallback();
crash.parentElement.removeChild(crash);
var canvas = document.getElementById('screen');
canvas.removeAttribute('class');
} else {
lcdFade(gba.context, gba.targetCanvas.getContext('2d'), gba.video.drawCallback);
}
load.onclick = function () {
document.getElementById('loader').click();
};
fadeOut('ingame', 'preload');
// Clear the ROM
gba.rom = null;
}
/**
* Stores the savefile data in the emulator.
*
* @param file
*/
function uploadSavedataPending(file) {
runCommands.push(function () {
gba.loadSavedataFromFile(file)
});
}
/**
* Toggles the state of the game
*/
function togglePause() {
var e = document.getElementById('pause');
if (gba.paused) {
gba.runStable();
e.textContent = "PAUSE";
} else {
gba.pause();
e.textContent = "UNPAUSE";
}
}
/**
* From a canvas context, creates an LCD animation that fades the content away.
*
* @param context
* @param target
* @param callback
*/
function lcdFade(context, target, callback) {
var i = 0;
var drawInterval = setInterval(function () {
i++;
var pixelData = context.getImageData(0, 0, 240, 160);
for (var y = 0; y < 160; ++y) {
for (var x = 0; x < 240; ++x) {
var xDiff = Math.abs(x - 120);
var yDiff = Math.abs(y - 80) * 0.8;
var xFactor = (120 - i - xDiff) / 120;
var yFactor = (80 - i - ((y & 1) * 10) - yDiff + Math.pow(xDiff, 1 / 2)) / 80;
pixelData.data[(x + y * 240) * 4 + 3] *= Math.pow(xFactor, 1 / 3) * Math.pow(yFactor, 1 / 2);
}
}
context.putImageData(pixelData, 0, 0);
target.clearRect(0, 0, 480, 320);
if (i > 40) {
clearInterval(drawInterval);
} else {
callback();
}
}, 50);
}
/**
* Set the volume of the emulator.
*
* @param value
*/
function setVolume(value) {
gba.audio.masterVolume = Math.pow(2, value) - 1;
}
4.添加动作脚本
操作脚本只是附加到先前(第1步)创建的DOM元素的事件侦听器, 作为暂停, 加载ROM等的按钮。它们显然使用了上一步的某些脚本, 因此需要在以下环境中加载它们: gba存在。因此, 你可以从文档中的另一个文件中加载脚本, 也可以将它们包装在文档中的script标签中:
// If clicked, simulate click on the File Select input to load a ROM
document.getElementById("select").addEventListener("click", function(){
document.getElementById("loader").click();
}, false);
// Run the emulator with the loaded ROM
document.getElementById("loader").addEventListener("change", function(){
var ROM = this.files[0];
run(ROM);
}, false);
// If clicked, simulate click on the File Select Input to load the savegame file
document.getElementById("select-savegame-btn").addEventListener("click", function(){
document.getElementById('saveloader').click();
}, false);
// Load the savegame to the emulator
document.getElementById("saveloader").addEventListener("change", function(){
var SAVEGAME = this.files[0];
uploadSavedataPending(SAVEGAME);
}, false);
// Pause/Resume game
document.getElementById("pause").addEventListener("click", function(){
togglePause();
}, false);
// Reset game
document.getElementById("reset-btn").addEventListener("click", function(){
reset();
}, false);
// Download the savegamefile
document.getElementById("download-savegame").addEventListener("click", function(){
gba.downloadSavedata();
}, false);
// Mute/Unmute emulator
document.getElementById("audio-enabled-checkbox").addEventListener("change", function(){
gba.audio.masterEnable = this.checked;
}, false);
// Handle volume level slider
document.getElementById("volume-level-slider").addEventListener("change", function(){
var volumeLevel = this.value;
setVolume(volumeLevel);
}, false);
document.getElementById("volume-level-slider").addEventListener("input", function(){
var volumeLevel = this.value;
setVolume(volumeLevel);
}, false);
// In order to pause/resume the game when the user changes the website tab in the browser
// add the 2 following listeners to the window !
//
// This feature is problematic/tricky to handle, so you can make it better if you need to
window.onblur = function () {
if(gba.hasRom()){
var e = document.getElementById('pause');
if (!gba.paused) {
gba.pause();
e.textContent = "UNPAUSE";
console.log("Window Focused: the game has been paused");
}
}
};
window.onfocus = function () {
if(gba.hasRom()){
var e = document.getElementById('pause');
if (gba.paused) {
gba.runStable();
e.textContent = "PAUSE";
console.log("Window Focused: the game has been resumed");
}
}
};
使用此脚本, 你的应用程序终于可以使用了, 你可以开始对其进行测试。运行本地服务器, 然后转到emulator.html文件并对其进行测试。例如, 它如何加载保存游戏, 并且我们能够继续进行以前的游戏比赛:
如你所见, 第二种实现没有添加任何样式, 但是有助于理解库的基础知识以及如何轻松使用它们。仿真器本身功能非常强大。大多数游戏可以运行并且可以玩, 也许有些游戏有时仍会崩溃或在特定时间锁定。此外, 图形或声音中的其他小错误仍然存在, 开发人员正在努力修复这些错误。兼容性列表可以在这里找到。如果你认为已发现错误, 请在GitHub问题跟踪器上报告该错误。
最后的例子
以下文件emulator.html显示了文件的外观, 包括script标记内的所有脚本:
<!DOCTYPE html>
<html lang="en">
<head>
<title>GBA Rocks</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<!-- Screen of the GBA.js -->
<canvas id="screen" width="480" height="320"></canvas>
<!-- Start Controls -->
<div id="controls">
<!-- Start App controls -->
<h4>App Controls</h4>
<div id="preload">
<button id="select"> Select ROM file </button>
<input id="loader" type="file" accept=".gba" />
<button id="select-savegame-btn">Upload Savegame</button>
<input id="saveloader" type="file" />
</div>
<!-- End App controls -->
<br>
<!-- Start ingame controls -->
<h4>In-game controls</h4>
<div id="ingame" class="hidden">
<button id="pause">Pause game</button>
<button id="reset-btn">Reset</button>
<button id="download-savegame">Download Savegame File</button>
<div id="sound">
<p>Audio enabled</p>
<input type="checkbox" id="audio-enabled-checkbox" checked="checked" />
<p>Change sound level</p>
<input id="volume-level-slider" type="range" min="0" max="1" value="1" step="any" />
</div>
</div>
<!-- End ingame controls -->
</div>
<!-- End Controls -->
<script src="js/util.js"></script>
<script src="js/core.js"></script>
<script src="js/arm.js"></script>
<script src="js/thumb.js"></script>
<script src="js/mmu.js"></script>
<script src="js/io.js"></script>
<script src="js/audio.js"></script>
<script src="js/video.js"></script>
<script src="js/video/proxy.js"></script>
<script src="js/video/software.js"></script>
<script src="js/irq.js"></script>
<script src="js/keypad.js"></script>
<script src="js/sio.js"></script>
<script src="js/savedata.js"></script>
<script src="js/gpio.js"></script>
<script src="js/gba.js"></script>
<!--
This file is optional as it only is a function to load the ROM
But the function loadRom needs to exist !
-->
<script src="resources/xhr.js"></script>
<!-- Start APP Scripts -->
<script>
var gba;
var runCommands = [];
// Setup the emulator
try {
gba = new GameBoyAdvance();
gba.keypad.eatInput = true;
gba.setLogger(function (level, error) {
console.error(error);
gba.pause();
var screen = document.getElementById('screen');
if (screen.getAttribute('class') == 'dead') {
console.log('We appear to have crashed multiple times without reseting.');
return;
}
// Show error image in the emulator screen
// The image can be retrieven from the repository
var crash = document.createElement('img');
crash.setAttribute('id', 'crash');
crash.setAttribute('src', 'resources/crash.png');
screen.parentElement.insertBefore(crash, screen);
screen.setAttribute('class', 'dead');
});
} catch (exception) {
gba = null;
}
// Initialize emulator once the browser loads
window.onload = function () {
if (gba && FileReader) {
var canvas = document.getElementById('screen');
gba.setCanvas(canvas);
gba.logLevel = gba.LOG_ERROR;
// Load the BIOS file of GBA (change the path according to yours)
loadRom('resources/bios.bin', function (bios) {
gba.setBios(bios);
});
if (!gba.audio.context) {
// Remove the sound box if sound isn't available
var soundbox = document.getElementById('sound');
soundbox.parentElement.removeChild(soundbox);
}
} else {
var dead = document.getElementById('controls');
dead.parentElement.removeChild(dead);
}
}
function fadeOut(id, nextId, kill) {
var e = document.getElementById(id);
var e2 = document.getElementById(nextId);
if (!e) {
return;
}
var removeSelf = function () {
if (kill) {
e.parentElement.removeChild(e);
} else {
e.setAttribute('class', 'dead');
e.removeEventListener('webkitTransitionEnd', removeSelf);
e.removeEventListener('oTransitionEnd', removeSelf);
e.removeEventListener('transitionend', removeSelf);
}
if (e2) {
e2.setAttribute('class', 'hidden');
setTimeout(function () {
e2.removeAttribute('class');
}, 0);
}
}
e.addEventListener('webkitTransitionEnd', removeSelf, false);
e.addEventListener('oTransitionEnd', removeSelf, false);
e.addEventListener('transitionend', removeSelf, false);
e.setAttribute('class', 'hidden');
}
/**
* Starts the emulator with the given ROM file
*
* @param file
*/
function run(file) {
var dead = document.getElementById('loader');
dead.value = '';
var load = document.getElementById('select');
load.textContent = 'Loading...';
load.removeAttribute('onclick');
var pause = document.getElementById('pause');
pause.textContent = "PAUSE";
gba.loadRomFromFile(file, function (result) {
if (result) {
for (var i = 0; i < runCommands.length; ++i) {
runCommands[i]();
}
runCommands = [];
fadeOut('preload', 'ingame');
fadeOut('instructions', null, true);
gba.runStable();
} else {
load.textContent = 'FAILED';
setTimeout(function () {
load.textContent = 'SELECT';
load.onclick = function () {
document.getElementById('loader').click();
};
}, 3000);
}
});
}
/**
* Resets the emulator
*
*/
function reset() {
gba.pause();
gba.reset();
var load = document.getElementById('select');
load.textContent = 'SELECT';
var crash = document.getElementById('crash');
if (crash) {
var context = gba.targetCanvas.getContext('2d');
context.clearRect(0, 0, 480, 320);
gba.video.drawCallback();
crash.parentElement.removeChild(crash);
var canvas = document.getElementById('screen');
canvas.removeAttribute('class');
} else {
lcdFade(gba.context, gba.targetCanvas.getContext('2d'), gba.video.drawCallback);
}
load.onclick = function () {
document.getElementById('loader').click();
};
fadeOut('ingame', 'preload');
// Clear the ROM
gba.rom = null;
}
/**
* Stores the savefile data in the emulator.
*
* @param file
*/
function uploadSavedataPending(file) {
runCommands.push(function () {
gba.loadSavedataFromFile(file)
});
}
/**
* Toggles the state of the game
*/
function togglePause() {
var e = document.getElementById('pause');
if (gba.paused) {
gba.runStable();
e.textContent = "PAUSE";
} else {
gba.pause();
e.textContent = "UNPAUSE";
}
}
/**
* From a canvas context, creates an LCD animation that fades the content away.
*
* @param context
* @param target
* @param callback
*/
function lcdFade(context, target, callback) {
var i = 0;
var drawInterval = setInterval(function () {
i++;
var pixelData = context.getImageData(0, 0, 240, 160);
for (var y = 0; y < 160; ++y) {
for (var x = 0; x < 240; ++x) {
var xDiff = Math.abs(x - 120);
var yDiff = Math.abs(y - 80) * 0.8;
var xFactor = (120 - i - xDiff) / 120;
var yFactor = (80 - i - ((y & 1) * 10) - yDiff + Math.pow(xDiff, 1 / 2)) / 80;
pixelData.data[(x + y * 240) * 4 + 3] *= Math.pow(xFactor, 1 / 3) * Math.pow(yFactor, 1 / 2);
}
}
context.putImageData(pixelData, 0, 0);
target.clearRect(0, 0, 480, 320);
if (i > 40) {
clearInterval(drawInterval);
} else {
callback();
}
}, 50);
}
/**
* Set the volume of the emulator.
*
* @param value
*/
function setVolume(value) {
gba.audio.masterVolume = Math.pow(2, value) - 1;
}
</script>
<!-- End APP Scripts -->
<!-- Start Events Scripts -->
<script>
// If clicked, simulate click on the File Select input to load a ROM
document.getElementById("select").addEventListener("click", function(){
document.getElementById("loader").click();
}, false);
// Run the emulator with the loaded ROM
document.getElementById("loader").addEventListener("change", function(){
var ROM = this.files[0];
run(ROM);
}, false);
// If clicked, simulate click on the File Select Input to load the savegame file
document.getElementById("select-savegame-btn").addEventListener("click", function(){
document.getElementById('saveloader').click();
}, false);
// Load the savegame to the emulator
document.getElementById("saveloader").addEventListener("change", function(){
var SAVEGAME = this.files[0];
uploadSavedataPending(SAVEGAME);
}, false);
// Pause/Resume game
document.getElementById("pause").addEventListener("click", function(){
togglePause();
}, false);
// Reset game
document.getElementById("reset-btn").addEventListener("click", function(){
reset();
}, false);
// Download the savegamefile
document.getElementById("download-savegame").addEventListener("click", function(){
gba.downloadSavedata();
}, false);
// Mute/Unmute emulator
document.getElementById("audio-enabled-checkbox").addEventListener("change", function(){
gba.audio.masterEnable = this.checked;
}, false);
// Handle volume level slider
document.getElementById("volume-level-slider").addEventListener("change", function(){
var volumeLevel = this.value;
setVolume(volumeLevel);
}, false);
document.getElementById("volume-level-slider").addEventListener("input", function(){
var volumeLevel = this.value;
setVolume(volumeLevel);
}, false);
// In order to pause/resume the game when the user changes the website tab in the browser
// add the 2 following listeners to the window !
//
// This feature is problematic/tricky to handle, so you can make it better if you need to
window.onblur = function () {
if(gba.hasRom()){
var e = document.getElementById('pause');
if (!gba.paused) {
gba.pause();
e.textContent = "UNPAUSE";
console.log("Window Focused: the game has been paused");
}
}
};
window.onfocus = function () {
if(gba.hasRom()){
var e = document.getElementById('pause');
if (gba.paused) {
gba.runStable();
e.textContent = "PAUSE";
console.log("Window Focused: the game has been resumed");
}
}
};
</script>
<!-- End Events Scripts -->
</body>
</html>
最终建议
- 该项目旨在在理智(最新)的浏览器上运行, 因此不要期望对IE8的支持。
- 需要在ROM之前加载Savegame文件, 以确保其正常运行。
- 防止窗口滚动, 否则渲染过程会很繁重, 因此暂时会降低游戏速度。
- 尽管我们涵盖了模拟器的最重要方面, 但我们可能已经忘记了某些内容, 因此请不要忘记访问官方存储库和官方演示以获取更多信息。
快乐编码你可爱的玩家!
评论前必须登录!
注册