diff --git a/src/test_experiment.js b/src/test_experiment.js index 021145f2..99987fc7 100644 --- a/src/test_experiment.js +++ b/src/test_experiment.js @@ -6,7 +6,7 @@ // import { core, data, sound, util, visual } from "../out/psychojs-2024.1.0.js"; import { core, data, sound, util, visual } from "./index.js"; -// import {StimInspector} from 'https://run.pavlovia.org/lgtst/stiminspector/StimInspector.js'; +import {StimInspector} from 'https://run.pavlovia.org/lgtst/stiminspector/StimInspector.js'; // import {StimInspector} from '../stiminspector/StimInspector.js'; // import {PsyexpReader} from '../psyexpreader/PsyexpReader.js'; const { PsychoJS } = core; @@ -20,33 +20,35 @@ const { round } = util; let expName = 'gabor'; // from the Builder filename that created this script let expInfo = {}; +const TESTING = false; + // Start code blocks for 'Before Experiment' // init psychoJS: const psychoJS = new PsychoJS({ - debug: true + debug: true }); window.psychoJS = psychoJS; window.util = util; // open window: psychoJS.openWindow({ - fullscr: false, - color: new util.Color("gray"), - units: 'height', - waitBlanking: true + fullscr: false, + color: new util.Color("gray"), + units: 'height', + waitBlanking: true }); -// new StimInspector(psychoJS.window, { core, data, sound, util, visual }); +new StimInspector(psychoJS.window, { core, data, sound, util, visual }); // schedule the experiment: -psychoJS.schedule(psychoJS.gui.DlgFromDict({ - dictionary: expInfo, - title: expName -})); +// psychoJS.schedule(psychoJS.gui.DlgFromDict({ +// dictionary: expInfo, +// title: expName +// })); const flowScheduler = new Scheduler(psychoJS); const dialogCancelScheduler = new Scheduler(psychoJS); -psychoJS.scheduleCondition(function() { return (psychoJS.gui.dialogComponent.button === 'OK'); }, flowScheduler, dialogCancelScheduler); +// psychoJS.scheduleCondition(function() { return (psychoJS.gui.dialogComponent.button === 'OK'); }, flowScheduler, dialogCancelScheduler); // flowScheduler gets run if the participants presses OK flowScheduler.add(updateInfo); // add timeStamp @@ -59,41 +61,43 @@ flowScheduler.add(gaborRoutineEachFrame()); flowScheduler.add(gaborRoutineEnd()); flowScheduler.add(quitPsychoJS, '', true); +flowScheduler.start(); + // quit if user presses Cancel in dialog box: dialogCancelScheduler.add(quitPsychoJS, '', false); psychoJS.start({ - expName: expName, - expInfo: expInfo, - configURL: "../config.json", - resources: [ - // { - // name: "007", - // path: "007.jpg" - // }, - ] + expName: expName, + expInfo: expInfo, + configURL: "../config.json", + resources: [ + // { + // name: "007", + // path: "007.jpg" + // }, + ] }); psychoJS.experimentLogger.setLevel(core.Logger.ServerLevel.WARNING); var frameDur; async function updateInfo() { - expInfo['date'] = util.MonotonicClock.getDateStr(); // add a simple timestamp - expInfo['expName'] = expName; - expInfo['psychopyVersion'] = '2021.3.0'; - expInfo['OS'] = window.navigator.platform; - - // store frame rate of monitor if we can measure it successfully - expInfo['frameRate'] = psychoJS.window.getActualFrameRate(); - if (typeof expInfo['frameRate'] !== 'undefined') - frameDur = 1.0 / Math.round(expInfo['frameRate']); - else - frameDur = 1.0 / 60.0; // couldn't get a reliable measure so guess - - // add info from the URL: - util.addInfoFromUrl(expInfo); - - return Scheduler.Event.NEXT; + expInfo['date'] = util.MonotonicClock.getDateStr(); // add a simple timestamp + expInfo['expName'] = expName; + expInfo['psychopyVersion'] = '2021.3.0'; + expInfo['OS'] = window.navigator.platform; + + // store frame rate of monitor if we can measure it successfully + expInfo['frameRate'] = psychoJS.window.getActualFrameRate(); + if (typeof expInfo['frameRate'] !== 'undefined') + frameDur = 1.0 / Math.round(expInfo['frameRate']); + else + frameDur = 1.0 / 60.0; // couldn't get a reliable measure so guess + + // add info from the URL: + util.addInfoFromUrl(expInfo); + + return Scheduler.Event.NEXT; } var instructClock; @@ -106,46 +110,60 @@ var globalClock; var routineTimer; function addWheelListener () { - let v = 1.; - window.addEventListener('wheel', (e) => { - if (!psychoJS) { - return; - } - psychoJS._window._stimsContainer.position.y += e.deltaY * v; - }) + let v = 1.; + window.addEventListener('wheel', (e) => { + if (!psychoJS) { + return; + } + psychoJS._window._stimsContainer.position.y += e.deltaY * v; + }) } // var video; async function experimentInit() { - // Initialize components for Routine "instruct" - instructClock = new util.Clock(); - ready = new core.Keyboard({psychoJS: psychoJS, clock: new util.Clock(), waitForStart: true}); - psychoJS.window.backgroundImage = "toxen"; - - // Initialize components for Routine "gabor" - gaborClock = new util.Clock(); - - stims.push( - new visual.GratingStim({ - win : psychoJS.window, - name: 'morph', - tex: 'sin', - mask: undefined, - ori: 0, - size: [256, 512], - pos: [0, 0], - units: "pix", - depth: 0 - }) - ); - - window.stims = stims; - // Create some handy timers - globalClock = new util.Clock(); // to track the time since experiment started - routineTimer = new util.CountdownTimer(); // to track time remaining of each (non-slip) routine - addWheelListener(); - - return Scheduler.Event.NEXT; + // Initialize components for Routine "instruct" + instructClock = new util.Clock(); + ready = new core.Keyboard({psychoJS: psychoJS, clock: new util.Clock(), waitForStart: true}); + psychoJS.window.backgroundImage = "toxen"; + + // Initialize components for Routine "gabor" + gaborClock = new util.Clock(); + + stims.push( + // new visual.GratingStim({ + // win : psychoJS.window, + // name: 'morph', + // tex: 'sin', + // mask: undefined, + // ori: 0, + // size: [512, 512], + // pos: [0, 0], + // units: "pix", + // depth: 0 + // }), + new visual.DotStim({ + win : psychoJS.window, + name: 'dots', + nDots: 100, + ori: 0, + size: [512, 512], + pos: [0, 0], + units: "pix", + depth: 0, + dotSize: 10, + dotLife: 0, + speed: 0.5, + fieldShape: "circle" + }) + ); + + window.stims = stims; + // Create some handy timers + globalClock = new util.Clock(); // to track the time since experiment started + routineTimer = new util.CountdownTimer(); // to track time remaining of each (non-slip) routine + addWheelListener(); + + return Scheduler.Event.NEXT; } @@ -156,131 +174,131 @@ var gotValidClick; var _ready_allKeys; var instructComponents; function instructRoutineBegin(snapshot) { - return async function () { - TrialHandler.fromSnapshot(snapshot); // ensure that .thisN vals are up to date - - //------Prepare to start Routine 'instruct'------- - t = 0; - instructClock.reset(); // clock - frameN = -1; - continueRoutine = true; // until we're told otherwise - // update component parameters for each repeat - ready.keys = undefined; - ready.rt = undefined; - _ready_allKeys = []; - // keep track of which components have finished - instructComponents = []; - instructComponents.push(ready); - - for (const thisComponent of instructComponents) - if ('status' in thisComponent) - thisComponent.status = PsychoJS.Status.NOT_STARTED; - return Scheduler.Event.NEXT; - } + return async function () { + TrialHandler.fromSnapshot(snapshot); // ensure that .thisN vals are up to date + + //------Prepare to start Routine 'instruct'------- + t = 0; + instructClock.reset(); // clock + frameN = -1; + continueRoutine = true; // until we're told otherwise + // update component parameters for each repeat + ready.keys = undefined; + ready.rt = undefined; + _ready_allKeys = []; + // keep track of which components have finished + instructComponents = []; + instructComponents.push(ready); + + for (const thisComponent of instructComponents) + if ('status' in thisComponent) + thisComponent.status = PsychoJS.Status.NOT_STARTED; + return Scheduler.Event.NEXT; + } } function instructRoutineEachFrame() { - return async function () { - //------Loop for each frame of Routine 'instruct'------- - // get current time - t = instructClock.getTime(); - frameN = frameN + 1;// number of completed frames (so 0 is the first frame) - // update/draw components on each frame - - // *ready* updates - if (t >= 0 && ready.status === PsychoJS.Status.NOT_STARTED) { - // keep track of start time/frame for later - ready.tStart = t; // (not accounting for frame time here) - ready.frameNStart = frameN; // exact frame index - - // keyboard checking is just starting - psychoJS.window.callOnFlip(function() { ready.clock.reset(); }); // t=0 on next screen flip - psychoJS.window.callOnFlip(function() { ready.start(); }); // start on screen flip - psychoJS.window.callOnFlip(function() { ready.clearEvents(); }); - } - - if (ready.status === PsychoJS.Status.STARTED) { - let theseKeys = ready.getKeys({keyList: [], waitRelease: false}); - _ready_allKeys = _ready_allKeys.concat(theseKeys); - if (_ready_allKeys.length > 0) { - ready.keys = _ready_allKeys[_ready_allKeys.length - 1].name; // just the last key pressed - ready.rt = _ready_allKeys[_ready_allKeys.length - 1].rt; - // a response ends the routine - continueRoutine = false; - } - } - - // check for quit (typically the Esc key) - if (psychoJS.experiment.experimentEnded || psychoJS.eventManager.getKeys({keyList:['escape']}).length > 0) { - return quitPsychoJS('The [Escape] key was pressed. Goodbye!', false); - } - - // check if the Routine should terminate - if (!continueRoutine) { // a component has requested a forced-end of Routine - return Scheduler.Event.NEXT; - } - - continueRoutine = false; // reverts to True if at least one component still running - for (const thisComponent of instructComponents) - if ('status' in thisComponent && thisComponent.status !== PsychoJS.Status.FINISHED) { - continueRoutine = true; - break; - } - - // refresh the screen if continuing - if (continueRoutine) { - return Scheduler.Event.FLIP_REPEAT; - } else { - return Scheduler.Event.NEXT; - } - }; + return async function () { + //------Loop for each frame of Routine 'instruct'------- + // get current time + t = instructClock.getTime(); + frameN = frameN + 1;// number of completed frames (so 0 is the first frame) + // update/draw components on each frame + + // *ready* updates + if (t >= 0 && ready.status === PsychoJS.Status.NOT_STARTED) { + // keep track of start time/frame for later + ready.tStart = t; // (not accounting for frame time here) + ready.frameNStart = frameN; // exact frame index + + // keyboard checking is just starting + psychoJS.window.callOnFlip(function() { ready.clock.reset(); }); // t=0 on next screen flip + psychoJS.window.callOnFlip(function() { ready.start(); }); // start on screen flip + psychoJS.window.callOnFlip(function() { ready.clearEvents(); }); + } + + if (ready.status === PsychoJS.Status.STARTED) { + let theseKeys = ready.getKeys({keyList: [], waitRelease: false}); + _ready_allKeys = _ready_allKeys.concat(theseKeys); + if (_ready_allKeys.length > 0) { + ready.keys = _ready_allKeys[_ready_allKeys.length - 1].name; // just the last key pressed + ready.rt = _ready_allKeys[_ready_allKeys.length - 1].rt; + // a response ends the routine + continueRoutine = false; + } + } + + // check for quit (typically the Esc key) + if (psychoJS.experiment.experimentEnded || psychoJS.eventManager.getKeys({keyList:['escape']}).length > 0) { + return quitPsychoJS('The [Escape] key was pressed. Goodbye!', false); + } + + // check if the Routine should terminate + if (!continueRoutine) { // a component has requested a forced-end of Routine + return Scheduler.Event.NEXT; + } + + continueRoutine = false; // reverts to True if at least one component still running + for (const thisComponent of instructComponents) + if ('status' in thisComponent && thisComponent.status !== PsychoJS.Status.FINISHED) { + continueRoutine = true; + break; + } + + // refresh the screen if continuing + if (continueRoutine) { + return Scheduler.Event.FLIP_REPEAT; + } else { + return Scheduler.Event.NEXT; + } + }; } function instructRoutineEnd() { - return async function () { - //------Ending Routine 'instruct'------- - for (const thisComponent of instructComponents) { - if (typeof thisComponent.setAutoDraw === 'function') { - thisComponent.setAutoDraw(false); - } - } - ready.stop(); - // the Routine "instruct" was not non-slip safe, so reset the non-slip timer - routineTimer.reset(); - - return Scheduler.Event.NEXT; - }; + return async function () { + //------Ending Routine 'instruct'------- + for (const thisComponent of instructComponents) { + if (typeof thisComponent.setAutoDraw === 'function') { + thisComponent.setAutoDraw(false); + } + } + ready.stop(); + // the Routine "instruct" was not non-slip safe, so reset the non-slip timer + routineTimer.reset(); + + return Scheduler.Event.NEXT; + }; } var gaborComponents; function gaborRoutineBegin(snapshot) { - return async function () { - TrialHandler.fromSnapshot(snapshot); // ensure that .thisN vals are up to date - - //------Prepare to start Routine 'instruct'------- - t = 0; - gaborClock.reset(); // clock - frameN = -1; - continueRoutine = true; // until we're told otherwise - // update component parameters for each repeat - ready.keys = undefined; - ready.rt = undefined; - _ready_allKeys = []; - // keep track of which components have finished - gaborComponents = []; - gaborComponents.push(ready); - gaborComponents = [...gaborComponents, ...stims]; - - - for (const thisComponent of gaborComponents) - if ('status' in thisComponent) - thisComponent.status = PsychoJS.Status.NOT_STARTED; - - return Scheduler.Event.NEXT; - } + return async function () { + TrialHandler.fromSnapshot(snapshot); // ensure that .thisN vals are up to date + + //------Prepare to start Routine 'instruct'------- + t = 0; + gaborClock.reset(); // clock + frameN = -1; + continueRoutine = true; // until we're told otherwise + // update component parameters for each repeat + ready.keys = undefined; + ready.rt = undefined; + _ready_allKeys = []; + // keep track of which components have finished + gaborComponents = []; + gaborComponents.push(ready); + gaborComponents = [...gaborComponents, ...stims]; + + + for (const thisComponent of gaborComponents) + if ('status' in thisComponent) + thisComponent.status = PsychoJS.Status.NOT_STARTED; + + return Scheduler.Event.NEXT; + } } var secTimer = 0; @@ -296,188 +314,190 @@ var positionTestsProgress = 0; var anchorTestsProgress = 0; var continueAutoTest = true; window.stopTest = function () { - continueAutoTest = false; + continueAutoTest = false; }; window.startTest = function () { - continueAutoTest = true; + continueAutoTest = true; }; function gaborRoutineEachFrame() { - return async function () { - //------Loop for each frame of Routine 'gabor'------- - // get current time - t = gaborClock.getTime(); - frameN = frameN + 1;// number of completed frames (so 0 is the first frame) - - let i; - for (i = 0; i < stims.length; i++) { - if (t >= 0. && stims[i].status === PsychoJS.Status.NOT_STARTED) { - stims[i].tStart = t; - stims[i].frameNStart = frameN; - stims[i].setAutoDraw(true); - } - } - - - // testing code - secTimer += performance.now() - prevTime; - prevTime = performance.now(); - if (secTimer >= 1000 && continueAutoTest) - { - secTimer = 0; - - if (sizeTestsProgress < sizeTests.length * 2) - { - i = sizeTestsProgress % sizeTests.length; - newSize[dynamicDimension] = sizeTests[i]; - stims[0].setSize(newSize); - sizeTestsProgress++; - console.log("stim size set to", stims[0].getSize()); - if (sizeTestsProgress % sizeTests.length === 0) - { - dynamicDimension = (dynamicDimension + 1) % 2; - } - } - else if (sizeTestsProgress < sizeTests.length * 3) - { - i = sizeTestsProgress % sizeTests.length; - newSize[0] = sizeTests[i]; - newSize[1] = sizeTests[i]; - stims[0].setSize(newSize); - sizeTestsProgress++; - console.log("stim size set to", stims[0].getSize()); - } - else if ( - sizeTestsProgress >= sizeTests.length * 3 && - positionTestsProgress < positionTests.length * 2) - { - i = positionTestsProgress % positionTests.length; - newPos[dynamicDimension] = positionTests[i]; - stims[0].setPos(newPos); - positionTestsProgress++; - console.log("stim pos set to", stims[0].getPos()); - if (positionTestsProgress % positionTests.length === 0) - { - newPos[dynamicDimension] = 0; - dynamicDimension = (dynamicDimension + 1) % 2; - } - } - else if( - sizeTestsProgress >= sizeTests.length * 3 && - positionTestsProgress >= positionTests.length * 2 && - anchorTestsProgress < anchorTests.length) - { - i = anchorTestsProgress % anchorTests.length; - stims[0].setAnchor(anchorTests[i]); - anchorTestsProgress++; - console.log("anchor set to", anchorTests[i]); - } - - if ( - sizeTestsProgress >= sizeTests.length * 3 && - positionTestsProgress >= positionTests.length * 2 && - anchorTestsProgress >= anchorTests.length) - { - sizeTestsProgress = 0; - positionTestsProgress = 0; - anchorTestsProgress = 0; - dynamicDimension = 0; - newPos[0] = 0; - newPos[1] = 0; - newSize[0] = 512; - newSize[1] = 512; - console.log("============== full reset =============="); - stims[0].setPos(newPos); - stims[0].setSize(newSize); - stims[0].setAnchor("center"); - } - } - - - // check for quit (typically the Esc key) - if (psychoJS.experiment.experimentEnded || psychoJS.eventManager.getKeys({keyList:['escape']}).length > 0) - { - continueRoutine = false; - } - - // check if the Routine should terminate - if (!continueRoutine) { // a component has requested a forced-end of Routine - return Scheduler.Event.NEXT; - } - - continueRoutine = false; // reverts to True if at least one component still running - for (const thisComponent of gaborComponents) - if ('status' in thisComponent && thisComponent.status !== PsychoJS.Status.FINISHED) { - continueRoutine = true; - break; - } - - // refresh the screen if continuing - if (continueRoutine) { - return Scheduler.Event.FLIP_REPEAT; - } else { - return Scheduler.Event.NEXT; - } - }; + return async function () { + //------Loop for each frame of Routine 'gabor'------- + // get current time + t = gaborClock.getTime(); + frameN = frameN + 1;// number of completed frames (so 0 is the first frame) + + let i; + for (i = 0; i < stims.length; i++) { + if (t >= 0. && stims[i].status === PsychoJS.Status.NOT_STARTED) { + stims[i].tStart = t; + stims[i].frameNStart = frameN; + stims[i].setAutoDraw(true); + } + } + + + if (TESTING) + { + secTimer += performance.now() - prevTime; + prevTime = performance.now(); + if (secTimer >= 1000 && continueAutoTest) + { + secTimer = 0; + + if (sizeTestsProgress < sizeTests.length * 2) + { + i = sizeTestsProgress % sizeTests.length; + newSize[dynamicDimension] = sizeTests[i]; + stims[0].setSize(newSize); + sizeTestsProgress++; + console.log("stim size set to", stims[0].getSize()); + if (sizeTestsProgress % sizeTests.length === 0) + { + dynamicDimension = (dynamicDimension + 1) % 2; + } + } + else if (sizeTestsProgress < sizeTests.length * 3) + { + i = sizeTestsProgress % sizeTests.length; + newSize[0] = sizeTests[i]; + newSize[1] = sizeTests[i]; + stims[0].setSize(newSize); + sizeTestsProgress++; + console.log("stim size set to", stims[0].getSize()); + } + else if ( + sizeTestsProgress >= sizeTests.length * 3 && + positionTestsProgress < positionTests.length * 2) + { + i = positionTestsProgress % positionTests.length; + newPos[dynamicDimension] = positionTests[i]; + stims[0].setPos(newPos); + positionTestsProgress++; + console.log("stim pos set to", stims[0].getPos()); + if (positionTestsProgress % positionTests.length === 0) + { + newPos[dynamicDimension] = 0; + dynamicDimension = (dynamicDimension + 1) % 2; + } + } + else if( + sizeTestsProgress >= sizeTests.length * 3 && + positionTestsProgress >= positionTests.length * 2 && + anchorTestsProgress < anchorTests.length) + { + i = anchorTestsProgress % anchorTests.length; + stims[0].setAnchor(anchorTests[i]); + anchorTestsProgress++; + console.log("anchor set to", anchorTests[i]); + } + + if ( + sizeTestsProgress >= sizeTests.length * 3 && + positionTestsProgress >= positionTests.length * 2 && + anchorTestsProgress >= anchorTests.length) + { + sizeTestsProgress = 0; + positionTestsProgress = 0; + anchorTestsProgress = 0; + dynamicDimension = 0; + newPos[0] = 0; + newPos[1] = 0; + newSize[0] = 512; + newSize[1] = 512; + console.log("============== full reset =============="); + stims[0].setPos(newPos); + stims[0].setSize(newSize); + stims[0].setAnchor("center"); + } + } + } + + + // check for quit (typically the Esc key) + if (psychoJS.experiment.experimentEnded || psychoJS.eventManager.getKeys({keyList:['escape']}).length > 0) + { + continueRoutine = false; + } + + // check if the Routine should terminate + if (!continueRoutine) { // a component has requested a forced-end of Routine + return Scheduler.Event.NEXT; + } + + continueRoutine = false; // reverts to True if at least one component still running + for (const thisComponent of gaborComponents) + if ('status' in thisComponent && thisComponent.status !== PsychoJS.Status.FINISHED) { + continueRoutine = true; + break; + } + + // refresh the screen if continuing + if (continueRoutine) { + return Scheduler.Event.FLIP_REPEAT; + } else { + return Scheduler.Event.NEXT; + } + }; } function gaborRoutineEnd() { - return async function () { - //------Ending Routine 'gabor'------- - for (const thisComponent of gaborComponents) { - if (typeof thisComponent.setAutoDraw === 'function') { - thisComponent.setAutoDraw(false); - } - } + return async function () { + //------Ending Routine 'gabor'------- + for (const thisComponent of gaborComponents) { + if (typeof thisComponent.setAutoDraw === 'function') { + thisComponent.setAutoDraw(false); + } + } - // the Routine "gabor" was not non-slip safe, so reset the non-slip timer - routineTimer.reset(); + // the Routine "gabor" was not non-slip safe, so reset the non-slip timer + routineTimer.reset(); - return Scheduler.Event.NEXT; - }; + return Scheduler.Event.NEXT; + }; } function endLoopIteration(scheduler, snapshot) { - // ------Prepare for next entry------ - return async function () { - if (typeof snapshot !== 'undefined') { - // ------Check if user ended loop early------ - if (snapshot.finished) { - // Check for and save orphaned data - if (psychoJS.experiment.isEntryEmpty()) { - psychoJS.experiment.nextEntry(snapshot); - } - scheduler.stop(); - } else { - const thisTrial = snapshot.getCurrentTrial(); - if (typeof thisTrial === 'undefined' || !('isTrials' in thisTrial) || thisTrial.isTrials) { - psychoJS.experiment.nextEntry(snapshot); - } - } - return Scheduler.Event.NEXT; - } - }; + // ------Prepare for next entry------ + return async function () { + if (typeof snapshot !== 'undefined') { + // ------Check if user ended loop early------ + if (snapshot.finished) { + // Check for and save orphaned data + if (psychoJS.experiment.isEntryEmpty()) { + psychoJS.experiment.nextEntry(snapshot); + } + scheduler.stop(); + } else { + const thisTrial = snapshot.getCurrentTrial(); + if (typeof thisTrial === 'undefined' || !('isTrials' in thisTrial) || thisTrial.isTrials) { + psychoJS.experiment.nextEntry(snapshot); + } + } + return Scheduler.Event.NEXT; + } + }; } function importConditions(currentLoop) { - return async function () { - psychoJS.importAttributes(currentLoop.getCurrentTrial()); - return Scheduler.Event.NEXT; - }; + return async function () { + psychoJS.importAttributes(currentLoop.getCurrentTrial()); + return Scheduler.Event.NEXT; + }; } async function quitPsychoJS(message, isCompleted) { - // Check for and save orphaned data - if (psychoJS.experiment.isEntryEmpty()) { - psychoJS.experiment.nextEntry(); - } - psychoJS.window.close(); - psychoJS.quit({message: message, isCompleted: isCompleted}); - - return Scheduler.Event.QUIT; + // Check for and save orphaned data + if (psychoJS.experiment.isEntryEmpty()) { + psychoJS.experiment.nextEntry(); + } + psychoJS.window.close(); + psychoJS.quit({message: message, isCompleted: isCompleted}); + + return Scheduler.Event.QUIT; } diff --git a/src/visual/DotStim.js b/src/visual/DotStim.js new file mode 100644 index 00000000..1b4f93da --- /dev/null +++ b/src/visual/DotStim.js @@ -0,0 +1,506 @@ +/** + * Dot Stimulus. + * + * @author Nikita Agafonov + * @license Distributed under the terms of the MIT License + */ + +import * as PIXI from "pixi.js-legacy"; +import {AdjustmentFilter} from "@pixi/filter-adjustment"; +import { Color } from "../util/Color.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; + +/** + * Grating Stimulus. + * + * @extends VisualStim + */ +export class DotStim extends VisualStim +{ + /** + * Default size of the Dot Stimuli in pixels. + * + * @type {Array} + * @default [256, 256] + */ + static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels + + static #BLEND_MODES_MAP = { + avg: PIXI.BLEND_MODES.NORMAL, + add: PIXI.BLEND_MODES.ADD, + mul: PIXI.BLEND_MODES.MULTIPLY, + screen: PIXI.BLEND_MODES.SCREEN + }; + + /** + * @memberOf module:visual + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {Window} options.win - the associated Window + * @param {String | HTMLImageElement} [options.tex="sin"] - the name of the predefined grating texture or image resource or the HTMLImageElement corresponding to the texture + * @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus + * @param {number} [options.phase=0.0] - phase of the function used in grating stimulus, multiples of period of that function + * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus + * @param {string} [options.anchor = "center"] - sets the origin point of the stim + * @param {number} [options.ori= 0.0] - the orientation (in degrees) + * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) + * @param {Color} [options.color= "white"] - Foreground color of the stimulus. Can be String like "red" or "#ff0000" or Number like 0xff0000. + * @param {number} [options.opacity= 1.0] - Set the opacity of the stimulus. Determines how visible the stimulus is relative to background. + * @param {number} [options.contrast= 1.0] - Set the contrast of the stimulus, i.e. scales how far the stimulus deviates from the middle grey. Ranges [-1, 1]. + * @param {number} [options.depth= 0] - the depth (i.e. the z order) + * @param {boolean} [options.interpolate= false] - Whether to interpolate (linearly) the texture in the stimulus. Currently supports only image based gratings. + * @param {String} [options.blendmode= "avg"] - blend mode of the stimulus, determines how the stimulus is blended with the background. Supported values: "avg", "add", "mul", "screen". + * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip + * @param {boolean} [options.autoLog= false] - whether or not to log + */ + constructor({ + name, + win, + units, + nDots = 1, + coherence = 0.5, + pos = [0, 0], + size = [ 1, 1 ], + fieldShape = "sqr", + dotSize = 2, + dotLife = 3, + dir = 0.0, + speed = 0.5, + signalDots = "same", + noiseDots = "direction", + anchor, + ori, + color, + colorSpace, + opacity, + contrast = 1, + depth, + interpolate, + blendmode, + autoDraw, + autoLog, + } = {}) + { + super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog }); + + this._pixi = new PIXI.Container(); + this._adjustmentFilter = new AdjustmentFilter({ contrast }); + + this._addAttribute("coherence", coherence, coherence); + this._addAttribute("nDots", nDots, nDots); + this._addAttribute("fieldShape", fieldShape, fieldShape); + this._addAttribute("dotSize", dotSize, dotSize); + this._addAttribute("dotLife", dotLife, dotLife); + this._addAttribute("speed", speed, speed); + this._addAttribute("dir", dir, dir); + this._addAttribute("signalDots", signalDots, signalDots); + this._addAttribute("noiseDots", noiseDots, noiseDots); + this._addAttribute("color", color, "white"); + this._addAttribute("colorSpace", colorSpace, "RGB"); + this._addAttribute("contrast", + contrast, + 1.0, + () => { this._adjustmentFilter.contrast = this._contrast; } + ); + this._addAttribute("blendmode", blendmode, "avg"); + this._addAttribute("interpolate", interpolate, false); + + // estimate the bounding box: + this._estimateBoundingBox(); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } + + if (!Array.isArray(this.size) || this.size.length === 0) + { + this.size = util.to_unit(DotStim.#DEFAULT_STIM_SIZE_PX, "pix", this.win, this.units); + } + + this._positioningFunctions = { + "sqr": this._getRandomPositionWithinSquareField, + "circle": this._getRandomPosWithinCircleField + }; + + this._size_px = util.to_px(this.size, this.units, this.win); + this._dotsLife = new Float32Array(nDots); + this._dotsDir = new Float32Array(nDots); + + // TODO: DEBUG. + // const s = new PIXI.Sprite(PIXI.Texture.WHITE); + // s.width = this._size_px[ 0 ]; + // s.height = this._size_px[ 1 ]; + // s.tint = 0xff0000; + // this._pixi.addChild(s); + + this._spawnDots(); + } + + _getRandomPositionWithinSquareField() + { + const fieldWidth = this._size_px[ 0 ]; + const fieldHeight = this._size_px[ 1 ]; + const x = Math.min(Math.random() * fieldWidth, fieldWidth - this._dotSize); + const y = Math.min(Math.random() * fieldHeight, fieldHeight - this._dotSize); + + return { x, y }; + } + + _getRandomPosWithinCircleField() + { + const fieldRadius = this._size_px[ 0 ] * 0.5 - this._dotSize * 0.5; + const f = Math.random() * 2 * Math.PI - Math.PI; + const r = Math.random(); + const x = Math.cos(f) * fieldRadius * r + this._size_px[0] * 0.5; + const y = Math.sin(f) * fieldRadius * r + this._size_px[1] * 0.5; + + return { x, y }; + } + + _configureDot() + { + const positioningFunc = this._positioningFunctions[ this._fieldShape ]; + + return { + position: positioningFunc.call(this), + lifetime: Math.random() * this._dotLife + }; + } + + _spawnDots() + { + let i; + let dot; + let dotConfig; + const coherentDots = Math.round(this._coherence * this._nDots); + + for (i = 0; i < this._nDots; i ++) + { + // TODO: ensure this is an optimal way to do this. + dot = new PIXI.Graphics(); + dot.beginFill(0xffffff); + dot.arc(0, 0, this._dotSize * 0.5, 0, Math.PI * 2); + dot.endFill(); + dotConfig = this._configureDot(); + dot.x = dotConfig.position.x; + dot.y = dotConfig.position.y; + this._pixi.addChild(dot); + this._dotsLife[ i ] = dotConfig.lifetime; + this._dotsDir[ i ] = this._dir; + if (i > coherentDots) + { + this._dotsDir[ i ] = Math.random() * 360; + } + } + } + + _updateDots() + { + let dotConfig; + let i; + for (i = 0; i < this._nDots; i++) + { + // Ignore lifetime dot updates if initial dotLife setting was explicitly set to 0. + if (this._dotLife > 0) + { + this._dotsLife[ i ] = Math.max(0, this._dotsLife[ i ] - 1); + if (this._dotsLife[ i ] === 0) + { + dotConfig = this._configureDot(); + this._pixi.children[ i ].x = dotConfig.position.x; + this._pixi.children[ i ].y = dotConfig.position.y; + this._dotsLife[ i ] = dotConfig.lifetime; + } + } + + // Move dots. + if (this._noiseDots === "direction") + { + const x = Math.cos(this._dotsDir[ i ] * Math.PI / 180); + const y = Math.sin(this._dotsDir[ i ] * Math.PI / 180); + + // TODO: ensure this is adequate conversion of speed. + const speed_px = util.to_px([ this._speed, this._speed ], this.units, this.win)[ 0 ]; + + this._pixi.children[ i ].x += x * speed_px; + this._pixi.children[ i ].y += y * speed_px; + } + + // Field bounds check. + if (this._fieldShape === "sqr") + { + if (this._pixi.children[ i ].x < 0 || + this._pixi.children[ i ].y < 0 || + this._pixi.children[ i ].x + this._dotSize > this._size_px[ 0 ] || + this._pixi.children[ i ].y + this._dotSize > this._size_px[ 1 ]) + { + // Reset position if out of bounds. + dotConfig = this._configureDot(); + this._pixi.children[ i ].x = dotConfig.position.x; + this._pixi.children[ i ].y = dotConfig.position.y; + } + } + else if (this._fieldShape === "circle") + { + // Shift positions back to the origin for ease of calculations. + const x = this._pixi.children[ i ].x - this._size_px[ 0 ] * 0.5; + const y = this._pixi.children[ i ].y - this._size_px[ 1 ] * 0.5; + const l = Math.sqrt(x * x + y * y); + const r = this._size_px[ 0 ] * 0.5; + + if (l > r) + { + // Reset position if out of bounds. + dotConfig = this._configureDot(); + this._pixi.children[ i ].x = dotConfig.position.x; + this._pixi.children[ i ].y = dotConfig.position.y; + } + } + } + } + + /** + * Setter for the mask attribute. + * + * @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {boolean} [log= false] - whether of not to log + */ + setMask(mask, log = false) + { + const response = { + origin: "DotStim.setMask", + context: "when setting the mask of DotStim: " + this._name, + }; + + try + { + // mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem + if (typeof mask === "undefined") + { + this.psychoJS.logger.warn("setting the mask of DotStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the mask of DotStim: " + this._name + " as: undefined"); + } + else + { + // mask is a string: it should be the name of a resource, which we load + if (typeof mask === "string") + { + mask = this.psychoJS.serverManager.getResource(mask); + } + + // mask should now be an actual HTMLImageElement: we raise an error if it is not + if (!(mask instanceof HTMLImageElement)) + { + throw "the argument: " + mask.toString() + " is not an image\" }"; + } + + this.psychoJS.logger.debug("set the mask of DotStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); + } + + this._setAttribute("mask", mask, log); + + this._onChange(true, false)(); + } + catch (error) + { + throw Object.assign(response, { error }); + } + } + + /** + * Get the size of the display image, which is either that of the DotStim or that of the image + * it contains. + * + * @protected + * @return {number[]} the size of the displayed image + */ + _getDisplaySize() + { + let displaySize = this._size; + + if (typeof displaySize === "undefined") + { + // use the size of the pixi element, if we have access to it: + if (typeof this._pixi !== "undefined" && this._pixi.width > 0) + { + const pixiContainerSize = [this._pixi.width, this._pixi.height]; + displaySize = util.to_unit(pixiContainerSize, "pix", this.win, this.units); + } + } + + return displaySize; + } + + /** + * Estimate the bounding box. + * + * @override + * @protected + */ + _estimateBoundingBox() + { + const size = this._getDisplaySize(); + if (typeof size !== "undefined") + { + this._boundingBox = new PIXI.Rectangle( + this._pos[0] - size[0] / 2, + this._pos[1] - size[1] / 2, + size[0], + size[1], + ); + } + } + + /** + * Set color space value for the grating stimulus. + * + * @param {String} colorSpaceVal - color space value + * @param {boolean} [log= false] - whether of not to log + */ + setColorSpace (colorSpaceVal = "RGB", log = false) + { + let colorSpaceValU = colorSpaceVal.toUpperCase(); + if (Color.COLOR_SPACE[colorSpaceValU] === undefined) + { + colorSpaceValU = "RGB"; + } + const hasChanged = this._setAttribute("colorSpace", colorSpaceValU, log); + if (hasChanged) + { + this.setColor(this._color); + } + } + + /** + * Set foreground color value for the grating stimulus. + * + * @param {Color} colorVal - color value, can be String like "red" or "#ff0000" or Number like 0xff0000. + * @param {boolean} [log= false] - whether of not to log + */ + setColor (colorVal = "white", log = false) + { + const colorObj = (colorVal instanceof Color) ? colorVal : new Color(colorVal, Color.COLOR_SPACE[this._colorSpace]) + this._setAttribute("color", colorObj, log); + // TODO: update dots? + } + + /** + * Determines how visible the stimulus is relative to background. + * + * @param {number} [opacity=1] opacity - The value should be a single float ranging 1.0 (opaque) to 0.0 (transparent). + * @param {boolean} [log= false] - whether of not to log + */ + setOpacity (opacity = 1, log = false) + { + this._setAttribute("opacity", opacity, log); + if (this._pixi) + { + this._pixi.opacity = opacity; + } + } + + /** + * Set blend mode of the grating stimulus. + * + * @param {String} blendMode - blend mode, can be one of the following: ["avg", "add", "mul", "screen"]. + * @param {boolean} [log=false] - whether or not to log + */ + setBlendmode (blendMode = "avg", log = false) + { + this._setAttribute("blendmode", blendMode, log); + if (this._pixi !== undefined) + { + let pixiBlendMode = DotStim.#BLEND_MODES_MAP[blendMode]; + if (pixiBlendMode === undefined) + { + pixiBlendMode = PIXI.BLEND_MODES.NORMAL; + } + if (this._pixi.filters) + { + this._pixi.filters[this._pixi.filters.length - 1].blendMode = pixiBlendMode; + } + else + { + this._pixi.blendMode = pixiBlendMode; + } + } + } + + /** + * Whether to interpolate (linearly) the texture in the stimulus. + * + * @param {boolean} interpolate - interpolate or not. + * @param {boolean} [log=false] - whether or not to log + */ + setInterpolate (interpolate = false, log = false) + { + this._setAttribute("interpolate", interpolate, log); + } + + /** + * Setter for the anchor attribute. + * + * @param {string} anchor - anchor of the stim + * @param {boolean} [log= false] - whether or not to log + */ + setAnchor (anchor = "center", log = false) + { + this._setAttribute("anchor", anchor, log); + if (this._pixi !== undefined) + { + const anchorNum = this._anchorTextToNum(this._anchor); + this._pixi.pivot.x = anchorNum[0] * this._pixi.scale.x * this._size_px[0]; + this._pixi.pivot.y = anchorNum[1] * this._pixi.scale.y * this._size_px[1]; + } + } + + setCoherence(c = 1, log = false) + { + const coherence = Math.max(0, Math.min(1, c)); + this._setAttribute("coherence", coherence, log); + } + + /** + * Update the stimulus, if necessary. + * + * @protected + */ + _updateIfNeeded() + { + // Always update dots. + this._updateDots(); + + // if (!this._needUpdate) + // { + // return; + // } + + // this._needUpdate = false; + + // if (this._needPixiUpdate) + // { + // this._needPixiUpdate = false; + // } + + this._size_px = util.to_px(this._size, this.units, this.win); + this._pixi.zIndex = -this._depth; + this.opacity = this._opacity; + this.anchor = this._anchor; + + // set the scale: + this._pixi.scale.x = 1; + this._pixi.scale.y = 1; + + let pos = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.position.set(pos.x, pos.y); + this._pixi.rotation = -this.ori * Math.PI / 180; + + // re-estimate the bounding box, as the texture's width may now be available: + this._estimateBoundingBox(); + } +} diff --git a/src/visual/index.js b/src/visual/index.js index 9152e784..64f40cd2 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -2,6 +2,7 @@ export * from "./ButtonStim.js"; export * from "./Form.js"; export * from "./ImageStim.js"; export * from "./GratingStim.js"; +export * from "./DotStim.js"; export * from "./MovieStim.js"; export * from "./Polygon.js"; export * from "./Rect.js"; @@ -14,4 +15,4 @@ export * from "./VisualStim.js"; export * from "./FaceDetector.js"; export * from "./Survey.js"; export * from "./ParticleEmitter.js"; -export * from "./Progress.js"; \ No newline at end of file +export * from "./Progress.js"; diff --git a/vite.config.js b/vite.config.js index e187d6d2..4a61057b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,6 +5,9 @@ const fileName = `psychojs-${process.env.npm_package_version}`; export default { root: "./src/", base: "./", + define: { + PSYCHOJS_VERSION: JSON.stringify(process.env.npm_package_version) + }, build: { outDir: "../out",