diff --git a/app/games/directional-processing/gabor.js b/app/games/directional-processing/gabor.js index 7bfdfb3..61c9871 100644 --- a/app/games/directional-processing/gabor.js +++ b/app/games/directional-processing/gabor.js @@ -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. * @@ -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); @@ -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) } } @@ -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 = {}) { @@ -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); } diff --git a/app/games/directional-processing/index.js b/app/games/directional-processing/index.js index dfec98a..2fa785a 100644 --- a/app/games/directional-processing/index.js +++ b/app/games/directional-processing/index.js @@ -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'; @@ -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; @@ -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} */ @@ -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( @@ -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); } @@ -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(); @@ -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); @@ -348,7 +362,6 @@ export function handleDirectionResponse(direction) { updateStats(); updateTrendChart(); playFeedbackSound(success); - flashStageFeedback(success); if (success) { announce('Correct!'); @@ -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); + } + }); } /** @@ -479,6 +498,7 @@ function init(gameContainer) { */ function start() { game.startGame(); + _colorFamily = pickColorFamily(); timerService.startTimer((elapsedMs) => { if (_sessionTimerEl) { @@ -542,6 +562,7 @@ function reset() { _currentDirection = null; _responseEnabled = false; + _colorFamily = null; if (_sessionTimerEl) _sessionTimerEl.textContent = '00:00'; if (_feedbackEl) _feedbackEl.textContent = ''; diff --git a/app/games/directional-processing/style.css b/app/games/directional-processing/style.css index 138aa1d..cbed958 100644 --- a/app/games/directional-processing/style.css +++ b/app/games/directional-processing/style.css @@ -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; } @@ -197,4 +197,4 @@ height: 48px; font-size: 0.875rem; } -} +} \ No newline at end of file diff --git a/app/games/directional-processing/tests/gabor.test.js b/app/games/directional-processing/tests/gabor.test.js index 6e2c11e..88378f7 100644 --- a/app/games/directional-processing/tests/gabor.test.js +++ b/app/games/directional-processing/tests/gabor.test.js @@ -11,10 +11,12 @@ import { PHASE_SPEED_RAD_PER_MS, TWO_PI, DIRECTION_PARAMS, + COLOR_FAMILIES, computeGaborPixels, drawGabor, drawMask, getDirectionParams, + pickColorFamily, } from '../gabor.js'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -65,6 +67,24 @@ describe('exported constants', () => { test('up and down have opposite phiDirection values', () => { expect(DIRECTION_PARAMS.up.phiDirection).toBe(-DIRECTION_PARAMS.down.phiDirection); }); + + test('COLOR_FAMILIES has exactly 6 entries', () => { + expect(COLOR_FAMILIES).toHaveLength(6); + }); + + test('each COLOR_FAMILIES entry has dark and bright arrays of length 3', () => { + COLOR_FAMILIES.forEach((family) => { + expect(family.dark).toHaveLength(3); + expect(family.bright).toHaveLength(3); + }); + }); + + test('pickColorFamily returns an object from COLOR_FAMILIES', () => { + const family = pickColorFamily(); + expect(COLOR_FAMILIES).toContain(family); + expect(family.dark).toHaveLength(3); + expect(family.bright).toHaveLength(3); + }); }); // ── computeGaborPixels ──────────────────────────────────────────────────────── @@ -83,7 +103,7 @@ describe('computeGaborPixels', () => { } }); - test('all RGB channels are equal (grayscale output)', () => { + test('all RGB channels are equal when no colorFamily is provided (grayscale output)', () => { const pixels = computeGaborPixels(8, 8); for (let i = 0; i < pixels.length; i += 4) { expect(pixels[i]).toBe(pixels[i + 1]); @@ -122,6 +142,46 @@ describe('computeGaborPixels', () => { const pCustom = Array.from(computeGaborPixels(16, 16, { sigma: DEFAULT_SIGMA / 2 })); expect(pDefault).not.toEqual(pCustom); }); + + test('with a colorFamily the RGB channels are not all equal', () => { + // Blue family has very different R, G, B endpoints so channels will differ. + const blueFamily = COLOR_FAMILIES[0]; + const pixels = computeGaborPixels(16, 16, { colorFamily: blueFamily }); + let allEqual = true; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i] !== pixels[i + 1] || pixels[i] !== pixels[i + 2]) { + allEqual = false; + break; + } + } + expect(allEqual).toBe(false); + }); + + test('with a colorFamily all pixel values are bounded by the family endpoints', () => { + const family = { dark: [10, 20, 30], bright: [200, 180, 160] }; + const pixels = computeGaborPixels(16, 16, { colorFamily: family }); + for (let i = 0; i < pixels.length; i += 4) { + expect(pixels[i]).toBeGreaterThanOrEqual(family.dark[0]); + expect(pixels[i]).toBeLessThanOrEqual(family.bright[0]); + expect(pixels[i + 1]).toBeGreaterThanOrEqual(family.dark[1]); + expect(pixels[i + 1]).toBeLessThanOrEqual(family.bright[1]); + expect(pixels[i + 2]).toBeGreaterThanOrEqual(family.dark[2]); + expect(pixels[i + 2]).toBeLessThanOrEqual(family.bright[2]); + } + }); + + test('at zero contrast with a colorFamily all pixels are at the midpoint color', () => { + const family = { dark: [0, 40, 80], bright: [200, 160, 120] }; + const pixels = computeGaborPixels(8, 8, { contrast: 0, colorFamily: family }); + const midR = (family.dark[0] + family.bright[0]) / 2; // 100 + const midG = (family.dark[1] + family.bright[1]) / 2; // 100 + const midB = (family.dark[2] + family.bright[2]) / 2; // 100 + for (let i = 0; i < pixels.length; i += 4) { + expect(pixels[i]).toBeCloseTo(midR, 0); + expect(pixels[i + 1]).toBeCloseTo(midG, 0); + expect(pixels[i + 2]).toBeCloseTo(midB, 0); + } + }); }); // ── drawGabor ───────────────────────────────────────────────────────────────── @@ -170,7 +230,7 @@ describe('drawGabor', () => { // ── drawMask ────────────────────────────────────────────────────────────────── describe('drawMask', () => { - test('fills the entire canvas with mid-gray', () => { + test('fills the entire canvas with mid-gray when no colorFamily is provided', () => { const mockFillRect = jest.fn(); const mockCtx = { fillStyle: '', @@ -188,6 +248,26 @@ describe('drawMask', () => { expect(mockFillRect).toHaveBeenCalledWith(0, 0, 20, 15); }); + test('fills the canvas with the midpoint color of the provided colorFamily', () => { + const mockFillRect = jest.fn(); + const mockCtx = { + fillStyle: '', + fillRect: mockFillRect, + }; + const mockCanvas = { + width: 20, + height: 15, + getContext: () => mockCtx, + }; + const family = { dark: [0, 40, 100], bright: [200, 160, 120] }; + + drawMask(mockCanvas, family); + + // midpoint: R=100, G=100, B=110 + expect(mockCtx.fillStyle).toBe('rgb(100, 100, 110)'); + expect(mockFillRect).toHaveBeenCalledWith(0, 0, 20, 15); + }); + test('does nothing (no throw) when getContext returns null', () => { const mockCanvas = { width: 10, diff --git a/app/games/directional-processing/tests/index.test.js b/app/games/directional-processing/tests/index.test.js index 18ad091..2d0ce4a 100644 --- a/app/games/directional-processing/tests/index.test.js +++ b/app/games/directional-processing/tests/index.test.js @@ -43,6 +43,7 @@ jest.unstable_mockModule('../gabor.js', () => ({ drawGabor: jest.fn(), drawMask: jest.fn(), getDirectionParams: jest.fn(() => ({ theta: 0, phiDirection: -1 })), + pickColorFamily: jest.fn(() => ({ dark: [20, 20, 20], bright: [235, 235, 235] })), PHASE_SPEED_RAD_PER_MS: 0.015, })); @@ -229,8 +230,10 @@ describe('directional-processing plugin', () => { document.querySelector('#dp-btn-right').classList.contains('dp-dir-btn--correct'), ).toBe(true); - // Fire the inter-trial timer → startTrial() → clearDirectionHighlights(). - jest.runOnlyPendingTimers(); + // Fire flash timer → callback shows background → fires post-flash pause + // → startTrial() → clearDirectionHighlights(). + jest.runOnlyPendingTimers(); // flash timer + jest.runOnlyPendingTimers(); // post-flash pause timer → startTrial expect( document.querySelector('#dp-btn-right').classList.contains('dp-dir-btn--correct'),