Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 78 additions & 18 deletions app/games/directional-processing/gabor.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,35 @@ export const PHASE_SPEED_RAD_PER_MS = 0.015;
/** Convenience constant for 2π used in the Gabor formula. */
export const TWO_PI = 2 * Math.PI;

/**
* Color family definitions for the Gabor patch gradient.
*
* Each family defines the darkest (`dark`) and brightest (`bright`) RGB endpoints
* of the sinusoidal grating. The patch oscillates between these two colors, giving
* each family a distinct hue while maintaining high contrast.
*
* Families: Blue, Purple, Orange, Grey, Yellow, Teal.
*
* @type {Array<{ dark: [number, number, number], bright: [number, number, number] }>}
*/
export const COLOR_FAMILIES = [
{ dark: [0, 30, 130], bright: [130, 210, 255] }, // Blue
{ dark: [60, 0, 130], bright: [220, 130, 255] }, // Purple
{ dark: [130, 40, 0], bright: [255, 200, 70] }, // Orange
{ dark: [20, 20, 20], bright: [235, 235, 235] }, // Grey
{ dark: [70, 40, 0], bright: [255, 240, 100] }, // Yellow
{ dark: [0, 55, 60], bright: [80, 230, 220] }, // Teal
];

/**
* Return a randomly selected color family from {@link COLOR_FAMILIES}.
*
* @returns {{ dark: [number, number, number], bright: [number, number, number] }}
*/
export function pickColorFamily() {
return COLOR_FAMILIES[Math.floor(Math.random() * COLOR_FAMILIES.length)];
}

/**
* Direction → Gabor orientation and phase-drift sign mapping.
*
Expand Down Expand Up @@ -68,16 +97,18 @@ export const DIRECTION_PARAMS = {
* sigma?: number,
* gamma?: number,
* contrast?: number,
* colorFamily?: { dark: [number,number,number], bright: [number,number,number] } | null,
* }} [options] - Gabor parameters. Omitted fields use their defaults.
* @returns {Uint8ClampedArray} Flat RGBA array of length width × height × 4.
*/
export function computeGaborPixels(width, height, options = {}) {
const lambda = options.lambda ?? DEFAULT_LAMBDA;
const theta = options.theta ?? 0;
const phi = options.phi ?? DEFAULT_PHI;
const sigma = options.sigma ?? DEFAULT_SIGMA;
const gamma = options.gamma ?? DEFAULT_GAMMA;
const contrast = options.contrast ?? DEFAULT_CONTRAST;
const lambda = options.lambda ?? DEFAULT_LAMBDA;
const theta = options.theta ?? 0;
const phi = options.phi ?? DEFAULT_PHI;
const sigma = options.sigma ?? DEFAULT_SIGMA;
const gamma = options.gamma ?? DEFAULT_GAMMA;
const contrast = options.contrast ?? DEFAULT_CONTRAST;
const colorFamily = options.colorFamily ?? null;

const pixels = new Uint8ClampedArray(width * height * 4);

Expand All @@ -97,15 +128,29 @@ export function computeGaborPixels(width, height, options = {}) {
);
const grating = Math.cos(TWO_PI * (xTheta / lambda) + phi);

// Scale to 0–255 with contrast applied. At contrast=0 the result is 127.5
// (uniform mid-gray); at contrast=1 it spans the full luminance range.
const value = 127.5 * (1 + contrast * envelope * grating);
// t maps from 0 (dark pole) to 1 (bright pole) with contrast applied.
// At contrast=0 all pixels sit at t=0.5 (the midpoint color).
const t = 0.5 * (1 + contrast * envelope * grating);

let r, g, b;
if (colorFamily !== null) {
// Interpolate between the family's dark and bright endpoint colors.
r = colorFamily.dark[0] + t * (colorFamily.bright[0] - colorFamily.dark[0]);
g = colorFamily.dark[1] + t * (colorFamily.bright[1] - colorFamily.dark[1]);
b = colorFamily.dark[2] + t * (colorFamily.bright[2] - colorFamily.dark[2]);
} else {
// Default greyscale path: all channels equal.
const value = 255 * t;
r = value;
g = value;
b = value;
}

const index = (y * width + x) * 4;
pixels[index] = value; // R
pixels[index + 1] = value; // G
pixels[index + 2] = value; // B
pixels[index + 3] = 255; // A (fully opaque)
pixels[index] = r; // R
pixels[index + 1] = g; // G
pixels[index + 2] = b; // B
pixels[index + 3] = 255; // A (fully opaque)
}
}

Expand All @@ -123,6 +168,7 @@ export function computeGaborPixels(width, height, options = {}) {
* sigma?: number,
* gamma?: number,
* contrast?: number,
* colorFamily?: { dark: [number,number,number], bright: [number,number,number] } | null,
* }} [options] - Gabor parameters.
*/
export function drawGabor(canvas, options = {}) {
Expand All @@ -137,19 +183,33 @@ export function drawGabor(canvas, options = {}) {
}

/**
* Fill a canvas with a uniform mid-gray mask.
* Fill a canvas with the midpoint color of a color family (or mid-gray when
* no family is supplied).
*
* The mask interrupts the motion percept without introducing new pattern energy.
* The gray value (128) matches the DC level of the Gabor patches so there is
* no perceptual "pop-out" from the transition.
* Its color matches the DC level of the Gabor patches so there is no perceptual
* "pop-out" from the transition.
*
* @param {HTMLCanvasElement} canvas - Target canvas element.
* @param {{ dark: [number,number,number], bright: [number,number,number] } | null} [colorFamily]
* Optional color family whose midpoint is used as the mask color.
* Defaults to mid-gray (128, 128, 128) when omitted or null.
*/
export function drawMask(canvas) {
export function drawMask(canvas, colorFamily = null) {
const ctx = canvas.getContext('2d');
if (!ctx) return;

ctx.fillStyle = 'rgb(128, 128, 128)';
let fillColor;
if (colorFamily !== null) {
const r = Math.round((colorFamily.dark[0] + colorFamily.bright[0]) / 2);
const g = Math.round((colorFamily.dark[1] + colorFamily.bright[1]) / 2);
const b = Math.round((colorFamily.dark[2] + colorFamily.bright[2]) / 2);
fillColor = `rgb(${r}, ${g}, ${b})`;
} else {
fillColor = 'rgb(128, 128, 128)';
}

ctx.fillStyle = fillColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}

Expand Down
51 changes: 36 additions & 15 deletions app/games/directional-processing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
*/

import * as game from './game.js';
import { drawGabor, drawMask, getDirectionParams, PHASE_SPEED_RAD_PER_MS } from './gabor.js';
import {
drawGabor, drawMask, getDirectionParams, pickColorFamily, PHASE_SPEED_RAD_PER_MS,
} from './gabor.js';
import { playFeedbackSound } from '../../components/audioService.js';
import { returnToMainMenu } from '../../components/gameUtils.js';
import { saveScore } from '../../components/scoreService.js';
Expand All @@ -26,8 +28,8 @@ const GAME_ID = 'directional-processing';
/** Duration (ms) the visual mask is displayed between stimulus and response. */
const MASK_DURATION_MS = 150;

/** Pause (ms) between a response being submitted and the next trial starting. */
const INTER_TRIAL_DELAY_MS = 400;
/** Duration (ms) the color-family background is held after a flash before the next trial. */
const POST_FLASH_PAUSE_MS = 100;

/** Duration (ms) of the green/red flash overlay on the canvas stage. */
const FEEDBACK_FLASH_MS = 250;
Expand Down Expand Up @@ -95,6 +97,12 @@ let _currentDirection = null;
/** Whether the player can currently submit a direction response. */
let _responseEnabled = false;

/**
* Active color family for Gabor patch rendering. Changes on each level change.
* @type {object|null}
*/
let _colorFamily = null;

// ── Async handle references ───────────────────────────────────────────────────

/** rAF handle for the stimulus animation loop. @type {number|null} */
Expand Down Expand Up @@ -197,12 +205,17 @@ export function updateTrendChart() {
}

/**
* Apply a brief colored flash to the stage to indicate correct/incorrect.
* Apply a brief colored flash to the stage to indicate correct/incorrect,
* then invoke an optional callback once the flash has cleared.
*
* @param {boolean} isSuccess
* @param {(() => void) | null} [onComplete] - Called after the flash clears.
*/
function flashStageFeedback(isSuccess) {
if (!_stageEl) return;
function flashStageFeedback(isSuccess, onComplete = null) {
if (!_stageEl) {
if (onComplete) onComplete();
return;
}

_stageEl.classList.remove('dp-stage--flash-correct', 'dp-stage--flash-wrong');
_stageEl.classList.add(
Expand All @@ -218,6 +231,7 @@ function flashStageFeedback(isSuccess) {
_stageEl.classList.remove('dp-stage--flash-correct', 'dp-stage--flash-wrong');
}
_flashTimer = null;
if (onComplete) onComplete();
}, FEEDBACK_FLASH_MS);
}

Expand Down Expand Up @@ -262,7 +276,7 @@ function enterResponsePhase() {
* then transition to the response phase.
*/
function runMaskPhase() {
if (_canvasEl) drawMask(_canvasEl);
if (_canvasEl) drawMask(_canvasEl, _colorFamily);

const start = nowMs();

Expand Down Expand Up @@ -307,7 +321,7 @@ function runStimulusPhase(direction, contrast, displayDurationMs) {
// Advance the grating phase to create the apparent motion effect.
const phi = phiDirection * PHASE_SPEED_RAD_PER_MS * elapsed;
if (_canvasEl) {
drawGabor(_canvasEl, { theta, phi, contrast });
drawGabor(_canvasEl, { theta, phi, contrast, colorFamily: _colorFamily });
}

_stimulusRafId = requestAnimationFrame(tick);
Expand Down Expand Up @@ -348,7 +362,6 @@ export function handleDirectionResponse(direction) {
updateStats();
updateTrendChart();
playFeedbackSound(success);
flashStageFeedback(success);

if (success) {
announce('Correct!');
Expand All @@ -358,12 +371,18 @@ export function handleDirectionResponse(direction) {
announce(`Incorrect — direction was ${_currentDirection}.`);
}

if (game.isRunning()) {
_nextTrialTimer = setTimeout(() => {
_nextTrialTimer = null;
startTrial();
}, INTER_TRIAL_DELAY_MS);
}
// After the flash: switch to a new color family, show its background, then
// wait POST_FLASH_PAUSE_MS before starting the next trial.
flashStageFeedback(success, () => {
_colorFamily = pickColorFamily();
if (_canvasEl) drawMask(_canvasEl, _colorFamily);
if (game.isRunning()) {
_nextTrialTimer = setTimeout(() => {
_nextTrialTimer = null;
startTrial();
}, POST_FLASH_PAUSE_MS);
}
});
}

/**
Expand Down Expand Up @@ -479,6 +498,7 @@ function init(gameContainer) {
*/
function start() {
game.startGame();
_colorFamily = pickColorFamily();

timerService.startTimer((elapsedMs) => {
if (_sessionTimerEl) {
Expand Down Expand Up @@ -542,6 +562,7 @@ function reset() {

_currentDirection = null;
_responseEnabled = false;
_colorFamily = null;

if (_sessionTimerEl) _sessionTimerEl.textContent = '00:00';
if (_feedbackEl) _feedbackEl.textContent = '';
Expand Down
10 changes: 5 additions & 5 deletions app/games/directional-processing/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@
z-index: 3;
}

.dp-stage--flash-correct::after {
background: rgba(16, 185, 129, 0.34);
.dp-stage--flash-wrong::after {
background: rgb(239, 68, 68);
opacity: 1;
}

.dp-stage--flash-wrong::after {
background: rgba(239, 68, 68, 0.34);
.dp-stage--flash-correct::after {
background: rgba(16, 185, 129);
opacity: 1;
}

Expand Down Expand Up @@ -197,4 +197,4 @@
height: 48px;
font-size: 0.875rem;
}
}
}
Loading
Loading