我相信大多数人都用模拟器玩过游戏吧!比如GBA模拟器,PSP模拟器,NES模拟器等。所以应该也有人会跟我一样想自己写个游戏机模拟器。但这些模拟器对于一个新手来说难度太大了,就比如NES模拟器中CPU的指令就有100个以上了,更别说除了CPU还有显卡之类的东西需要模拟。所以有没有一个比较简单适新手的模拟器项目呢?后来我就找到了 Chip-8。
文章完整代码放在:
https://gitee.com/baojuhua/javascript_writing_Chip8/tree/master
关于Chip-8参考资料(可能有墙):
https://en.wikipedia.org/wiki/CHIP8
http://devernay.free.fr/hacks/chip8/C8TECH10.HTM
http://mattmik.com/files/chip8/mastering/chip8.html
0x00 CHIP8简介
我们根据CHIP8的Wiki可以了解到CHIP8是一种解释性的编程语言。最初被应用是在1970年代中期。CHIP8的程序运行在CHIP8虚拟机中,它的出现让电子游戏编程变得简单些了(相对于那个年代来说)。用CHIP8实现的电子游戏有,比如小蜜蜂,俄罗斯方块,吃豆人等。更多可以前往CHIP8的Wiki了解。
0x01 创建CHIP8对象
我们假设CHIP8是由处理器、键盘、显示屏与扬声器组成,其中CPU是CHIP8核心,那么代码应该像这样的:
<!DOCTYPE html>
<html>
<head>
<title>创建Chip8对象</title>
</head>
<body>
<script>
(function () {
function CPU() {/*...*/ };
function Screen() {/*...*/ };
function Keyboard() {/*...*/ };
function Speaker(){/*...*/ };
window.CHIP8 = function () {
var c8 = new CPU();
c8.screen = new Screen();
c8.speaker = new Speaker();
c8.input = new Keyboard();
return c8;
};
})();
</script>
</body>
</html>
0x02 编写简单的显示屏
根据CHIP8的Wiki可以了解到,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坐标像素
0x03 编写扬声器
这里需要参考 Web APIs:
扬声器也十分简单:
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;
}
}
};
编写好扬声器我们可以对扬声器进行测试(在线查看扬声器测试):
<!DOCTYPE html>
<html>
<head>
<title>编写扬声器</title>
</head>
<body>
频率:
<input type="range" id="frequency" value="440" min="100" max="1000">
<label id="showfv">(440)</label>
<button id="play_btn">播放</button>
<script>
(function () {
function CPU() {/*...*/ };
function Screen() {/*...*/ };//略...
function Keyboard() {/*...*/ };
function Speaker() {/*...*/};//略...
window.CHIP8 = function () {
var c8 = new CPU();
c8.screen = new Screen();
c8.speaker = new Speaker();
c8.input = new Keyboard();
return c8;
};
})();
var chip8 = CHIP8();
//=======
var f = document.getElementById("frequency");
var isPlay = false;
var play_btn = document.getElementById("play_btn");
f.onchange = function () {
var v = Number(this.value);
document.getElementById("showfv").innerHTML = "(" + v + ")";
if (isPlay) {
chip8.speaker.stop();
chip8.speaker.play(v);
}
};
play_btn.onclick = function () {
isPlay = !isPlay;
this.innerHTML = isPlay ? '停止' : '播放';
if (!isPlay) chip8.speaker.stop();
else chip8.speaker.play(f.value);
};
</script>
</body>
</html>
0x04 编写键盘输入设备
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"
}
编写好键盘输入,同样我们可以对演示器进行测试(在线查看键盘输入测试)
0x05 编写CHIP8核心部分
如果你已经成功编写完成了键盘、扬声器与显示器
那么接下去的就是重点了
同样是在 CHIP8的Wiki 上可以了解到:
1.内存
CHIP8基本是在一个有4K内存的系统上实现,也就是4096个字节。前512(也就是从0x000到0x1ff)字节由CHIP8的解释器占据。所以CHIP8的程序都是从0x200地址开始的。最顶上的256个字(0xF00-0xFFF) 用于显示刷新,在这下面的96个字节 (0xEA0-0xEFF) 用于栈, 内部使用或者其他变量。其中前512字节 (0x000-0x200) 存放字体数据
2.寄存器(先要了解寄存器是啥)
CHIP8有16个通用8位数据寄存器,V0~VF。VF寄存器存放进位标识。还有一个地址寄存器叫做I,2个字节的长度。
程序计数器(PC)应该是16位的,是用来存放当前正在执行的地址,堆栈是16个16位值的数组,用于存放函数返回的地址值和保存一些数据。
3.扬声器与定时器
CHIP8提供了2个定时器,延时定时器和一个声音定时器
延时定时器活跃时,延时定时器寄存器(Delay Timer 简称 DT)是非零的。这个定时器只是减去1,DT频率的在60Hz。当DT达0时无效。
声音定时器活跃时,声音定时器寄存器(Sound Timer 简称 ST)是非零的。这个定时器也递减率在60Hz,然而,只要ST的价值大于零,该CHIP8蜂鸣器发声。当ST达到零,定时器关闭声音。
由CHIP8翻译产生的声音只有一种声音。声音的音调或频率是由解释器开发者决定的。
4.其他详细信息查看 http://devernay.free.fr/hacks/chip8/C8TECH10.HTM
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) {/****/ }
};
0x06 CHIP8操作指令集
在编写指令集之前你要对JavaScript中的位运算有所了解
你可以查看我编写的 二进制运算 简单了解下
在 CHIP8的Wiki 上有对操作码的说明:
根据说明我们编写指令集就简单很多,我的做法是先获取到 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]();
};
0x07 整合代码初步实现Chip-8
代码:
<!DOCTYPE html>
<html>
<head>
<script>
(function () {
//略.....
})();
</script>
</head>
<body>
<input type="file" id="f" />
<div id="boxs" style="overflow: auto;width:320px;border: 1px solid #666;"></div>
<script>
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);
}
};
f.onchange = function () {
var fl = this.files[0]
var reader = new FileReader();
reader.onload = function (e) {
chip8.reset();//重置
chip8.rom = new Uint8Array(e.target.result);//读取到rom
chip8.loadProgram(chip8.rom);//加载程序
//启动
if (chip8._loop) {
clearInterval(chip8._loop);
chip8._loop = null;
}
chip8._loop = setInterval(() => {
chip8.cycle();
}, 1000 / 60);//模拟频率 60
}
reader.onerror = function (error) {
}
reader.readAsArrayBuffer(fl);
};
</script>
</body>
</html>
0x08 显示优化
由于使用不断生成HTML代码作为显示会出现卡顿的现象,我们尝试着使用Canvas作为显示器
<!DOCTYPE html>
<html>
<head>
<script>
(function () {
//略.....
})();
</script>
</head>
<body>
<input type="file" id="f" />
<br/>
<canvas id="boxv"></canvas>
<script>
var chip8 = CHIP8();
// 使用Canvas作为显示
var boxv = document.getElementById("boxv");
var scale = 5;
boxv.width = chip8.screen.columns * scale;
boxv.height = chip8.screen.rows * scale;
var ctx = boxv.getContext("2d");
chip8.screen.render = function () {//自定义实现显示渲染
var i, x, y;
ctx.clearRect(0, 0, boxv.width, boxv.height);
for (i = 0; i < chip8.screen.resolution; i++) {
x = (i % chip8.screen.columns) * scale;
y = Math.floor(i / chip8.screen.columns) * scale;
if (chip8.screen.bitMap[i]) {
ctx.fillStyle = "#000";
ctx.fillRect(x, y, scale, scale);
}
}
};
f.onchange = function () {
var fl = this.files[0];
if(!fl)return;
var reader = new FileReader();
reader.onload = function (e) {
chip8.reset();//重置
chip8.rom = new Uint8Array(e.target.result);//读取到rom
chip8.loadProgram(chip8.rom);//加载程序
//启动
if (chip8._loop) {
clearInterval(chip8._loop);
chip8._loop = null;
}
chip8._loop = setInterval(() => {
chip8.cycle();
}, 1000 / 60);//模拟频率 60
}
reader.onerror = function (error) {
}
reader.readAsArrayBuffer(fl);
};
</script>
</body>
</html>