diff --git a/src/common/baseplatform.ts b/src/common/baseplatform.ts index 0fb8eb50..96fa32fc 100644 --- a/src/common/baseplatform.ts +++ b/src/common/baseplatform.ts @@ -941,11 +941,13 @@ export abstract class BaseMachinePlatform extends BaseDebugPl resume() { this.timer.start(); this.audio && this.audio.start(); + this.poller && this.poller.start(); } pause() { this.timer.stop(); this.audio && this.audio.stop(); + this.poller && this.poller.stop(); } // so probe views stick around TODO: must be a better way? diff --git a/src/common/emu.ts b/src/common/emu.ts index 21b593bd..a4e58de4 100644 --- a/src/common/emu.ts +++ b/src/common/emu.ts @@ -565,9 +565,13 @@ export function makeKeycodeMap(table: [KeyDef, number, number][]): KeyCodeMap { return map; } +// TODO: P2_GP_B and P2_GP_C share keycodes with P2_B and P2_A, so they can't +// be listed here without button 1/2 double-binding on player 2. const DEFAULT_CONTROLLER_KEYS: KeyDef[] = [ Keys.UP, Keys.DOWN, Keys.LEFT, Keys.RIGHT, Keys.A, Keys.B, Keys.SELECT, Keys.START, + Keys.GP_A, Keys.GP_B, Keys.GP_C, Keys.GP_D, Keys.OPTION, Keys.P2_UP, Keys.P2_DOWN, Keys.P2_LEFT, Keys.P2_RIGHT, Keys.P2_A, Keys.P2_B, Keys.P2_SELECT, Keys.P2_START, + Keys.P2_GP_A, Keys.P2_GP_D, ]; // Gamepad helper @@ -589,21 +593,26 @@ export class ControllerPoller { }); } reset() { - this.active = typeof navigator.getGamepads === 'function'; - if (this.active) { - let numGamepads = navigator.getGamepads().length; + if (typeof navigator.getGamepads === 'function') { + let numGamepads = navigator.getGamepads()?.length; this.state = new Array(numGamepads); this.lastState = new Array(numGamepads); for (var i = 0; i < numGamepads; i++) { this.state[i] = new Int32Array(64); this.lastState[i] = new Int32Array(64); } - console.log(this); + this.active = this.state?.length > 0; } } + start() { + this.reset(); + } + stop() { + this.active = false; + } poll() { if (!this.active) return; - var gamepads = navigator.getGamepads(); + var gamepads = navigator.getGamepads && navigator.getGamepads(); for (var gpi = 0; gpi < gamepads.length; gpi++) { let state = this.state[gpi]; let lastState = this.lastState[gpi]; @@ -628,28 +637,34 @@ export class ControllerPoller { } handleStateChange(gpi: number, k: number) { var axis = k - this.AXIS0; + var state = this.state[gpi][k]; + var lastState = this.lastState[gpi][k]; // TODO: this is slow for (var def of DEFAULT_CONTROLLER_KEYS) { // is this a gamepad entry? same player #? - if (def && def.plyr == gpi) { - var code = def.c; - var state = this.state[gpi][k]; - var lastState = this.lastState[gpi][k]; - // check for button/axis match - if (k == def.button || (axis == 0 && def.xaxis == state) || (axis == 1 && def.yaxis == state)) { - //console.log("Gamepad", gpi, code, state); - if (state != 0) { - this.handler(code, 0, KeyFlags.KeyDown); - } else { - this.handler(code, 0, KeyFlags.KeyUp); - } - break; - } - // joystick released? - else if (state == 0 && (axis == 0 && def.xaxis == lastState) || (axis == 1 && def.yaxis == lastState)) { - this.handler(code, 0, KeyFlags.KeyUp); - break; - } + if (!def || def.plyr != gpi) continue; + // does this key map to the button/axis that changed? + // an axis has to be compared against the direction the key stands for, + // so that e.g. a left-to-right flick releases LEFT and presses RIGHT + var active: boolean, wasActive: boolean; + if (def.button != null && k == def.button) { + active = state != 0; + wasActive = lastState != 0; + } else if (axis == 0 && def.xaxis != null) { + active = def.xaxis == state; + wasActive = def.xaxis == lastState; + } else if (axis == 1 && def.yaxis != null) { + active = def.yaxis == state; + wasActive = def.yaxis == lastState; + } else { + continue; + } + // don't break -- several keys can share a button (e.g. A and GP_A) + //console.log("Gamepad", gpi, def.c, state); + if (active && !wasActive) { + this.handler(def.c, 0, KeyFlags.KeyDown); + } else if (!active && wasActive) { + this.handler(def.c, 0, KeyFlags.KeyUp); } } } diff --git a/src/platform/vector.ts b/src/platform/vector.ts index 3577b453..9ec6896f 100644 --- a/src/platform/vector.ts +++ b/src/platform/vector.ts @@ -47,7 +47,7 @@ var AtariVectorPlatform = function(mainElement) { var cpuCyclesPerNMI = Math.round(cpuFrequency*12/(XTAL/4096)); // ~250 Hz var cpuCyclesPerFrame = Math.round(cpuFrequency/60); var cpu, cpuram, dvgram, rom, vecrom, bus, dvg; - var video, audio, timer; + var video, audio, timer, poller; var clock; var watchdog = 0; var switches = new RAM(16).mem; @@ -98,9 +98,11 @@ var AtariVectorPlatform = function(mainElement) { audio = newPOKEYAudio(2); video.create(); timer = new AnimationTimer(60, this.nextFrame.bind(this)); - setKeyboardFromMap(video, switches, ASTEROIDS_KEYCODE_MAP); + poller = setKeyboardFromMap(video, switches, ASTEROIDS_KEYCODE_MAP); } + this.pollControls = function() { poller && poller.poll(); } + this.advance = (novideo) => { if (!novideo) video.clear(); var debugCond = this.getDebugCallback(); @@ -189,7 +191,7 @@ var AtariColorVectorPlatform = function(mainElement) { var cpuCyclesPerNMI = Math.round(cpuFrequency / nmiFrequency); var cpuCyclesPerFrame = Math.round(cpuFrequency / 60); var cpu, cpuram, dvgram, rom, vecrom, bus, dvg, earom; - var video, audio, timer; + var video, audio, timer, poller; var clock; var switches = new RAM(16).mem; var nmicount = cpuCyclesPerNMI; @@ -259,9 +261,11 @@ var AtariColorVectorPlatform = function(mainElement) { audio = newPOKEYAudio(2); video.create(); timer = new AnimationTimer(60, this.nextFrame.bind(this)); - setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP); + poller = setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP); } + this.pollControls = function() { poller && poller.poll(); } + this.advance = (novideo) => { if (!novideo) video.clear(); var debugCond = this.getDebugCallback(); @@ -343,7 +347,7 @@ var Z80ColorVectorPlatform = function(mainElement, proto) { var cpuFrequency = 4000000.0; var cpuCyclesPerFrame = Math.round(cpuFrequency/60); var cpu, cpuram, dvgram, rom, bus, dvg; - var video, audio, timer; + var video, audio, timer, poller; var clock; var switches = new RAM(16).mem; var mathram = new RAM(16).mem; @@ -404,9 +408,11 @@ var Z80ColorVectorPlatform = function(mainElement, proto) { audio = newPOKEYAudio(2); video.create(); timer = new AnimationTimer(60, this.nextFrame.bind(this)); - setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP); + poller = setKeyboardFromMap(video, switches, GRAVITAR_KEYCODE_MAP); } + this.pollControls = function() { poller && poller.poll(); } + this.advance = (novideo) => { if (!novideo) video.clear(); this.runCPU(cpu, cpuCyclesPerFrame); diff --git a/src/platform/vectrex.ts b/src/platform/vectrex.ts index feea9856..663db426 100644 --- a/src/platform/vectrex.ts +++ b/src/platform/vectrex.ts @@ -1,6 +1,6 @@ import { Platform, BaseZ80Platform, Base6502Platform, Base6809Platform } from "../common/baseplatform"; -import { PLATFORMS, newAddressDecoder, padBytes, noise, setKeyboardFromMap, AnimationTimer, VectorVideo, Keys, makeKeycodeMap } from "../common/emu"; +import { PLATFORMS, newAddressDecoder, padBytes, noise, setKeyboardFromMap, AnimationTimer, VectorVideo, Keys, makeKeycodeMap, ControllerPoller } from "../common/emu"; import { hex, lzgmini, stringToByteArray, safe_extend } from "../common/util"; import { MasterAudio, AY38910_Audio } from "../common/audio"; import { ProbeRecorder } from "../common/probe"; @@ -785,6 +785,7 @@ class VectrexPlatform extends Base6809Platform { psg: AY38910_Audio; audio; timer: AnimationTimer; + poller: ControllerPoller; constructor(mainElement) { super(); @@ -831,9 +832,11 @@ class VectrexPlatform extends Base6809Platform { this.psg = new AY38910_Audio(this.audio); this.video.create(); this.timer = new AnimationTimer(60, this.nextFrame.bind(this)); - setKeyboardFromMap(this.video, this.inputs, VECTREX_KEYCODE_MAP); // true = always send function); + this.poller = setKeyboardFromMap(this.video, this.inputs, VECTREX_KEYCODE_MAP); // true = always send function); } + pollControls() { this.poller && this.poller.poll(); } + // TODO: loadControlsState updateControls() { // joystick (analog simulation) diff --git a/src/platform/williams.ts b/src/platform/williams.ts index 1800ebd3..2ab27511 100644 --- a/src/platform/williams.ts +++ b/src/platform/williams.ts @@ -31,7 +31,7 @@ var WilliamsPlatform = function(mainElement, proto, options) { var pia6821 = new RAM(8).mem; var blitregs = new RAM(8).mem; - var video, timer, pixels, displayPCs; + var video, timer, pixels, displayPCs, poller; var screenNeedsRefresh = false; var membus; var video_counter; @@ -317,11 +317,13 @@ var WilliamsPlatform = function(mainElement, proto, options) { if (displayPCs) console.log(x, y, hex(addr, 4), "PC", hex(displayPCs[addr], 4)); }); var idata = video.getFrameData(); - setKeyboardFromMap(video, pia6821, KEYCODE_MAP); + poller = setKeyboardFromMap(video, pia6821, KEYCODE_MAP); pixels = video.getFrameData(); timer = new AnimationTimer(60, this.nextFrame.bind(this)); } + this.pollControls = function() { poller && poller.poll(); } + this.getRasterScanline = function() { return video_counter; } this.advance = function(novideo: boolean) { diff --git a/test/cli/testplatforms.js b/test/cli/testplatforms.js index aa783f5f..d641d04b 100644 --- a/test/cli/testplatforms.js +++ b/test/cli/testplatforms.js @@ -404,3 +404,90 @@ describe('Platform Replay', () => { }); }); }); + +describe('Platform Input', () => { + + // node's global navigator is a getter, so it has to be replaced this way + const realNavigator = Object.getOwnPropertyDescriptor(global, 'navigator'); + let pads; + + function fakePads() { + pads = [0, 1].map(() => ({ + axes: [0, 0], + buttons: new Array(16).fill(null).map(() => ({ pressed: false })), + })); + Object.defineProperty(global, 'navigator', { + value: { getGamepads: () => pads }, configurable: true, writable: true + }); + } + + after(() => { + if (realNavigator === undefined) delete global.navigator; + else Object.defineProperty(global, 'navigator', realNavigator); + }); + + // starts a platform with a gamepad plugged in, then runs `press` (which can + // poke the pads or call keycallback) and reports the input port before/after + async function withGamepad(platid, romname, press, addr) { + fakePads(); + var platform = new emu.PLATFORMS[platid](document.getElementById('emulator')); + await platform.start(); + platform.loadROM("ROM", new Uint8Array(fs.readFileSync('./test/roms/' + platid + '/' + romname))); + platform.resume(); + // the poller only wakes up once a pad announces itself + window.dispatchEvent(new window.Event('gamepadconnected')); + for (var i = 0; i < 4; i++) platform.nextFrame(); + var before = platform.readAddress(addr); + press(pads); + platform.nextFrame(); + var after = platform.readAddress(addr); + platform.pause(); + return { before, after }; + } + + // Robotron-style twin stick: player 1's stick is bound to the P2_* keys, + // so it comes from gamepad #1, and firing comes from gamepad #0 + it('Should read a gamepad on williams-z80', async () => { + var r = await withGamepad('williams-z80', 'game1.c.rom', + (pads) => { pads[1].axes[0] = -1; }, 0xc804); // input0, LEFT1 = 0x4 + assert.equal(r.before & 0x4, 0); + assert.equal(r.after & 0x4, 0x4, "gamepad left should set LEFT1"); + }); + + it('Should read gamepad buttons on williams-z80', async () => { + var r = await withGamepad('williams-z80', 'game1.c.rom', + (pads) => { pads[0].buttons[8].pressed = true; }, 0xc80c); // input2, COIN1 = 0x10 + assert.equal(r.before & 0x10, 0); + assert.equal(r.after & 0x10, 0x10, "gamepad button 8 should set COIN1"); + }); + + // GRAVITAR_KEYCODE_MAP is active low, so a press clears the bit + it('Should read a gamepad on vector-z80color', async () => { + var r = await withGamepad('vector-z80color', 'game.c.rom', + (pads) => { pads[0].axes[0] = -1; }, 0x8001); // input1, LEFT1 = 0x8 + assert.equal(r.before & 0x8, 0x8); + assert.equal(r.after & 0x8, 0, "gamepad left should clear LEFT1"); + }); + + it('Should read gamepad axis 1 on vector-z80color', async () => { + var r = await withGamepad('vector-z80color', 'game.c.rom', + (pads) => { pads[0].axes[1] = -1; }, 0x8001); // input1, UP1 = 0x10 + assert.equal(r.before & 0x10, 0x10); + assert.equal(r.after & 0x10, 0, "gamepad up should clear UP1"); + }); + + // the coin slots are on the number keys, which no gamepad button reaches + it('Should read the coin keys on vector-z80color', async () => { + var r = await withGamepad('vector-z80color', 'game.c.rom', + () => { keycallback(Keys.VK_5.c, Keys.VK_5.c, 1); }, 0x8000); // input0, COIN1 = 0x2 + assert.equal(r.before & 0x2, 0); + assert.equal(r.after & 0x2, 0x2, "'5' should set COIN1"); + }); + + it('Should read the coin keys on williams-z80', async () => { + var r = await withGamepad('williams-z80', 'game1.c.rom', + () => { keycallback(Keys.SELECT.c, Keys.SELECT.c, 1); }, 0xc80c); // input2, COIN1 = 0x10 + assert.equal(r.before & 0x10, 0); + assert.equal(r.after & 0x10, 0x10, "'\\' should set COIN1"); + }); +}); diff --git a/test/unit/testemu.ts b/test/unit/testemu.ts index a9a273c6..11202cb5 100644 --- a/test/unit/testemu.ts +++ b/test/unit/testemu.ts @@ -4,7 +4,7 @@ import { noise, getNoiseSeed, setNoiseSeed, KeyFlags, RAM, EmuHalt, AnimationTimer, dumpRAM, Keys, makeKeycodeMap, newKeyboardHandler, _setKeyboardEvents, padBytes, AddressDecoder, newAddressDecoder, - getMousePos, drawCrosshair, + getMousePos, drawCrosshair, ControllerPoller, } from "../../src/common/emu"; describe('Noise PRNG', function () { @@ -469,6 +469,155 @@ describe('drawCrosshair', function () { }); }); +describe('ControllerPoller', function () { + const realWindow = (global as any).window; + // node defines a getter-only global navigator, so it can't just be assigned + const realNavigator = Object.getOwnPropertyDescriptor(global, 'navigator'); + function setNavigator(nav) { + Object.defineProperty(global, 'navigator', { value: nav, configurable: true, writable: true }); + } + + let pads: any[]; + let listeners: { [name: string]: (e: any) => void }; + let events: [number, number][]; + + // builds a poller wired to two fake standard-layout gamepads + function newPoller() { + pads = [0, 1].map(() => ({ + axes: [0, 0], + buttons: new Array(16).fill(null).map(() => ({ pressed: false })), + })); + listeners = {}; + events = []; + (global as any).window = { addEventListener: (n, f) => { listeners[n] = f; } }; + setNavigator({ getGamepads: () => pads }); + return new ControllerPoller((key, code, flags) => { events.push([key, flags]); }); + } + + // polls and returns the keys that went down/up since the last call + function poll(poller: ControllerPoller) { + events = []; + poller.poll(); + return events; + } + + function down(k) { return [k.c, KeyFlags.KeyDown]; } + function up(k) { return [k.c, KeyFlags.KeyUp]; } + + after(function () { + if (realWindow === undefined) delete (global as any).window; + else (global as any).window = realWindow; + if (realNavigator === undefined) delete (global as any).navigator; + else Object.defineProperty(global, 'navigator', realNavigator); + }); + + it('should stay inactive until a gamepad connects', function () { + const poller = newPoller(); + assert.strictEqual(poller.active, false); + pads[0].buttons[0].pressed = true; + assert.deepStrictEqual(poll(poller), []); // no state array yet, must not throw + }); + + it('should activate on the gamepadconnected event', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + assert.strictEqual(poller.active, true); + assert.deepStrictEqual(poll(poller), []); // nothing pressed yet + }); + + it('should report button presses and releases once each', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].buttons[9].pressed = true; + assert.deepStrictEqual(poll(poller), [down(Keys.START)]); + assert.deepStrictEqual(poll(poller), []); // held, not re-sent + pads[0].buttons[9].pressed = false; + assert.deepStrictEqual(poll(poller), [up(Keys.START)]); + }); + + it('should send every key sharing a button, not just the first', function () { + // A and GP_A are both button 0 -- platforms bind one or the other + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].buttons[0].pressed = true; + assert.deepStrictEqual(poll(poller), [down(Keys.A), down(Keys.GP_A)]); + }); + + it('should map the face buttons past B', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].buttons[2].pressed = true; + pads[0].buttons[3].pressed = true; + assert.deepStrictEqual(poll(poller), [down(Keys.GP_C), down(Keys.GP_D)]); + }); + + it('should translate axes into direction keys', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].axes[0] = -1; + assert.deepStrictEqual(poll(poller), [down(Keys.LEFT)]); + pads[0].axes[0] = 0; + assert.deepStrictEqual(poll(poller), [up(Keys.LEFT)]); + pads[0].axes[1] = 1; + assert.deepStrictEqual(poll(poller), [down(Keys.DOWN)]); + pads[0].axes[1] = 0; + assert.deepStrictEqual(poll(poller), [up(Keys.DOWN)]); + }); + + it('should release the old direction when an axis flicks across center', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].axes[0] = -1; + assert.deepStrictEqual(poll(poller), [down(Keys.LEFT)]); + // -1 to +1 in one poll: LEFT must not stay stuck down + pads[0].axes[0] = 1; + assert.deepStrictEqual(poll(poller), [up(Keys.LEFT), down(Keys.RIGHT)]); + pads[0].axes[1] = -1; + assert.deepStrictEqual(poll(poller), [down(Keys.UP)]); + pads[0].axes[1] = 1; + assert.deepStrictEqual(poll(poller), [up(Keys.UP), down(Keys.DOWN)]); + }); + + it('should route the second gamepad to the player 2 keys', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[1].axes[1] = -1; + pads[1].buttons[9].pressed = true; + assert.deepStrictEqual(poll(poller), [down(Keys.P2_UP), down(Keys.P2_START)]); + // player 1 keys must not fire for player 2's pad + assert.ok(!events.some(e => e[0] == Keys.UP.c || e[0] == Keys.START.c)); + }); + + it('should ignore axes a key does not use', function () { + // A is button-only, so it must not react to any axis movement + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].axes[0] = 1; + pads[0].axes[1] = 1; + const codes = poll(poller).map(e => e[0]); + assert.ok(!codes.includes(Keys.A.c)); + assert.deepStrictEqual(codes, [Keys.RIGHT.c, Keys.DOWN.c]); // x axis polled before y + }); + + it('should drop gamepad state on disconnect', function () { + const poller = newPoller(); + listeners['gamepadconnected']({}); + poll(poller); + pads[0].buttons[0].pressed = true; + poll(poller); + listeners['gamepaddisconnected']({}); + // state is cleared, so a held button reads as a fresh press + assert.deepStrictEqual(poll(poller), [down(Keys.A), down(Keys.GP_A)]); + }); +}); + describe('getMousePos', function () { const realWindow = (global as any).window;