351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
import {
|
|
Vga,
|
|
} from './vga.js';
|
|
import {
|
|
BlinkenLights,
|
|
} from './blinkenlights.js';
|
|
import {
|
|
Gigatron,
|
|
} from './gigatron.js';
|
|
import {
|
|
Gamepad,
|
|
} from './gamepad.js';
|
|
import {
|
|
Audio,
|
|
} from './audio.js';
|
|
import {
|
|
Loader,
|
|
} from './loader.js';
|
|
import {
|
|
Spi,
|
|
} from './spi.js';
|
|
|
|
const {
|
|
finalize,
|
|
} = rxjs.operators;
|
|
|
|
const HZ = 6250000;
|
|
const romUrl = 'gigatron.rom';
|
|
|
|
$(function() {
|
|
$('[data-toggle="tooltip"]').tooltip();
|
|
|
|
let muteButton = $('#mute');
|
|
let unmuteButton = $('#unmute');
|
|
let volumeSlider = $('#volume-slider');
|
|
let vgaCanvas = $('#vga-canvas');
|
|
let loadFileInput = $('#load-file-input');
|
|
let loadVhdInput = $('#load-vhd-input');
|
|
|
|
/** Trigger a keydown/keyup event in response to a mousedown/mouseup event
|
|
* @param {JQuery} $button
|
|
* @param {string} key
|
|
*/
|
|
function bindKeyToButton($button, key) {
|
|
$button
|
|
.on('mousedown', (event) => {
|
|
event.preventDefault();
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
|
'key': key,
|
|
}));
|
|
$button.addClass('pressed');
|
|
})
|
|
.on('mouseenter', (event) => {
|
|
event.preventDefault();
|
|
if (event.originalEvent.buttons & 1) {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
|
'key': key,
|
|
}));
|
|
$button.addClass('pressed');
|
|
}
|
|
})
|
|
.on('mouseup mouseleave', (event) => {
|
|
event.preventDefault();
|
|
document.dispatchEvent(new KeyboardEvent('keyup', {
|
|
'key': key,
|
|
}));
|
|
$button.removeClass('pressed');
|
|
});
|
|
}
|
|
|
|
bindKeyToButton($('.gamepad-btn-a'), 'Delete');
|
|
bindKeyToButton($('.gamepad-btn-b'), 'Insert');
|
|
bindKeyToButton($('.gamepad-btn-start'), 'PageUp');
|
|
bindKeyToButton($('.gamepad-btn-select'), 'PageDown');
|
|
bindKeyToButton($('.gamepad-btn-up'), 'ArrowUp');
|
|
bindKeyToButton($('.gamepad-btn-down'), 'ArrowDown');
|
|
bindKeyToButton($('.gamepad-btn-left'), 'ArrowLeft');
|
|
bindKeyToButton($('.gamepad-btn-right'), 'ArrowRight');
|
|
|
|
// jQuery targets of current touches indexed by touch identifier
|
|
let $touchTargets = {};
|
|
|
|
// track touches within the fc30 and map them to mouse events
|
|
$('.gamepad')
|
|
.on('touchstart', (event) => {
|
|
event.preventDefault();
|
|
for (let touch of event.changedTouches) {
|
|
let $currTarget = $(document.elementFromPoint(
|
|
touch.clientX, touch.clientY))
|
|
.filter('.gamepad-btn');
|
|
$touchTargets[touch.identifier] = $currTarget;
|
|
$currTarget.trigger('mousedown');
|
|
if ($currTarget.length > 0 && navigator.vibrate) {
|
|
navigator.vibrate(20);
|
|
}
|
|
}
|
|
})
|
|
.on('touchmove', (event) => {
|
|
event.preventDefault();
|
|
for (let touch of event.changedTouches) {
|
|
let $prevTarget = $touchTargets[touch.identifier];
|
|
let $currTarget = $(document.elementFromPoint(
|
|
touch.clientX, touch.clientY))
|
|
.filter('.gamepad-btn');
|
|
if ($prevTarget.get(0) != $currTarget.get(0)) {
|
|
$prevTarget.trigger('mouseup');
|
|
$touchTargets[touch.identifier] = $currTarget;
|
|
$currTarget.trigger('mousedown');
|
|
if ($currTarget.length > 0 && navigator.vibrate) {
|
|
navigator.vibrate(20);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.on('touchend touchcancel', (event) => {
|
|
event.preventDefault();
|
|
for (let touch of event.changedTouches) {
|
|
let $prevTarget = $touchTargets[touch.identifier];
|
|
$prevTarget.trigger('mouseup');
|
|
delete $touchTargets[touch.identifier];
|
|
}
|
|
});
|
|
|
|
/** display the error modal with the given message
|
|
* @param {JQuery} body
|
|
*/
|
|
function showError(body) {
|
|
$('#error-modal-body')
|
|
.empty()
|
|
.append(body);
|
|
$('#error-modal').modal();
|
|
}
|
|
|
|
let cpu = new Gigatron({
|
|
hz: HZ,
|
|
romAddressWidth: 16,
|
|
ramAddressWidth: 17,
|
|
});
|
|
|
|
let vga = new Vga(vgaCanvas.get(0), cpu, {
|
|
horizontal: {
|
|
frontPorch: 16,
|
|
pulse: 96,
|
|
backPorch: 48,
|
|
visible: 640,
|
|
},
|
|
vertical: {
|
|
frontPorch: 6,
|
|
pulse: 8,
|
|
backPorch: 27,
|
|
visible: 480,
|
|
},
|
|
});
|
|
|
|
let blinkenLights = new BlinkenLights(cpu);
|
|
|
|
let audio = new Audio(cpu);
|
|
|
|
let gamepad = new Gamepad(cpu, {
|
|
up: ['ArrowUp'],
|
|
down: ['ArrowDown'],
|
|
left: ['ArrowLeft'],
|
|
right: ['ArrowRight'],
|
|
select: ['PageDown'],
|
|
start: ['PageUp'],
|
|
a: ['Delete', 'Backspace', 'End'],
|
|
b: ['Insert', 'Home'],
|
|
});
|
|
|
|
let loader = new Loader(cpu);
|
|
|
|
let spi = new Spi(cpu, 0);
|
|
spi.loadvhdurl('./sd.vhd');
|
|
|
|
muteButton.click(function() {
|
|
audio.mute = true;
|
|
$([muteButton, unmuteButton]).toggleClass('d-none');
|
|
});
|
|
|
|
unmuteButton.click(function() {
|
|
audio.mute = false;
|
|
$([muteButton, unmuteButton]).toggleClass('d-none');
|
|
});
|
|
|
|
volumeSlider.val(100 * audio.volume);
|
|
volumeSlider.on('input', function(event) {
|
|
let target = event.target;
|
|
target.labels[0].textContent = target.value + '%';
|
|
audio.volume = target.value / 100;
|
|
});
|
|
volumeSlider.trigger('input');
|
|
|
|
/** load a GT1 file
|
|
* @param {File} file
|
|
*/
|
|
function loadGt1(file) {
|
|
gamepad.stop();
|
|
spi.stop();
|
|
loader.load(file)
|
|
.pipe(finalize(() => {
|
|
gamepad.start();
|
|
spi.start();
|
|
}))
|
|
.subscribe({
|
|
error: (error) => showError($(`\
|
|
<p>\
|
|
Could not load GT1 from <code>${file.name}</code>\
|
|
</p>\
|
|
<hr>\
|
|
<p class="alert alert-danger">\
|
|
<span class="oi oi-warning"></span> ${error.message}\
|
|
</p>`)),
|
|
});
|
|
}
|
|
|
|
loadFileInput
|
|
.on('click', (event) => {
|
|
loadFileInput.closest('form').get(0).reset();
|
|
})
|
|
.on('change', (event) => {
|
|
let target = event.target;
|
|
if (target.files.length != 0) {
|
|
let file = target.files[0];
|
|
loadGt1(file);
|
|
}
|
|
});
|
|
|
|
loadVhdInput
|
|
.on('click', (event) => {
|
|
loadVhdInput.closest('form').get(0).reset();
|
|
})
|
|
.on('change', (event) => {
|
|
let target = event.target;
|
|
if (target.files.length != 0) {
|
|
let file = target.files[0];
|
|
spi.loadvhdfile(file);
|
|
}
|
|
});
|
|
|
|
|
|
$(document)
|
|
.on('dragenter', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
let dataTransfer = event.originalEvent.dataTransfer;
|
|
dataTransfer.dropEffect = 'link';
|
|
})
|
|
.on('dragover', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
})
|
|
.on('drop', (event) => {
|
|
let dataTransfer = event.originalEvent.dataTransfer;
|
|
if (dataTransfer) {
|
|
let files = dataTransfer.files;
|
|
if (files.length != 0) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
let fn = files[0].name.toLowerCase()
|
|
if (fn.endsWith('.gt1')) {
|
|
loadGt1(files[0])
|
|
} else if (fn.endsWith('.vhd') || fn.endsWith('.img')) {
|
|
spi.loadvhdfile(files[0])
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let timer = 0;
|
|
let lastdate = 0;
|
|
|
|
/** start the simulation loop */
|
|
function startRunLoop() {
|
|
gamepad.start();
|
|
lastdate = Date.now()
|
|
timer = setInterval(() => {
|
|
/* Self correcting simulation speed */
|
|
let newdate = Date.now()
|
|
let cycles = cpu.hz * Math.min(newdate-lastdate, 30)/1000
|
|
lastdate = newdate
|
|
audio.drain();
|
|
while (cycles-- >= 0 && !audio.full) {
|
|
cpu.tick();
|
|
vga.tick();
|
|
audio.tick();
|
|
spi.tick();
|
|
loader.tick();
|
|
}
|
|
blinkenLights.tick(); // don't need realtime update
|
|
gamepad.tick();
|
|
}, audio.duration * 500);
|
|
|
|
audio.context.resume();
|
|
|
|
// Chrome suspends the AudioContext on reload
|
|
// and doesn't allow it to be resumed unless there
|
|
// is user interaction
|
|
if (audio.context.state === 'suspended') {
|
|
vga.ctx.fillStyle = 'white';
|
|
vga.ctx.textAlign = 'center';
|
|
vga.ctx.textBaseline = 'middle';
|
|
vga.ctx.font = '4em sans-serif';
|
|
vga.ctx.fillText('Click to start', 320, 240);
|
|
vgaCanvas.on('click', (event) => {
|
|
audio.context.resume();
|
|
vgaCanvas.off('click');
|
|
});
|
|
}
|
|
}
|
|
|
|
/** stop the simulation loop */
|
|
function stopRunLoop() { // eslint-disable-line
|
|
clearTimeout(timer);
|
|
gamepad.stop();
|
|
}
|
|
|
|
/** load the ROM image
|
|
* @param {string} url
|
|
*/
|
|
function loadRom(url) {
|
|
var req = new XMLHttpRequest();
|
|
req.open('GET', url);
|
|
req.responseType = 'arraybuffer';
|
|
/* req.setRequestHeader('accept-encoding','gzip'); */
|
|
req.onload = (event) => {
|
|
if (req.status != 200) {
|
|
showError($(`\
|
|
<p>\
|
|
Could not load ROM from <code>${url}</code>\
|
|
</p>\
|
|
<hr>\
|
|
<p class="alert alert-danger">\
|
|
<span class="oi oi-warning"></span> ${req.statusText}\
|
|
</p>`));
|
|
} else {
|
|
let dataView = new DataView(req.response);
|
|
let wordCount = dataView.byteLength >> 1;
|
|
// convert to host endianess
|
|
for (let wordIndex = 0; wordIndex < wordCount; wordIndex++) {
|
|
cpu.rom[wordIndex] = dataView.getUint16(2 * wordIndex);
|
|
}
|
|
startRunLoop();
|
|
}
|
|
};
|
|
|
|
req.send(null);
|
|
}
|
|
|
|
loadRom(romUrl);
|
|
});
|