diff --git a/.changeset/silent-tigers-press.md b/.changeset/silent-tigers-press.md new file mode 100644 index 00000000..e108ac7b --- /dev/null +++ b/.changeset/silent-tigers-press.md @@ -0,0 +1,5 @@ +--- +"@jspsych-contrib/jspsych-gamepad": major +--- + +Added gamepad plugin that allows one to use gamepads in a jsPsych experiment diff --git a/README.md b/README.md index cfcb656d..8d166a23 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Plugin/Extension | Contributor | Description [audio-multi-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-audio-multi-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an audio file using both button clicks and key presses. [audio-swipe-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-audio-swipe-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an audio file using swipe gestures and keyboard responses. [corsi-blocks](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-corsi-blocks/README.md) | [Josh de Leeuw](https://github.com/jodeleeuw) | This plugin displays a configurable Corsi blocks task and records a series of click responses. +[gamepad](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-gamepad/README.md) | [Shaobin Jiang](https://github.com/Shaobin-Jiang) | This plugin allows one to use gamepads in a jsPsych experiment. [html-choice](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-choice/README.md) | [Younes Strittmatter](https://github.com/younesStrittmatter) | This plugin displays clickable html elements that can be used to present a choice. [html-multi-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-multi-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an arbitrary HTML string using both button clicks and key presses. [html-swipe-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-swipe-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an arbitrary HTML string using swipe gestures and keyboard responses. @@ -38,7 +39,6 @@ Plugin/Extension | Contributor | Description [rdk](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-rdk/docs/jspsych-rdk.md#jspsych-rdk-plugin) | [Sivananda Rajananda](https://github.com/vrsivananda) | This plugin displays a Random Dot Kinematogram (RDK) and allows the subject to report the primary direction of motion by pressing a key on the keyboard. [rok](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-rok/docs/jspsych-rok.md#jspsych-rok-plugin) | [Younes Strittmatter](https://github.com/younesStrittmatter) | This plugin displays a Random Object Kinematogram (ROK) and allows the subject to report the primary direction of motion or the primary orientation by pressing a key on the keyboard. [self-paced-reading](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-self-paced-reading/docs/jspsych-self-paced-reading.md) | [@igmmgi](https://github.com/igmmgi) | Self-paced reading tasks with different display options. -[video-several-keyboard-responses](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-video-several-keyboard-responses/docs/jspsych-video-several-keyboard-responses.md)|[@marianylund](https://github.com/marianylund) | This plugin is based on [video-keyboard-response](https://github.com/jspsych/jsPsych/tree/main/packages/plugin-video-keyboard-response) with possibility of recording multiple responses together with video timestamps. [vsl-animate-occlusion](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-vsl-animate-occlusion/docs/jspsych-vsl-animate-occlusion.md#jspsych-vsl-animate-occlusion-plugin) | [Josh de Leeuw](https://github.com/jodeleeuw) | The VSL (visual statistical learning) animate occlusion plugin displays an animated sequence of shapes that disappear behind an occluding rectangle while they change from one shape to another. [vsl-grid-scene](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-vsl-grid-scene/docs/jspsych-vsl-grid-scene.md#jspsych-vsl-grid-scene-plugin) | [Josh de Leeuw](https://github.com/jodeleeuw) | The VSL (visual statistical learning) grid scene plugin displays images arranged in a grid. diff --git a/package-lock.json b/package-lock.json index 0a1793d4..51e8bbd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3115,6 +3115,10 @@ "resolved": "packages/extension-touchscreen-buttons", "link": true }, + "node_modules/@jspsych-contrib/jspsych-gamepad": { + "resolved": "packages/plugin-gamepad", + "link": true + }, "node_modules/@jspsych-contrib/plugin-audio-multi-response": { "resolved": "packages/plugin-audio-multi-response", "link": true @@ -16650,6 +16654,18 @@ "jspsych": ">=7.3.2" } }, + "packages/plugin-gamepad": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + }, + "peerDependencies": { + "jspsych": ">=7.0.0" + } + }, "packages/plugin-html-choice": { "name": "@jspsych-contrib/plugin-html-choice", "version": "1.0.0", @@ -19158,6 +19174,14 @@ "jspsych": "^7.0.0" } }, + "@jspsych-contrib/jspsych-gamepad": { + "version": "file:packages/plugin-gamepad", + "requires": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } + }, "@jspsych-contrib/plugin-audio-multi-response": { "version": "file:packages/plugin-audio-multi-response", "requires": { diff --git a/packages/plugin-gamepad/README.md b/packages/plugin-gamepad/README.md new file mode 100644 index 00000000..9b182a78 --- /dev/null +++ b/packages/plugin-gamepad/README.md @@ -0,0 +1,51 @@ +# jsPsych Gamepad Plugin + +## Overview + +This is a plugin that allows one to use gamepads in a jsPsych experiment. Currently, the plugin is only tested with limited models of gamepads (by limited, it means that only xbox 360 controllers have been tested up to now) and certain features are only functional when using these gamepads. Any support or enhancement is appreciated. + +## Compatibility + +jsPsych >= 7.0 + +## Parameters + +In addition to the [parameters available in all plugins](https://www.jspsych.org/overview/plugins#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of *undefined* must be specified. Parameters can be left unspecified if the default value is acceptable. + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| canvas_size | array | `[500, 500]` | Array that defines the size of the canvas element in pixels. First value is height, second value is width. | +| display_minature_gamepad | boolean | false | Whether to display a minature gamepad on the page that reflects gamepad operations. This feature should probably be used for debugging purposes and at the current stage supports only limited models of gamepads (namely, xbox 360 controllers only) | +| end_trial | function | `(context, gamepad, time_stamp, delta) => { return time_stamp > 2000 }` | This function, when returning `true`, would terminate the trial. It is called once every frame, after `on_frame_update`. It receives four arguments, which are the context to paint on, the gamepad connected (**caution: gamepad can be `null`**), the milliseconds that have passed since the start of the first frame, and the milliseconds since the last frame. | +| gamepad_connection_prompt | HTML string | `Awaiting gamepad connection...` | The content to prompt for gamepad connection, displayed beneath the stimulus. | +| on_frame_update | function | `(context, gamepad, time_stamp, delta) => {}` | This function is called once every frame, where new content can be painted (the old content is automatically removed and the user needs not manually do that part). It receives four arguments, which are the context to paint on, the gamepad connected (**caution: gamepad can be `null`**), the milliseconds that have passed since the start of the first frame, and the milliseconds since the last frame. | +| stimulus | function | `(context) => {}` | The unchanging content of each frame. The function is only called once, and the rendered content is thereafter copy-pasted in every frame before `on_frame_update`. | + +## Data Generated + +In addition to the [default data collected by all plugins](../overview/plugins.md#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +| Name | Type | Value | +| --------- | ------- | ---------------------------------------- | +| rt | number | Milliseconds from the start of the first frame to the end of the last frame. | +| input | Array of Gamepad objects | The state of the gamepad in each frame, put together in the form of an array. Note that this can be colossal in size, so mind how you deal with it. | + +## Example + +See the `examples/` folder. Note that this plugin can only be used via the **https** protocol. + +## Trouble Shooting + +> I keep on receiving the `npm ERR! node-pre-gyp ERR!` error message when running `npm install` on Windows. + +This is actually not about the project itself, but a potential problem one might encounter when installing the dependency [`node-canvas`](https://github.com/Automattic/node-canvas/). If you look more carefully at the full log, you would probably locate this one line: + +``` +npm ERR! C:\GTK\bin\libpangowin32-1.0-0.dll +``` + +The solution to this is thus simple. According to the installation guide of `node-canvas` on Windows, you need to also install GTK2 and unzip it to `C:\GTK`. Check out the project's [wiki](https://github.com/Automattic/node-canvas/wiki/Installation:-Windows) for more information. + +> `display_minature_gamepad` does not work despite my setting it to `true` + +Well, currently I have only tested the feature on Xbox 360 controllers and that is the only model that supports the feature. Theoretically, it should not be too hard to implement the support for other models, but again, I have only this one controller with me, so testing with other gamepads are not possible for me at present. You are, however, more than welcome to contribute to this plugin. diff --git a/packages/plugin-gamepad/docs/jspsych-gamepad.md b/packages/plugin-gamepad/docs/jspsych-gamepad.md new file mode 100644 index 00000000..b1aaaaeb --- /dev/null +++ b/packages/plugin-gamepad/docs/jspsych-gamepad.md @@ -0,0 +1,67 @@ +# jsPsych Gamepad Plugin + +## Overview + +This is a plugin that allows one to use gamepads in a jsPsych experiment. Currently, the plugin is only tested with limited models of gamepads (by limited, it means that only xbox 360 controllers have been tested up to now) and certain features are only functional when using these gamepads. Any support or enhancement is appreciated. + +## Compatibility + +jsPsych >= 7.0 + +## Parameters + +In addition to the [parameters available in all plugins](https://www.jspsych.org/overview/plugins#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of *undefined* must be specified. Parameters can be left unspecified if the default value is acceptable. + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| canvas_size | array | `[500, 500]` | Array that defines the size of the canvas element in pixels. First value is height, second value is width. | +| display_minature_gamepad | boolean | false | Whether to display a minature gamepad on the page that reflects gamepad operations. This feature should probably be used for debugging purposes and at the current stage supports only limited models of gamepads (namely, xbox 360 controllers only) | +| end_trial | function | `(context, gamepad, time_stamp, delta) => { return time_stamp > 2000 }` | This function, when returning `true`, would terminate the trial. It is called once every frame, after `on_frame_update`. It receives four arguments, which are the context to paint on, the gamepad connected (**caution: gamepad can be `null`**), the milliseconds that have passed since the start of the first frame, and the milliseconds since the last frame. | +| gamepad_connection_prompt | HTML string | `Awaiting gamepad connection...` | The content to prompt for gamepad connection, displayed beneath the stimulus. | +| on_frame_update | function | `(context, gamepad, time_stamp, delta) => {}` | This function is called once every frame, where new content can be painted (the old content is automatically removed and the user needs not manually do that part). It receives four arguments, which are the context to paint on, the gamepad connected (**caution: gamepad can be `null`**), the milliseconds that have passed since the start of the first frame, and the milliseconds since the last frame. | +| stimulus | function | `(context) => {}` | The unchanging content of each frame. The function is only called once, and the rendered content is thereafter copy-pasted in every frame before `on_frame_update`. | + +## Data Generated + +In addition to the [default data collected by all plugins](../overview/plugins.md#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +| Name | Type | Value | +| --------- | ------- | ---------------------------------------- | +| rt | number | Milliseconds from the start of the first frame to the end of the last frame. | +| input | Array of Gamepad objects | The state of the gamepad in each frame, put together in the form of an array. Note that this can be colossal in size, so mind how you deal with it. | + +## Examples + +### Track gamepad input for 10 s + +```javascript +let trial = { + type: jsPsychGamepad, + canvas_size: [400, 400], + display_minature_gamepad: true, + end_trial: (context, gamepad, time_stamp, delta_time) => { + return time_stamp > 10000; + }, + gamepad_connection_prompt: 'No controller detected...', + on_frame_update: (context, gamepad, time_stamp, delta) => { + context.save(); + context.font = 'normal 16px Arial'; + context.fillStyle = 'red'; + context.textBaseline = 'top'; + context.fillText(`Time: ${Math.round(time_stamp)} ms`, 20, 20); + context.fillText(`Fps: ${Math.round(1000 / delta)}`, 20, 50); + context.restore(); + }, + stimulus: (context) => { + context.save(); + context.fillStyle = 'rgb(200, 200, 200)'; + context.fillRect(0, 0, 400, 400); + context.font = 'normal 30px Arial'; + context.fillStyle = 'red'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Trial ends in 10 seconds', 200, 200); + context.restore(); + }, +}; +``` diff --git a/packages/plugin-gamepad/examples/basic.html b/packages/plugin-gamepad/examples/basic.html new file mode 100644 index 00000000..f520eed6 --- /dev/null +++ b/packages/plugin-gamepad/examples/basic.html @@ -0,0 +1,48 @@ + + + + + + jspsych-gamepad plugin test + + + + + + + + diff --git a/packages/plugin-gamepad/examples/minature-gamepad.html b/packages/plugin-gamepad/examples/minature-gamepad.html new file mode 100644 index 00000000..092e38cd --- /dev/null +++ b/packages/plugin-gamepad/examples/minature-gamepad.html @@ -0,0 +1,26 @@ + + + + + + jspsych-gamepad plugin test + + + + + + + + diff --git a/packages/plugin-gamepad/examples/move-a-ball.html b/packages/plugin-gamepad/examples/move-a-ball.html new file mode 100644 index 00000000..65bf7875 --- /dev/null +++ b/packages/plugin-gamepad/examples/move-a-ball.html @@ -0,0 +1,68 @@ + + + + + + jspsych-gamepad plugin test + + + + + + + + diff --git a/packages/plugin-gamepad/examples/vibration.html b/packages/plugin-gamepad/examples/vibration.html new file mode 100644 index 00000000..f769036e --- /dev/null +++ b/packages/plugin-gamepad/examples/vibration.html @@ -0,0 +1,54 @@ + + + + + + jspsych-gamepad plugin test + + + + + + + + diff --git a/packages/plugin-gamepad/jest.config.cjs b/packages/plugin-gamepad/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/plugin-gamepad/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-gamepad/package.json b/packages/plugin-gamepad/package.json new file mode 100644 index 00000000..13907382 --- /dev/null +++ b/packages/plugin-gamepad/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jspsych-contrib/jspsych-gamepad", + "version": "0.1.0", + "description": "A jsPsych plugin for using gamepad in behavioral experiments", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest", + "test:watch": "npm test -- --watch", + "tsc": "tsc", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jspsych/jspsych-contrib.git", + "directory": "packages/plugin-gamepad" + }, + "author": "Shaobin Jiang", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jspsych-contrib/issues" + }, + "homepage": "https://github.com/jspsych/jspsych-contrib/tree/main/packages/plugin-pipe", + "peerDependencies": { + "jspsych": ">=7.0.0" + }, + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } +} diff --git a/packages/plugin-gamepad/rollup.config.mjs b/packages/plugin-gamepad/rollup.config.mjs new file mode 100644 index 00000000..a0c1b1ce --- /dev/null +++ b/packages/plugin-gamepad/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychGamepad"); diff --git a/packages/plugin-gamepad/src/gamepad-model.ts b/packages/plugin-gamepad/src/gamepad-model.ts new file mode 100644 index 00000000..90de19ab --- /dev/null +++ b/packages/plugin-gamepad/src/gamepad-model.ts @@ -0,0 +1,80 @@ +import { xbox_360_controller } from "./gamepads/xbox_360_controller"; + +export class GamepadModel { + constructor(private gamepad: Gamepad, parent: HTMLElement, private size: number) { + this.canvas = document.createElement("canvas"); + this.canvas.width = this.size; + this.canvas.height = this.size; + this.canvas.style.width = "100%"; + + parent.appendChild(this.canvas); + + this.context = this.canvas.getContext("2d", { willReadFrequently: true }); + } + + private canvas: HTMLCanvasElement; + + private context: CanvasRenderingContext2D; + + private draw_call_list: Array = []; + + public draw_outline: (context: CanvasRenderingContext2D) => void = ( + _context: CanvasRenderingContext2D + ) => {}; + + // set button_id to -1 if the current component cannot be pressed + public draw_component(component: ComponentPath, axes_id: number, button_id: number): void { + if (component instanceof Path2D) { + this.draw_call_list.push(() => { + this.context.save(); + this.context.fillStyle = `rgba(0, 0, 0, ${this.gamepad.buttons[button_id]?.value})`; + this.context.lineWidth = 3; + this.context.stroke(component); + this.context.fill(component); + this.context.restore(); + }); + } else { + this.draw_call_list.push(() => { + let axes_1: number = axes_id === -1 ? 0 : this.gamepad.axes[axes_id * 2]; + let axes_2: number = axes_id === -1 ? 0 : this.gamepad.axes[axes_id * 2 + 1]; + + let path: Path2D = new Path2D(); + path.arc( + component.x + (axes_1 * component.radius) / 2, + component.y + (axes_2 * component.radius) / 2, + component.radius, + 0, + Math.PI * 2 + ); + + this.context.stroke(path); + + this.context.save(); + this.context.fillStyle = `rgba(0, 0, 0, ${this.gamepad.buttons[button_id]?.value})`; + this.context.lineWidth = 3; + this.context.stroke(path); + this.context.fill(path); + this.context.restore(); + }); + } + } + + public update(gamepad: Gamepad | null): void { + if (gamepad !== null) { + this.gamepad = gamepad; + } + this.context.clearRect(0, 0, this.size, this.size); + this.draw_outline(this.context); + for (let func of this.draw_call_list) { + func(); + } + } +} + +type ComponentPath = Path2D | { x: number; y: number; radius: number }; + +type GamepadTemplate = (gamepad: Gamepad, parent: HTMLElement) => GamepadModel; + +export let GamepadModels: { [prop: string]: GamepadTemplate } = { + "Xbox 360 Controller (XInput STANDARD GAMEPAD)": xbox_360_controller, +}; diff --git a/packages/plugin-gamepad/src/gamepads/xbox_360_controller.ts b/packages/plugin-gamepad/src/gamepads/xbox_360_controller.ts new file mode 100644 index 00000000..91ecfd36 --- /dev/null +++ b/packages/plugin-gamepad/src/gamepads/xbox_360_controller.ts @@ -0,0 +1,81 @@ +import { GamepadModel } from "../gamepad-model"; + +export function xbox_360_controller(gamepad: Gamepad, parent: HTMLElement): GamepadModel { + let model: GamepadModel = new GamepadModel(gamepad, parent, 441); + + model.draw_outline = (context: CanvasRenderingContext2D) => { + context.save(); + context.lineWidth = 3; + + let outline_left: Path2D = new Path2D( + "M220.5 323.5L150 323.5C105 323.5 81.5 407.5 49.5 407.5C17.5 407.5 4 392.9 4 346.5C4 300.1 43.5 194.5 55 166.5C66.5 138.5 95.5 121 128 121L220.5 121" + ); + let outline_right: Path2D = new Path2D( + "M220.5 323.5L291 323.5C336 323.5 359.5 407.5 391.5 407.5C423.5 407.5 437 392.9 437 346.5C437 300.1 397.5 194.5 386 166.5C374.5 138.5 345.5 121 313 121L220.5 121" + ); + context.stroke(outline_left); + context.stroke(outline_right); + + let ls_outline: Path2D = new Path2D(); + ls_outline.arc(113, 189, 37.5, 0, Math.PI * 2); + context.stroke(ls_outline); + + let rs_outline: Path2D = new Path2D(); + rs_outline.arc(278, 267, 37.5, 0, Math.PI * 2); + context.stroke(rs_outline); + + let d_outline: Path2D = new Path2D(); + d_outline.arc(163, 267, 37.5, 0, Math.PI * 2); + context.stroke(d_outline); + + context.restore(); + }; + + model.draw_component({ x: 113, y: 189, radius: 30 }, 0, 10); // left stick + model.draw_component({ x: 278, y: 267, radius: 30 }, 1, 11); // right stick + + model.draw_component({ x: 328, y: 207, radius: 8 }, -1, 0); // A + model.draw_component({ x: 346, y: 189, radius: 8 }, -1, 1); // B + model.draw_component({ x: 310, y: 189, radius: 8 }, -1, 2); // X + model.draw_component({ x: 328, y: 171, radius: 8 }, -1, 3); // Y + + model.draw_component( + new Path2D( + "M111.5 90.5L152.5 90.5C160 90.5 160 103.5 152.5 103.5L111.5 103.5C104 103.5 104 90.5 111.5 90.5" + ), + -1, + 4 + ); // LB + model.draw_component( + new Path2D( + "M329.5 90.5L288.5 90.5C281 90.5 281 103.5 288.5 103.5L329.5 103.5C337 103.5 337 90.5 329.5 90.5" + ), + -1, + 5 + ); // RB + + model.draw_component( + new Path2D( + "M152.5 66C152.5 70 149 73.5 145 73.5H132C128 73.5 124.5 70 124.5 66V45.5C124.5 38 131 31.5 138.5 31.5C146 31.5 152.5 38 152.5 45.5V66Z" + ), + -1, + 6 + ); // LT + model.draw_component( + new Path2D( + "M288.5 66C289.5 70 292 73.5 296 73.5H309C313 73.5 316.5 70 316.5 66V45.5C316.5 38 310 31.5 302.5 31.5C295 31.5 288.5 38 288.5 45.5V66Z" + ), + -1, + 7 + ); // RT + + model.draw_component({ x: 185, y: 191, radius: 8 }, -1, 8); // Left meta + model.draw_component({ x: 259, y: 191, radius: 8 }, -1, 9); // Right meta + + model.draw_component({ x: 163, y: 249, radius: 8 }, -1, 12); // DUp + model.draw_component({ x: 163, y: 285, radius: 8 }, -1, 13); // DDown + model.draw_component({ x: 145, y: 267, radius: 8 }, -1, 14); // DLeft + model.draw_component({ x: 181, y: 267, radius: 8 }, -1, 15); // DRight + + return model; +} diff --git a/packages/plugin-gamepad/src/index.spec.ts b/packages/plugin-gamepad/src/index.spec.ts new file mode 100644 index 00000000..6806fb98 --- /dev/null +++ b/packages/plugin-gamepad/src/index.spec.ts @@ -0,0 +1,22 @@ +import { startTimeline } from "@jspsych/test-utils"; + +import PluginGamepad from "."; + +jest.useFakeTimers(); + +describe("jsPsychGamepad", () => { + it("should end within about 2s", async () => { + let time_start = performance.now(); + await startTimeline([ + { + type: PluginGamepad, + end_trial: (_c: CanvasRenderingContext2D, _g: Gamepad, time_stamp: number, _d: number) => { + return time_stamp > 10000; + }, + }, + ]); + let time_now = performance.now(); + // Set a 50 ms threshold + expect(time_now - time_start).toBeLessThanOrEqual(50); + }); +}); diff --git a/packages/plugin-gamepad/src/index.ts b/packages/plugin-gamepad/src/index.ts new file mode 100644 index 00000000..e2d8417a --- /dev/null +++ b/packages/plugin-gamepad/src/index.ts @@ -0,0 +1,213 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +import { GamepadModel, GamepadModels } from "./gamepad-model"; + +const info = { + name: "jspsych-gamepad", + parameters: { + /** + * The size of the canvas element + */ + canvas_size: { + type: ParameterType.INT, + default: [500, 500], + array: true, + }, + /** + * Whether to display a minature gamepad on the page. + * If set to true, a minature gamepad would be rendered, which simultaneously reflects the button presses and + * joystick movements of the gamepad. + * This should only be used for debug purposes, though. + * Note: at the current stage, there is only limited support to this feature + */ + display_minature_gamepad: { + type: ParameterType.BOOL, + default: false, + }, + /** + * The function which, when returning true, would terminate the current trial. + * This function is called once every frame. + * + * @param {CanvasRenderingContext2D} context: The context to draw upon + * @param {Gamepad} gamepad: The gamepad object connected + * @param {number} time_stamp: The milliseconds that have elapsed since the first frame + * @param {number} delta_time: The milliseconds that have elapsed since the last frame + */ + end_trial: { + type: ParameterType.FUNCTION, + default: ( + _context: CanvasRenderingContext2D, + _gamepad: Gamepad, + _time_stamp: number, + _delta_time: number + ) => { + return _time_stamp > 2000; + }, + }, + /** + * The message to display above the canvas when no gamepad is connected or when connection is lost. + */ + gamepad_connection_prompt: { + type: ParameterType.STRING, + default: "Awaiting gamepad connection...", + }, + /** + * The function that runs in every frame update. + * + * @param {CanvasRenderingContext2D} context: The context to draw upon + * @param {Gamepad} gamepad: The gamepad object connected + * @param {number} time_stamp: The milliseconds that have elapsed since the first frame + * @param {number} delta_time: The milliseconds that have elapsed since the last frame + */ + on_frame_update: { + type: ParameterType.FUNCTION, + default: ( + _context: CanvasRenderingContext2D, + _gamepad: Gamepad, + _time_stamp: number, + _delta_time: number + ) => {}, + }, + /** + * The function to draw on the canvas. + * This function automatically takes a canvas context as its only argument + * The content of the stimulus is only drawn once in the first frame, and is then copy-pasted in subsequent frames. + * Therefore, this parameter is best suited for drawing contents that does not change throughout the entire trial. + * One can return a Promise object with the function, which will be automatically detected by the plugin. + */ + stimulus: { + type: ParameterType.FUNCTION, + default: (_context: CanvasRenderingContext2D) => {}, + }, + }, +}; + +type Info = typeof info; + +/** + * **jspsych-gamepad** + * + * A jsPsych plugin for using gamepad in behavioral experiments. + * + * @author Shaobin Jiang + */ +class GamepadPlugin implements JsPsychPlugin { + static info = info; + + constructor(private jsPsych: JsPsych) {} + + private gamepad: Gamepad | null; + private gamepad_is_connected: boolean = false; + + private minature_gamepad: GamepadModel | null = null; + private minature_gamepad_wrapper: HTMLDivElement; + + private start_time_stamp: number = 0; + private last_frame_time_stamp: number = 0; + + private animation_frame_id: number = -1; + private frame_request_callback: FrameRequestCallback = (_time: DOMHighResTimeStamp) => {}; + + private gamepad_inputs: Array = []; + + private find_gamepad(): void { + this.gamepad = null; + this.gamepad_is_connected = false; + for (let gamepad of navigator.getGamepads()) { + if (gamepad instanceof Gamepad) { + let previous_gamepad_id = this.gamepad?.id; + + this.gamepad = gamepad; + this.gamepad_is_connected = true; + + if (gamepad.id !== previous_gamepad_id) { + this.minature_gamepad_wrapper.innerHTML = ""; + this.minature_gamepad = GamepadModels[gamepad.id](gamepad, this.minature_gamepad_wrapper); + } + break; + } + } + } + + public trial(display_element: HTMLElement, trial: TrialType, on_load: Function) { + // Initialize the canvas element + let canvas: HTMLCanvasElement = document.createElement("canvas"); + canvas.width = trial.canvas_size[0]; + canvas.height = trial.canvas_size[1]; + + // Initialize the prompt element + let gamepad_connection_prompt: HTMLParagraphElement = document.createElement("p"); + gamepad_connection_prompt.style.textAlign = "center"; + gamepad_connection_prompt.innerHTML = trial.gamepad_connection_prompt; + + display_element.appendChild(canvas); + display_element.appendChild(gamepad_connection_prompt); + + this.minature_gamepad_wrapper = document.createElement("div"); + this.minature_gamepad_wrapper.id = "gamepad-model-wrapper"; + this.minature_gamepad_wrapper.style.width = "10%"; + this.minature_gamepad_wrapper.style.position = "absolute"; + this.jsPsych.getDisplayContainerElement().appendChild(this.minature_gamepad_wrapper); + + let context: CanvasRenderingContext2D = canvas.getContext("2d", { willReadFrequently: true }); + + let stimulus: any = trial.stimulus(context); + let promise: Promise; + if (stimulus instanceof Promise) { + promise = stimulus; + } else { + promise = Promise.resolve(stimulus); + } + + promise.then(() => { + let image_data: ImageData = context.getImageData(0, 0, canvas.width, canvas.height); + + this.frame_request_callback = (now: DOMHighResTimeStamp) => { + if (this.start_time_stamp === 0) { + this.start_time_stamp = now; + } + + this.find_gamepad(); + + // If gamepad_is_connected is true, then gamepad_connection_prompt should be empty and vice versa + gamepad_connection_prompt.style.visibility = this.gamepad_is_connected ? "hidden" : ""; + + let time_stamp: number = now - this.start_time_stamp; + let delta: number = this.last_frame_time_stamp === 0 ? 0 : now - this.last_frame_time_stamp; + + context.putImageData(image_data, 0, 0); + trial.on_frame_update(context, this.gamepad, time_stamp, delta); + + if (trial.display_minature_gamepad && this.minature_gamepad_wrapper !== null) { + this.minature_gamepad?.update(this.gamepad); + } + + this.gamepad_inputs.push(this.gamepad); + + if (trial.end_trial(context, this.gamepad, time_stamp, delta)) { + finish_trial({ + rt: time_stamp, + input: this.gamepad_inputs, + }); + } + + this.last_frame_time_stamp = now; + + this.animation_frame_id = window.requestAnimationFrame(this.frame_request_callback); + }; + + on_load(); + + this.animation_frame_id = window.requestAnimationFrame(this.frame_request_callback); + }); + + let finish_trial: Function = (data: object) => { + window.cancelAnimationFrame(this.animation_frame_id); + display_element.innerHTML = ""; + this.minature_gamepad_wrapper.remove(); + this.jsPsych.finishTrial(data); + }; + } +} + +export default GamepadPlugin; diff --git a/packages/plugin-gamepad/tsconfig.json b/packages/plugin-gamepad/tsconfig.json new file mode 100644 index 00000000..3eabd0c2 --- /dev/null +++ b/packages/plugin-gamepad/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.contrib.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}