JSNES游戏模拟器在 Node.js 环境下的测试使用及高清显示优化
随着技术的发展和设备生态的不断扩展,游戏模拟器作为一项经典的娱乐方式也逐渐在新的操作系统上得到实现。本文将介绍如何在 Node.js 环境下使用 JavaScript NES 模拟器(JSNES),并通过生成动态 GIF 文件来测试其运行效果。
JSNES 简介
JSNES 是一个用纯 JavaScript 编写的 NES(Nintendo Entertainment System)游戏模拟器。它可以在支持 JavaScript 的环境中运行,无需额外的插件或软件安装。JSNES 的主要特点包括:
- 高兼容性:支持大量的 NES 游戏 ROM 文件,用户可以通过简单的 ROM 文件加载即可体验到经典游戏。
- 可移植性:由于是基于 JavaScript 编写的,JSNES 可以很容易地移植到各种支持 JavaScript 的运行环境中,包括浏览器、Node.js 以及基于 JavaScript 的操作系统。
- 开源许可:JSNES 使用 MIT 许可证分发,这意味着开发者可以自由地使用、修改和分发该模拟器,促进了游戏模拟技术的发展和社区共享。
完整工程源码下载地址(带资源可运行):
https://download.csdn.net/download/qq8864/92043021
安装依赖
要在 Node.js 环境下运行 JSNES,我们需要安装一些必要的依赖包,包括 jsnes
、canvas
和 gifencoder
。
npm install jsnes canvas gifencoder
代码实现
下面我们将编写代码来测试 JSNES 在 Node.js 环境下的运行情况,并生成动态 GIF 文件。
1. 导入必要的模块
const jsnes = require('jsnes');
const fs = require('fs');
const { createCanvas } = require('canvas');
const GIFEncoder = require('gifencoder');
2. 定义常量
// 定义常量
const SCREEN_WIDTH = 256;
const SCREEN_HEIGHT = 240;
const FRAMEBUFFER_SIZE = SCREEN_WIDTH * SCREEN_HEIGHT;
3. 创建 Canvas 和 GIFEncoder
// 创建Canvas和GIFEncoder
const canvas = createCanvas(SCREEN_WIDTH, SCREEN_HEIGHT);
const ctx = canvas.getContext('2d');
const encoder = new GIFEncoder(SCREEN_WIDTH, SCREEN_HEIGHT);// 设置GIFEncoder参数
encoder.setRepeat(0); // 0 for repeat, -1 for no-repeat
encoder.setDelay(50); // frame delay in ms
encoder.setQuality(10); // image quality. 10 is default
4. 分配帧缓冲区数组
// 分配帧缓冲区数组
let framebuffer_u8, framebuffer_u32;
let image = ctx.getImageData(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
let buffer = new ArrayBuffer(image.data.length);
framebuffer_u8 = new Uint8ClampedArray(buffer);
framebuffer_u32 = new Uint32Array(buffer);
5. 初始化画布背景
// 初始化画布背景
ctx.fillStyle = "black";
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
6. 初始化 NES 模拟器
// 初始化NES模拟器
const nes = new jsnes.NES({onFrame: function(framebuffer_24) {console.log('onFrame enter:');// 将帧缓冲区数据转换为Uint32Arrayfor (let i = 0; i < FRAMEBUFFER_SIZE; i++) {framebuffer_u32[i] = 0xFF000000 | framebuffer_24[i];}// 设置图像数据并绘制到Canvasimage.data.set(framebuffer_u8);ctx.putImageData(image, 0, 0);// 添加当前帧到GIFencoder.addFrame(ctx);},onAudioSample: function(left, right) {// 处理音频样本,这里可以添加音频播放逻辑// console.log('onAudioSample:');}
});
7. 读取 ROM 文件
// 读取ROM文件
try {const romData = fs.readFileSync('test1.nes', { encoding: 'binary' });nes.loadROM(romData);
} catch (error) {console.error('读取ROM文件失败:', error);process.exit(1);
}
8. 启动 GIF 编码
// 启动GIF编码
encoder.start();
9. 运行 NES 帧
// 运行NES帧
const frameTime = 1000 / 60; // 60 FPS 意味着每帧 16.67 毫秒
function runFrame() {nes.frame();// 可以在这里处理输入设备// nes.buttonDown(1, jsnes.Controller.BUTTON_A);// nes.frame();// nes.buttonUp(1, jsnes.Controller.BUTTON_A);// nes.frame();
}setInterval(runFrame, frameTime);
10. 保持应用程序活动状态
// 保持应用程序活动状态
process.stdin.resume();
11. 处理 Ctrl+C 退出
// 处理 Ctrl+C 退出
process.on('SIGINT', function() {console.log('Exiting...');encoder.finish();const buffer = encoder.out.getData();fs.writeFileSync('animation.gif', buffer);console.log('动态GIF已生成: animation.gif');process.exit();
});
注意事项
-
性能优化:
- NES 模拟器对性能要求较高,考虑使用多线程来提高整体性能。
- 如果需要,可以关闭音频模拟以提高渲染效率。
-
音频适配:
- Node.js 环境下没有浏览器的 Web Audio API,需要特别适配音频处理。
-
内存管理:
- 确保及时释放不再使用的资源。
- 注意 Node.js 的内存使用限制。
-
兼容性:
- 测试不同的 ROM 文件以确保兼容性。
完整测试代码
const jsnes = require('jsnes');
const fs = require('fs');
const { createCanvas } = require('canvas');
const GIFEncoder = require('gifencoder');console.log('Hello World!');// 定义常量
const SCREEN_WIDTH = 256;
const SCREEN_HEIGHT = 240;
const FRAMEBUFFER_SIZE = SCREEN_WIDTH * SCREEN_HEIGHT;// 创建Canvas和GIFEncoder
const canvas = createCanvas(SCREEN_WIDTH, SCREEN_HEIGHT);
const ctx = canvas.getContext('2d');
const encoder = new GIFEncoder(SCREEN_WIDTH, SCREEN_HEIGHT);// 设置GIFEncoder参数
encoder.setRepeat(0); // 0 for repeat, -1 for no-repeat
encoder.setDelay(50); // frame delay in ms
encoder.setQuality(10); // image quality. 10 is default// 分配帧缓冲区数组
let framebuffer_u8, framebuffer_u32;
let image = ctx.getImageData(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
let buffer = new ArrayBuffer(image.data.length);
framebuffer_u8 = new Uint8ClampedArray(buffer);
framebuffer_u32 = new Uint32Array(buffer);// 初始化画布背景
ctx.fillStyle = "black";
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);// 初始化NES模拟器
const nes = new jsnes.NES({onFrame: function(framebuffer_24) {console.log('onFrame enter:');// 将帧缓冲区数据转换为Uint32Arrayfor (let i = 0; i < FRAMEBUFFER_SIZE; i++) {framebuffer_u32[i] = 0xFF000000 | framebuffer_24[i];}// 设置图像数据并绘制到Canvasimage.data.set(framebuffer_u8);ctx.putImageData(image, 0, 0);// 添加当前帧到GIFencoder.addFrame(ctx);},onAudioSample: function(left, right) {// 处理音频样本,这里可以添加音频播放逻辑// console.log('onAudioSample:');}
});// 读取ROM文件
try {const romData = fs.readFileSync('test1.nes', { encoding: 'binary' });nes.loadROM(romData);
} catch (error) {console.error('读取ROM文件失败:', error);process.exit(1);
}// 启动GIF编码
encoder.start();// 运行NES帧
const frameTime = 1000 / 60; // 60 FPS 意味着每帧 16.67 毫秒
function runFrame() {nes.frame();// 可以在这里处理输入设备// nes.buttonDown(1, jsnes.Controller.BUTTON_A);// nes.frame();// nes.buttonUp(1, jsnes.Controller.BUTTON_A);// nes.frame();
}setInterval(runFrame, frameTime);// 保持应用程序活动状态
process.stdin.resume();// 处理 Ctrl+C 退出
process.on('SIGINT', function() {console.log('Exiting...');encoder.finish();const buffer = encoder.out.getData();fs.writeFileSync('animation.gif', buffer);console.log('动态GIF已生成: animation.gif');process.exit();
});
高清显示优化算法
默认的游戏画质太差了,可以使用优化算法提高显示分辨率,增强在手机或平板等大屏设备上的使用体验。
这是一个基于jsnes的NES游戏模拟器,支持将原始的256x240分辨率游戏画面放大到高分辨率,适配现代手机屏幕。
以下是显示算法优化效果:
- 🎮 支持NES游戏ROM文件
- 📱 多种手机屏幕分辨率支持 (720p, 1080p, 1440p, 4K)
- 🎨 两种图像放大算法:
- 最近邻插值:保持像素艺术风格
- 双线性插值:平滑效果
- 🎬 生成高质量GIF动画
- ⚙️ 灵活的配置系统
const jsnes = require('jsnes');
const fs = require('fs');
const { createCanvas } = require('canvas');
const GIFEncoder = require('gifencoder');
const config = require('./config');// 定义常量
const NES_WIDTH = 256;
const NES_HEIGHT = 240;
const NES_FRAMEBUFFER_SIZE = NES_WIDTH * NES_HEIGHT;// 从配置文件加载设置
const DISPLAY_CONFIGS = config.DISPLAY_CONFIGS;
const DISPLAY_CONFIG = DISPLAY_CONFIGS[config.CURRENT_CONFIG];
const SCREEN_WIDTH = DISPLAY_CONFIG.width;
const SCREEN_HEIGHT = DISPLAY_CONFIG.height;
const SCALE_FACTOR = DISPLAY_CONFIG.scale;console.log('=== NES游戏模拟器 - 高分辨率版本 ===');
console.log(`当前配置: ${Object.keys(DISPLAY_CONFIGS).find(key => DISPLAY_CONFIGS[key] === DISPLAY_CONFIG)}`);
console.log(`输出分辨率: ${SCREEN_WIDTH}x${SCREEN_HEIGHT}`);
console.log(`放大倍数: ${SCALE_FACTOR}x`);
console.log(`插值算法: ${config.USE_NEAREST_NEIGHBOR ? '最近邻插值(保持像素艺术风格)' : '双线性插值(平滑效果)'}`);
console.log('按 Ctrl+C 停止录制并生成GIF文件');
console.log('=====================================');// 图像放大算法
function nearestNeighborUpscale(sourceData, sourceWidth, sourceHeight, targetWidth, targetHeight) {const targetData = new Uint8ClampedArray(targetWidth * targetHeight * 4);const scaleX = sourceWidth / targetWidth;const scaleY = sourceHeight / targetHeight;for (let y = 0; y < targetHeight; y++) {for (let x = 0; x < targetWidth; x++) {const sourceX = Math.floor(x * scaleX);const sourceY = Math.floor(y * scaleY);const sourceIndex = (sourceY * sourceWidth + sourceX) * 4;const targetIndex = (y * targetWidth + x) * 4;targetData[targetIndex] = sourceData[sourceIndex]; // RtargetData[targetIndex + 1] = sourceData[sourceIndex + 1]; // GtargetData[targetIndex + 2] = sourceData[sourceIndex + 2]; // BtargetData[targetIndex + 3] = sourceData[sourceIndex + 3]; // A}}return targetData;
}// 双线性插值放大算法(更平滑但可能模糊像素艺术)
function bilinearUpscale(sourceData, sourceWidth, sourceHeight, targetWidth, targetHeight) {const targetData = new Uint8ClampedArray(targetWidth * targetHeight * 4);const scaleX = (sourceWidth - 1) / targetWidth;const scaleY = (sourceHeight - 1) / targetHeight;for (let y = 0; y < targetHeight; y++) {for (let x = 0; x < targetWidth; x++) {const gx = x * scaleX;const gy = y * scaleY;const gxi = Math.floor(gx);const gyi = Math.floor(gy);const gxw = gx - gxi;const gyw = gy - gyi;const c00 = (gyi * sourceWidth + gxi) * 4;const c10 = (gyi * sourceWidth + Math.min(gxi + 1, sourceWidth - 1)) * 4;const c01 = (Math.min(gyi + 1, sourceHeight - 1) * sourceWidth + gxi) * 4;const c11 = (Math.min(gyi + 1, sourceHeight - 1) * sourceWidth + Math.min(gxi + 1, sourceWidth - 1)) * 4;const targetIndex = (y * targetWidth + x) * 4;for (let i = 0; i < 4; i++) {const c0 = sourceData[c00 + i] * (1 - gxw) + sourceData[c10 + i] * gxw;const c1 = sourceData[c01 + i] * (1 - gxw) + sourceData[c11 + i] * gxw;targetData[targetIndex + i] = Math.round(c0 * (1 - gyw) + c1 * gyw);}}}return targetData;
}// 创建Canvas和GIFEncoder
const canvas = createCanvas(SCREEN_WIDTH, SCREEN_HEIGHT);
const ctx = canvas.getContext('2d');
const encoder = new GIFEncoder(SCREEN_WIDTH, SCREEN_HEIGHT);// 设置GIFEncoder参数
encoder.setRepeat(config.GIF_SETTINGS.repeat);
encoder.setDelay(config.GIF_SETTINGS.delay);
encoder.setQuality(config.GIF_SETTINGS.quality);// 分配帧缓冲区数组
let nes_framebuffer_u8, nes_framebuffer_u32;
let nes_image = ctx.getImageData(0, 0, NES_WIDTH, NES_HEIGHT);
let nes_buffer = new ArrayBuffer(nes_image.data.length);
nes_framebuffer_u8 = new Uint8ClampedArray(nes_buffer);
nes_framebuffer_u32 = new Uint32Array(nes_buffer);// 创建高分辨率图像数据
let display_image = ctx.getImageData(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);// 初始化画布背景
ctx.fillStyle = "black";
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);// 配置参数
const USE_NEAREST_NEIGHBOR = config.USE_NEAREST_NEIGHBOR;// 初始化NES模拟器
const nes = new jsnes.NES({onFrame: function(framebuffer_24) {console.log('onFrame enter:');// 将NES帧缓冲区数据转换为Uint32Arrayfor (let i = 0; i < NES_FRAMEBUFFER_SIZE; i++) {nes_framebuffer_u32[i] = 0xFF000000 | framebuffer_24[i];}// 设置NES原始图像数据nes_image.data.set(nes_framebuffer_u8);// 使用选择的算法进行图像放大let upscaledData;if (USE_NEAREST_NEIGHBOR) {upscaledData = nearestNeighborUpscale(nes_framebuffer_u8, NES_WIDTH, NES_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);} else {upscaledData = bilinearUpscale(nes_framebuffer_u8, NES_WIDTH, NES_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);}// 设置高分辨率图像数据display_image.data.set(upscaledData);ctx.putImageData(display_image, 0, 0);// 添加当前帧到GIFencoder.addFrame(ctx);},onAudioSample: function(left, right) {// 处理音频样本,这里可以添加音频播放逻辑// console.log('onAudioSample:');}
});// 读取ROM文件
try {const romData = fs.readFileSync('test1.nes', { encoding: 'binary' });nes.loadROM(romData);
} catch (error) {console.error('读取ROM文件失败:', error);process.exit(1);
}// 启动GIF编码
encoder.start();// 运行NES帧
const frameTime = 1000 / 60; // 60 FPS 意味着每帧 16.67 毫秒
function runFrame() {nes.frame();// 可以在这里处理输入设备// nes.buttonDown(1, jsnes.Controller.BUTTON_A);// nes.frame();// nes.buttonUp(1, jsnes.Controller.BUTTON_A);// nes.frame();
}setInterval(runFrame, frameTime);// 保持应用程序活动状态
process.stdin.resume();// 处理 Ctrl+C 退出
process.on('SIGINT', function() {console.log('Exiting...');encoder.finish();const buffer = encoder.out.getData();// 根据配置生成文件名const configName = Object.keys(DISPLAY_CONFIGS).find(key => DISPLAY_CONFIGS[key] === DISPLAY_CONFIG);const algorithmName = USE_NEAREST_NEIGHBOR ? 'nearest' : 'bilinear';const filename = `nes_${configName}_${algorithmName}_${SCREEN_WIDTH}x${SCREEN_HEIGHT}.gif`;fs.writeFileSync(filename, buffer);console.log(`高分辨率动态GIF已生成: ${filename}`);console.log(`分辨率: ${SCREEN_WIDTH}x${SCREEN_HEIGHT}`);console.log(`放大倍数: ${SCALE_FACTOR}x`);console.log(`插值算法: ${USE_NEAREST_NEIGHBOR ? '最近邻插值(像素艺术风格)' : '双线性插值(平滑)'}`);process.exit();
});
在这里插入代码片
网页版对比优化算法后的效果
nes-embed.js文件
const NES_WIDTH = 256;
const NES_HEIGHT = 240;
const NES_FRAMEBUFFER_SIZE = NES_WIDTH * NES_HEIGHT;var canvas_ctx, image,display_image;
var framebuffer_u8, framebuffer_u32;var AUDIO_BUFFERING = 512;
var SAMPLE_COUNT = 4*1024;
var SAMPLE_MASK = SAMPLE_COUNT - 1;
var audio_samples_L = new Float32Array(SAMPLE_COUNT);
var audio_samples_R = new Float32Array(SAMPLE_COUNT);
var audio_write_cursor = 0, audio_read_cursor = 0;const SCREEN_WIDTH = 2560;
const SCREEN_HEIGHT = 1440;
var FRAMEBUFFER_SIZE = SCREEN_WIDTH*SCREEN_HEIGHT;var nes = new jsnes.NES({onFrame: function(framebuffer_24){for(var i = 0; i < NES_FRAMEBUFFER_SIZE; i++) framebuffer_u32[i] = 0xFF000000 | framebuffer_24[i];// 截取前100字节var frameBufferSlice = framebuffer_u8.slice(0, 100);var hexString = Array.prototype.map.call(frameBufferSlice, function(byte) {return ('0' + (byte & 0xFF).toString(16)).slice(-2);}).join('');// 打印十六进制字符串console.log('onFrames: ' + hexString);},onAudioSample: function(l, r){audio_samples_L[audio_write_cursor] = l;audio_samples_R[audio_write_cursor] = r;audio_write_cursor = (audio_write_cursor + 1) & SAMPLE_MASK;},
});function onAnimationFrame(){window.requestAnimationFrame(onAnimationFrame);image.data.set(framebuffer_u8);//canvas_ctx.putImageData(image, 0, 0);// 使用选择的算法进行图像放大let upscaledData;upscaledData = nearestNeighborUpscale(framebuffer_u8, NES_WIDTH, NES_HEIGHT, SCREEN_WIDTH, SCREEN_HEIGHT);// 设置高分辨率图像数据display_image.data.set(upscaledData);canvas_ctx.putImageData(display_image, 0, 0);
}function audio_remain(){return (audio_write_cursor - audio_read_cursor) & SAMPLE_MASK;
}function audio_callback(event){var dst = event.outputBuffer;var len = dst.length;// Attempt to avoid buffer underruns.if(audio_remain() < AUDIO_BUFFERING) nes.frame();var dst_l = dst.getChannelData(0);var dst_r = dst.getChannelData(1);for(var i = 0; i < len; i++){var src_idx = (audio_read_cursor + i) & SAMPLE_MASK;dst_l[i] = audio_samples_L[src_idx];dst_r[i] = audio_samples_R[src_idx];}audio_read_cursor = (audio_read_cursor + len) & SAMPLE_MASK;
}function keyboard(callback, event){var player = 1;switch(event.keyCode){case 38: // UPcallback(player, jsnes.Controller.BUTTON_UP); break;case 40: // Downcallback(player, jsnes.Controller.BUTTON_DOWN); break;case 37: // Leftcallback(player, jsnes.Controller.BUTTON_LEFT); break;case 39: // Rightcallback(player, jsnes.Controller.BUTTON_RIGHT); break;case 65: // 'a' - qwerty, dvorakcase 81: // 'q' - azertycallback(player, jsnes.Controller.BUTTON_A); break;case 83: // 's' - qwerty, azertycase 79: // 'o' - dvorakcallback(player, jsnes.Controller.BUTTON_B); break;case 9: // Tabcallback(player, jsnes.Controller.BUTTON_SELECT); break;case 13: // Returncallback(player, jsnes.Controller.BUTTON_START); break;default: break;}
}function nes_init(canvas_id){var canvas = document.getElementById(canvas_id);canvas_ctx = canvas.getContext("2d");image = canvas_ctx.getImageData(0, 0, NES_WIDTH, NES_HEIGHT);// 创建高分辨率图像数据display_image = canvas_ctx.getImageData(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);canvas_ctx.fillStyle = "black";canvas_ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);// Allocate framebuffer array.var buffer = new ArrayBuffer(image.data.length);framebuffer_u8 = new Uint8ClampedArray(buffer);framebuffer_u32 = new Uint32Array(buffer);// Setup audio.var audio_ctx = new window.AudioContext();var script_processor = audio_ctx.createScriptProcessor(AUDIO_BUFFERING, 0, 2);script_processor.onaudioprocess = audio_callback;script_processor.connect(audio_ctx.destination);
}function nes_boot(rom_data){nes.loadROM(rom_data);window.requestAnimationFrame(onAnimationFrame);
}function nes_load_data(canvas_id, rom_data){nes_init(canvas_id);nes_boot(rom_data);
}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) {nes_boot(this.responseText);} else if (this.status === 0) {// Aborted, so ignore error} else {req.onerror();}};req.send();
}// 图像放大算法
function nearestNeighborUpscale(sourceData, sourceWidth, sourceHeight, targetWidth, targetHeight) {const targetData = new Uint8ClampedArray(targetWidth * targetHeight * 4);const scaleX = sourceWidth / targetWidth;const scaleY = sourceHeight / targetHeight;for (let y = 0; y < targetHeight; y++) {for (let x = 0; x < targetWidth; x++) {const sourceX = Math.floor(x * scaleX);const sourceY = Math.floor(y * scaleY);const sourceIndex = (sourceY * sourceWidth + sourceX) * 4;const targetIndex = (y * targetWidth + x) * 4;targetData[targetIndex] = sourceData[sourceIndex]; // RtargetData[targetIndex + 1] = sourceData[sourceIndex + 1]; // GtargetData[targetIndex + 2] = sourceData[sourceIndex + 2]; // BtargetData[targetIndex + 3] = sourceData[sourceIndex + 3]; // A}}return targetData;
}document.addEventListener('keydown', (event) => {keyboard(nes.buttonDown, event)});
document.addEventListener('keyup', (event) => {keyboard(nes.buttonUp, event)});
网页文件:
<!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", "test1.nes");}</script></head><body><div style="margin: auto; width: 75%;"><canvas id="nes-canvas" width="2560" height="1440" style="width: 100%"/></div><p>DPad: Arrow keys<br/>Start: Return, Select: Tab<br/>A Button: A, B Button: S</p></body>
</html>
运行命令:
python -m http.server
效果明显增强不少,显示清晰,没有模糊感啦。
结论
通过上述步骤,我们成功地将 JSNES 移植到 Node.js 环境下,并生成了一个动态 GIF 文件来测试其运行效果。JSNES 的高兼容性和可移植性使其成为在不同环境中运行 NES 游戏的理想选择。希望本文对您有所帮助!
参考资料
- JSNES GitHub 仓库
- Node.js Canvas
- GIFEncoder GitHub 仓库
通过这些资源,您可以进一步了解和扩展 JSNES 在 Node.js 环境下的功能。