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}

You may roll again (${rolls_remaining} ` + `practice ${rolls_remaining === 1 ? 'roll' : 'rolls'} remaining) or click 'Continue' to proceed.

`; // 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 ? `

Practice rolling the die. You can complete up to ${practice_roll_limit}

` + `practice ${practice_roll_limit === 1 ? 'roll' : 'rolls'}.

Click the die to start, then click it again to stop.

` : '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 ? `
${prompt}
` : ''; return `
${prompt_html}
?
`; } 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;