本文共 16085 字,大约阅读时间需要 53 分钟。
我相信大多数人都用模拟器玩过游戏吧!比如GBA模拟器,PSP模拟器,NES模拟器等。所以应该也有人会跟我一样想自己写个游戏机模拟器。但这些模拟器对于一个新手来说难度太大了,就比如NES模拟器中CPU的指令就有100个以上了,更别说除了CPU还有显卡之类的东西需要模拟。所以有没有一个比较简单适新手的模拟器项目呢?后来我就找到了 Chip-8。
文章完整代码放在: 关于Chip-8参考资料(可能有墙):
我们根据可以了解到CHIP8是一种解释性的编程语言。最初被应用是在1970年代中期。CHIP8的程序运行在CHIP8虚拟机中,它的出现让电子游戏编程变得简单些了(相对于那个年代来说)。用CHIP8实现的电子游戏有,比如小蜜蜂,俄罗斯方块,吃豆人等。更多可以前往了解。
我们假设CHIP8是由处理器、键盘、显示屏与扬声器组成,其中CPU是CHIP8核心,那么代码应该像这样的:
创建Chip8对象
根据可以了解到,CHIP8显示分辨率是64X32的像素,并且是单色的。某像素点为1则屏幕上显示相应像素点,为0则不显示。但某个像素点由有到无则进位标识被设置为1,可以用来进行冲撞检测。
那么代码应该像这样:function Screen() { this.rows = 32;//32行 this.columns = 64;//64列 this.resolution = this.rows * this.columns;//分辨率 this.bitMap = new Array(this.resolution);//像素点阵 this.clear = function () { this.bitMap = new Array(this.resolution); } this.render = function () { };//显示渲染 this.setPixel = function (x, y) {//在屏幕坐标(x,y)进行计算与显示 // 显示溢出处理 if (x > this.columns - 1) while (x > this.columns - 1) x -= this.columns; if (x < 0) while (x < 0) x += this.columns; if (y > this.rows - 1) while (y > this.rows - 1) y -= this.rows; if (y < 0) while (y < 0) y += this.rows; //获取点阵索引 var location = x + (y * this.columns); //反向显示,假设二值颜色黑白分别用1、0代表,那么值为1那么就将值设置成0,同理0的话变成1 this.bitMap[location] = this.bitMap[location] ^ 1; return !this.bitMap[location]; }};
编写好显示模块我们编写显示屏来测试显示模块():
var chip8 = CHIP8();chip8.screen.render = function () {//自定义实现显示渲染 var boxs = document.getElementById("boxs"); boxs.innerHTML = ""; for (var i of this.bitMap) { var d = document.createElement("span"); d.style = "width: 5px;height: 5px;float: left;"; d.style.backgroundColor = i ? "#000" : "#fff"; boxs.appendChild(d); }};/** 测试 **/chip8.screen.setPixel(2, 2);//设置x,y坐标像素chip8.screen.render();chip8.screen.setPixel(2, 2);//设置x,y坐标像素
这里需要参考 :
- API
- API
- 示例
- 示例
扬声器也十分简单:
function Speaker() { var contextClass = (window.AudioContext || window.webkitAudioContext || window.mozAudioContext || window.oAudioContext || window.msAudioContext) , context , oscillator , gain; if (contextClass) { context = new contextClass(); gain = context.createGain(); gain.connect(context.destination); } //播放声音 this.play = function (frequency) { //API https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode //示例 https://mdn.github.io/violent-theremin/ if (context && !oscillator) { oscillator = context.createOscillator(); oscillator.frequency.value = frequency || 440;//声音频率 oscillator.type = oscillator.TRIANGLE;//波形这里用的是三角波 查看示例:https://codepen.io/gregh/pen/LxJEaj oscillator.connect(gain); oscillator.start(0); } } //停止播放 this.clear = this.stop = function () { if (oscillator) { oscillator.stop(0); oscillator.disconnect(0); oscillator = null; } }};
编写好扬声器我们可以对扬声器进行测试():
编写扬声器 频率:
CHIP8的输入设备是一个十六进制的键盘,其中有16个键值,0~F。“8”“6”“4”“2”一般用于方向输入。有三个操作码用来处理输入,其中一个是当键值按下则执行下一个指令,对应的是另外一个操作码处理指定键值没有按下则调到下一个指令。第三个操作码是等待一个按键按下,然后将其存放一个寄存器里。
CHIP8键盘布局:1 | 2 | 3 | C |
4 | 5 | 6 | D |
7 | 8 | 9 | E |
A | 0 | B | F |
Chip8 我们键盘的映射--------- --------- 1 2 3 C 1 2 3 4 4 5 6 D q w e r 7 8 9 E a s d f A 0 B F z x c v
function Keyboard() { var keysPressed = [];//记录按下的按键 //处理下一个按键 this.onNextKeyPress = function () { } //清空 this.clear = function () { keysPressed = []; this.onNextKeyPress = function () { } } //当前按键是否按下 this.isKeyPressed = function (property) { var key = Keyboard.MAPPING[property]; return !!keysPressed[key]; } var self = this; this.keyDown = function (event) { var key = String.fromCharCode(event.which); keysPressed[key] = true; for (var property in Keyboard.MAPPING) { var keyCode = Keyboard.MAPPING[property]; if (keyCode == key) { try { self.onNextKeyPress(parseInt(property),keyCode); } finally { self.onNextKeyPress = function () { } } } } } this.keyUp = function (event) { var key = String.fromCharCode(event.which); keysPressed[key] = false; } window.addEventListener("keydown", this.keyDown, false);//绑定键盘按下时间 window.addEventListener("keyup", this.keyUp, false);//绑定键盘弹起时间};//自定义实际键盘按键对应Chip8输入值Keyboard.MAPPING = { 0x1:"1", 0x2:"2", 0x3:"3", 0xC:"4", 0x4:"Q", 0x5:"W", 0x6:"E", 0xD:"R", 0x7:"A", 0x8:"S", 0x9:"D", 0xE:"F", 0xA:"Z", 0x0:"X", 0xB:"C", 0xF:"V"}
编写好键盘输入,同样我们可以对演示器进行测试()
如果你已经成功编写完成了键盘、扬声器与显示器
那么接下去的就是重点了同样是在 上可以了解到:
CHIP8基本是在一个有4K内存的系统上实现,也就是4096个字节。前512(也就是从0x000到0x1ff)字节由CHIP8的解释器占据。所以CHIP8的程序都是从0x200地址开始的。最顶上的256个字(0xF00-0xFFF) 用于显示刷新,在这下面的96个字节 (0xEA0-0xEFF) 用于栈, 内部使用或者其他变量。其中前512字节 (0x000-0x200) 存放字体数据
CHIP8有16个通用8位数据寄存器,V0~VF。VF寄存器存放进位标识。还有一个地址寄存器叫做I,2个字节的长度。
程序计数器(PC)应该是16位的,是用来存放当前正在执行的地址,堆栈是16个16位值的数组,用于存放函数返回的地址值和保存一些数据。CHIP8提供了2个定时器,延时定时器和一个声音定时器
延时定时器活跃时,延时定时器寄存器(Delay Timer 简称 DT)是非零的。这个定时器只是减去1,DT频率的在60Hz。当DT达0时无效。 声音定时器活跃时,声音定时器寄存器(Sound Timer 简称 ST)是非零的。这个定时器也递减率在60Hz,然而,只要ST的价值大于零,该CHIP8蜂鸣器发声。当ST达到零,定时器关闭声音。 由CHIP8翻译产生的声音只有一种声音。声音的音调或频率是由解释器开发者决定的。function CPU() { this.pc = 0x200;//CHIP8的程序都是从0x200地址开始的 this.stack = new Array;//堆栈指针 this.screen = { clear: function () { }, render: function () { }, setPixel: function () { } };//显示 this.input = { isKeyPressed: function (key) { }, clear: function () { } };//输入 this.speaker = { clear: function () { }, play: function () { }, stop: function () { } };//扬声器 this.v = new Uint8Array(16);//16个数据寄存器 V0~VF this.i = 0;//地址寄存器 this.memory = new Uint8Array(4096);//4K内存 this.delayTimer = 0;//延时计时器 this.soundTimer = 0;//声音计时器 this.paused = false;//暂停 this.speed = 10;//运行速度 /** * 用默认值重置CPU的一些参数 */ this.reset = function () { this.pc = 0x200; this.stack = new Array; this.v = new Uint8Array(16); this.i = 0; this.memory = new Uint8Array(4096); this.delayTimer = 0; this.soundTimer = 0; this.screen.clear(); this.input.clear(); this.speaker.clear(); this.loadFonts(); this.paused = false; }; /** * 显示渲染 */ this.render = function () { this.screen.render(); }; /** * 播放扬声器直到声音计时器达到零 */ this.playSound = function () { if (this.soundTimer > 0) {//只要soundTimer的值大于零,CHIP8蜂鸣器发声。 this.speaker.play(); } else { this.speaker.stop(); } } /** * 更新CPU延迟和声音计时器 */ this.updateTimers = function () { if (this.delayTimer > 0) this.delayTimer -= 1;//递减至0 if (this.soundTimer > 0) this.soundTimer -= 1;//递减至0 } /** * 加载字体到chip8内存 */ this.loadFonts = function () { var fonts = [ 0xF0, 0x90, 0x90, 0x90, 0xF0, // 0 0x20, 0x60, 0x20, 0x20, 0x70, // 1 0xF0, 0x10, 0xF0, 0x80, 0xF0, // 2 0xF0, 0x10, 0xF0, 0x10, 0xF0, // 3 0x90, 0x90, 0xF0, 0x10, 0x10, // 4 0xF0, 0x80, 0xF0, 0x10, 0xF0, // 5 0xF0, 0x80, 0xF0, 0x90, 0xF0, // 6 0xF0, 0x10, 0x20, 0x40, 0x40, // 7 0xF0, 0x90, 0xF0, 0x90, 0xF0, // 8 0xF0, 0x90, 0xF0, 0x10, 0xF0, // 9 0xF0, 0x90, 0xF0, 0x90, 0x90, // A 0xE0, 0x90, 0xE0, 0x90, 0xE0, // B 0xF0, 0x80, 0x80, 0x80, 0xF0, // C 0xE0, 0x90, 0x90, 0x90, 0xE0, // D 0xF0, 0x80, 0xF0, 0x80, 0xF0, // E 0xF0, 0x80, 0xF0, 0x80, 0x80 // F ]; for (var i = 0, length = fonts.length; i < length; i++) { this.memory[i] = fonts[i]; } }; /** * 装程序到内存 * @param {Array} program 二进制程序 */ this.loadProgram = function (program) { for (var i = 0, length = program.length; i < length; i++) { this.memory[0x200 + i] = program[i]; } } /** * CPU 开始执行 */ this.cycle = function () { for (var i = 0; i < this.speed; i++) { if (!this.paused) { var opcode = this.memory[this.pc] << 8 | this.memory[this.pc + 1]; //获取操作码,chip-8操作码是两个字节的长度,我们可以读到这两个字节或连接起来 this.perform(opcode); } } if (!this.paused) { this.updateTimers(); } this.playSound(); this.render(); }; /** * 一个给定的操作码的进行解析执行 * @param {Integer} opcode */ this.perform = function (opcode) {/****/ }};
在编写指令集之前你要对JavaScript中的位运算有所了解
你可以查看我编写的 简单了解下
在 上有对操作码的说明:
根据说明我们编写指令集就简单很多,我的做法是先获取到 X Y NNN NN N 然后对操作码进行解析
代码://略.....this.perform = function (opcode) { this.pc += 2;//每个指令都是两个字节长 var x = (opcode & 0x0F00) >> 8;//取得x var y = (opcode & 0x00F0) >> 4;//y var NNN = opcode & 0x0FFF; var NN = opcode & 0x00FF; var N=opcode & 0x000F; ({ 0x0000() { let r = ({ //00E0 //执行“清理屏幕” 0x00E0() { self.screen.clear(); }, //00EE //执行“从子函数返回” 0x00EE() { self.pc = self.stack.pop(); } })[opcode]; if (r) r(); }, //1NNN //跳转到地址:NNN //例如:0x1222 则跳转到 0x0222 0x1000() { self.pc = NNN; }, //2NNN //解释器递增堆栈指针,然后跳转到地址:NNN 0x2000() { self.stack.push(self.pc); self.pc = NNN; }, //3XNN // if(Vx==NN) 将程序计数器递增2 跳过 0x3000() { if (self.v[x] == NN) self.pc += 2; }, //4XNN //if(Vx!=NN) 将程序计数器递增2 跳过 0x4000() { if (self.v[x] != NN) self.pc += 2; }, //5XY0 //if(Vx==Vy) 将程序计数器递增2 跳过 0x5000() { if (self.v[x] == self.v[y]) self.pc += 2; }, //6XNN //设置 Vx=NN 0x6000() { self.v[x] = NN; }, //7XNN //设置 Vx+=NN 0x7000() { self.v[x] += NN; }, //8XY0 0x8000() { ({ //8XY0 //Vx=Vy 0x0000() { self.v[x] = self.v[y]; }, //8XY1 //设置 Vx=Vx|Vy 0x0001() { self.v[x] = self.v[x] | self.v[y]; }, //8XY2 //Vx=Vx&Vy 0x0002() { self.v[x] = self.v[x] & self.v[y]; }, //8XY3 //Vx=Vx^Vy 0x0003() { self.v[x] = self.v[x] ^ self.v[y]; }, //8XY4 //Vx += Vy 0x0004() { var sum = self.v[x] + self.v[y]; if (sum > 0xFF) {//即VY+VX > 255 self.v[0xF] = 1;//出现了溢出,则把VF置为1 } else { self.v[0xF] = 0;//没有溢出VF置为0 } self.v[x] = sum; }, //8XY5 //Vx -= Vy 0x0005() { if (self.v[x] > self.v[y]) { self.v[0xF] = 1; } else { self.v[0xF] = 0; } self.v[x] = self.v[x] - self.v[y]; }, //8XY6 //Vx=Vy=Vy>>1 0x0006() { self.v[0xF] = self.v[x] & 0x01; self.v[x] = self.v[x] >> 1; }, //8XY7 //Vx=Vy-Vx 0x0007() { if (self.v[x] > self.v[y]) { this.v[0xF] = 0; } else { self.v[0xF] = 1; } self.v[x] = self.v[y] - self.v[x]; }, //8XYE //Vx=Vy=Vy<<1 0x000E() { self.v[0xF] = self.v[x] & 0x80; self.v[x] = self.v[x] << 1; } })[opcode & 0x000F](); }, //if(Vx!=Vy) 将程序计数器递增2 跳过 0x9000() { if (self.v[x] != self.v[y]) self.pc += 2; }, //ANNN //设置 I = NNN 0xA000() { self.i = NNN; }, //BNNN //跳转到的位置NNN + V0 0xB000() { self.pc = NNN + self.v[0]; }, //CXNN //Vx=(随机0至255)&NN 0xC000() { self.v[x] = Math.floor(Math.random() * 0xFF) & NN; }, //DXYN //绘画指令 0xD000() { var row, col, sprite , width = 8 , height = opcode & 0x000F;//取得N(图案的高度) self.v[0xF] = 0;//初始化VF为0 for (row = 0; row < height; row++) {//对于每一行 sprite = self.memory[self.i + row];//取得内存I处的值,pixel中包含了一行的8个像素 for (col = 0; col < width; col++) {//对于一行的8个像素 if ((sprite & 0x80) > 0) {//依次检查新值中每一位是否为1 if (self.screen.setPixel(self.v[x] + col, self.v[y] + row)) {//如果显示缓存gfx[]里该像素也为1,则发生了碰撞 self.v[0xF] = 1;//设置VF为1 } } sprite = sprite << 1; } } }, 0xE000() { ({ //EX9E //if(key()==Vx) 将程序计数器递增2 跳过 0x009E() { if (self.input.isKeyPressed(self.v[x])) self.pc += 2; }, //EXA1 //if(key()!=Vx) 将程序计数器递增2 跳过 0x00A1() { if (!self.input.isKeyPressed(self.v[x])) self.pc += 2; } })[NN](); }, 0xF000() { ({ //FX07 //Vx = delayTimer 0x0007() { self.v[x] = self.delayTimer; }, //FX0A //Vx =input_key 0x000A() { self.paused = true; self.input.onNextKeyPress = function (key) { self.v[x] = key; self.paused = false; }.bind(self); }, //FX15 //delayTimer=Vx 0x0015() { self.delayTimer = self.v[x]; }, //FX18 //soundTimer=Vx 0x0018() { self.soundTimer = self.v[x]; }, //FX1E //I +=Vx 0x001E() { self.i += self.v[x]; }, //FX29 //I=sprite_addr[Vx],一般用4x5字体表示 0x0029() { self.i = self.v[x] * 5; }, //FX33 //reg_dump(Vx,&I) 0x0033() { self.memory[self.i] = parseInt(self.v[x] / 100);//取得十进制百位 self.memory[self.i + 1] = parseInt(self.v[x] % 100 / 10);//取得十进制十位 self.memory[self.i + 2] = self.v[x] % 10;//取得十进制个位 }, //FX55 //reg_load(Vx,&I) 0x0055() { for (var i = 0; i <= x; i++) { self.memory[self.i + i] = self.v[i]; } }, //FX65 //I +=Vx 0x0065() { for (var i = 0; i <= x; i++) { self.v[i] = self.memory[self.i + i]; } } })[NN](); } })[opcode & 0xF000]();};
代码:
由于使用不断生成HTML代码作为显示会出现卡顿的现象,我们尝试着使用Canvas作为显示器
转载地址:http://jlazx.baihongyu.com/