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($(`\

\ Could not load GT1 from ${file.name}\

\
\

\ ${error.message}\

`)), }); } 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($(`\

\ Could not load ROM from ${url}\

\
\

\ ${req.statusText}\

`)); } 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); });