Files
suspicion-checks-experiment-3/plugins/jspsych-die-roll.js
2026-02-27 22:49:53 +01:00

347 lines
10 KiB
JavaScript

import { ParameterType } from 'jspsych';
const info = {
name: 'die-roll',
parameters: {
prompt: {
type: ParameterType.HTML_STRING,
default: '',
},
rigged_value: {
type: ParameterType.INT,
default: null,
},
roll_duration: {
type: ParameterType.INT,
default: 2000,
},
animation_interval: {
type: ParameterType.INT,
default: 120,
},
button_label: {
type: ParameterType.STRING,
default: 'Continue',
},
result_template: {
type: ParameterType.STRING,
default: 'You rolled a {{value}}.',
},
practice_trial: {
type: ParameterType.BOOL,
default: false,
},
practice_roll_limit: {
type: ParameterType.INT,
default: 5,
},
},
};
class DieRollPlugin {
constructor(jsPsych) {
this.jsPsych = jsPsych;
}
trial(display_element, trial) {
const start_time = performance.now();
const practice_enabled = Boolean(trial.practice_trial);
const practice_roll_limit = practice_enabled
? this.get_practice_roll_limit(trial.practice_roll_limit)
: 1;
const sanitized_rigged_value = practice_enabled
? null
: this.get_rigged_value(trial.rigged_value);
const get_next_result_value = () =>
sanitized_rigged_value ?? this.get_random_value();
let pending_final_value = get_next_result_value();
let last_roll_value = null;
display_element.innerHTML = this.build_markup(
trial.prompt,
trial.button_label
);
const face_element = display_element.querySelector(
'.jspsych_die_roll_face'
);
const result_element = display_element.querySelector(
'.jspsych_die_roll_result'
);
const button_element = display_element.querySelector(
'.jspsych_die_roll_button'
);
const PRACTICE_UNLOCK_DELAY_MS = 600;
const MIN_IDLE_CLICK_INTERVAL_MS = 350;
let is_rolling = false;
let has_rolled = false;
let roll_started_at = null;
let roll_stopped_at = null;
let die_click_count = 0;
let practice_rolls_completed = 0;
let practice_reenable_timeout = null;
let previous_face_value = null;
let last_face_interaction_at = null;
const clear_timeouts = () => {
if (practice_enabled && practice_reenable_timeout !== null) {
window.clearTimeout(practice_reenable_timeout);
practice_reenable_timeout = null;
}
this.jsPsych.pluginAPI.clearAllTimeouts();
};
const lock_die_face = () => {
face_element.classList.remove('jspsych_die_roll_face--interactive');
face_element.classList.remove('jspsych_die_roll_face--active');
face_element.classList.add('jspsych_die_roll_face--locked');
face_element.setAttribute('aria-disabled', 'true');
face_element.removeAttribute('tabindex');
face_element.style.pointerEvents = 'none';
};
const make_die_interactive = () => {
face_element.classList.remove('jspsych_die_roll_face--locked');
face_element.classList.add('jspsych_die_roll_face--interactive');
face_element.setAttribute('tabindex', '0');
face_element.removeAttribute('aria-disabled');
face_element.style.pointerEvents = '';
};
const get_next_face_value = () => {
let value;
do {
value = Math.floor(Math.random() * 6) + 1;
} while (
value === previous_face_value ||
(sanitized_rigged_value !== null && value === sanitized_rigged_value)
);
previous_face_value = value;
return value;
};
const cycle_face = () => {
if (!is_rolling) {
return;
}
const value = get_next_face_value();
face_element.textContent = value;
this.jsPsych.pluginAPI.setTimeout(cycle_face, trial.animation_interval);
};
const finalize_roll = () => {
if (!is_rolling || has_rolled) {
return;
}
is_rolling = false;
has_rolled = true;
die_click_count += 1;
roll_stopped_at = performance.now();
clear_timeouts();
last_roll_value = pending_final_value;
face_element.textContent = last_roll_value;
lock_die_face();
practice_rolls_completed += 1;
const rolls_remaining = Math.max(
practice_roll_limit - practice_rolls_completed,
0
);
const formatted_result = this.render_result_text(
trial.result_template,
{
value: last_roll_value,
roll_number: practice_rolls_completed,
roll_limit: practice_enabled ? practice_roll_limit : null,
rolls_remaining: practice_enabled ? rolls_remaining : null,
}
);
if (practice_enabled) {
if (rolls_remaining > 0) {
result_element.innerHTML =
`${formatted_result} <p class = "mt-2"> You may roll again (${rolls_remaining} ` +
`practice ${rolls_remaining === 1 ? 'roll' : 'rolls'} remaining) or click 'Continue' to proceed.</p>`;
// Brief delay prevents a double-click from immediately starting a new roll.
practice_reenable_timeout = window.setTimeout(() => {
make_die_interactive();
has_rolled = false;
practice_reenable_timeout = null;
}, PRACTICE_UNLOCK_DELAY_MS);
} else {
result_element.textContent =
`${formatted_result} Practice complete. Click 'Continue' to proceed.`;
}
} else {
result_element.textContent = formatted_result;
}
button_element.disabled = false;
if (!practice_enabled || rolls_remaining === 0) {
button_element.focus();
}
};
const begin_roll = () => {
if (is_rolling || has_rolled) {
return;
}
previous_face_value = null;
pending_final_value = get_next_result_value();
is_rolling = true;
die_click_count += 1;
roll_started_at = performance.now();
result_element.textContent = 'Rolling... click again to stop the die.';
face_element.classList.add('jspsych_die_roll_face--active');
button_element.disabled = true;
clear_timeouts();
cycle_face();
};
const handle_face_interaction = event => {
const event_type = event?.type ?? 'unknown';
if (
(event_type === 'pointerdown' || event_type === 'click') &&
typeof event?.button === 'number' &&
event.button !== 0
) {
return;
}
if (practice_enabled) {
const now = performance.now();
if (!is_rolling && last_face_interaction_at !== null) {
const elapsed = now - last_face_interaction_at;
if (elapsed < MIN_IDLE_CLICK_INTERVAL_MS) {
event?.preventDefault();
return;
}
}
last_face_interaction_at = now;
}
if (is_rolling) {
finalize_roll();
return;
}
if (!has_rolled) {
begin_roll();
return;
}
};
button_element.disabled = true;
face_element.textContent = '';
result_element.innerHTML = practice_enabled
? `<p>Practice rolling the die. You can complete up to ${practice_roll_limit}</p>` +
`practice ${practice_roll_limit === 1 ? 'roll' : 'rolls'}. <p class = "mt-2"> Click the die to start, then click it again to stop. </p>`
: 'Click the die to start, then click it again to lock in your number.';
make_die_interactive();
face_element.setAttribute('role', 'button');
face_element.setAttribute(
'aria-label',
'Virtual die. Click to start and stop.'
);
const face_interaction_event =
typeof window.PointerEvent === 'function' ? 'pointerdown' : 'click';
face_element.addEventListener(face_interaction_event, handle_face_interaction);
face_element.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handle_face_interaction(event);
}
});
const end_trial = () => {
is_rolling = false;
clear_timeouts();
const trial_data = {
roll: last_roll_value,
rigged_value: sanitized_rigged_value,
rigged: sanitized_rigged_value !== null,
die_click_count: die_click_count,
roll_started_after_ms: roll_started_at
? roll_started_at - start_time
: null,
roll_duration_ms:
roll_started_at && roll_stopped_at
? roll_stopped_at - roll_started_at
: null,
rt: performance.now() - start_time,
practice_trial: practice_enabled,
practice_roll_limit: practice_enabled ? practice_roll_limit : null,
practice_rolls_completed: practice_enabled
? practice_rolls_completed
: null,
practice_rolls_remaining: practice_enabled
? Math.max(practice_roll_limit - practice_rolls_completed, 0)
: null,
};
display_element.innerHTML = '';
this.jsPsych.finishTrial(trial_data);
};
button_element.addEventListener('click', () => {
if (!button_element.disabled) {
end_trial();
}
});
}
build_markup(prompt, button_label) {
const prompt_html = prompt
? `<div class="jspsych_die_roll_prompt">${prompt}</div>`
: '';
return `
<div class="jspsych_die_roll">
${prompt_html}
<div class="jspsych_die_roll_face">?</div>
<div class="jspsych_die_roll_result" aria-live="polite"></div>
<button class="jspsych-btn jspsych_die_roll_button" type="button">${button_label}</button>
</div>
`;
}
get_random_value() {
return this.jsPsych.randomization.sampleWithoutReplacement(
[1, 2, 3, 4, 5, 6],
1
)[0];
}
get_rigged_value(value) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= 6) {
return Math.round(numeric);
}
return null;
}
get_practice_roll_limit(value) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 1) {
return Math.round(numeric);
}
return 5;
}
render_result_text(template, replacements) {
if (typeof template !== 'string' || template.length === 0) {
return '';
}
return Object.entries(replacements).reduce((text, [key, value]) => {
if (value === null || value === undefined) {
return text;
}
const pattern = new RegExp(`\\{\\{${key}\\}\\}`, 'gi');
return text.replace(pattern, value);
}, template);
}
}
DieRollPlugin.info = info;
export default DieRollPlugin;