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
83 changes: 42 additions & 41 deletions app/components/gameCard.test.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,3 @@
it('displays high score for Fast Piggie when provided', () => {
const manifest = {
id: 'fast-piggie',
name: 'Fast Piggie',
description: 'Test desc',
thumbnail: '/images/test.png',
};
const progress = { highScore: 42 };
const card = createGameCard(manifest, progress);
const scoreElem = card.querySelector('.game-high-score');
expect(scoreElem).not.toBeNull();
expect(scoreElem.textContent).toContain('42');
});

it('displays all-time best threshold for Field of View when provided', () => {
const manifest = {
id: 'field-of-view',
name: 'Field of View',
description: 'Test desc',
thumbnail: '/images/test.png',
};
const progress = { bestThresholdMs: 84.2 };
const card = createGameCard(manifest, progress);
const scoreElem = card.querySelector('.game-high-score');
expect(scoreElem).not.toBeNull();
expect(scoreElem.textContent).toContain('All-time Best Threshold: 84.2ms');
});

it('shows no-data text for Field of View when no best threshold exists', () => {
const manifest = {
id: 'field-of-view',
name: 'Field of View',
description: 'Test desc',
thumbnail: '/images/test.png',
};
const progress = {};
const card = createGameCard(manifest, progress);
const scoreElem = card.querySelector('.game-high-score');
expect(scoreElem).not.toBeNull();
expect(scoreElem.textContent).toContain('No data yet');
});
import { createGameCard } from './gameCard.js';

const validManifest = {
Expand Down Expand Up @@ -115,4 +74,46 @@ describe('createGameCard', () => {
const button = card.querySelector('button');
expect(button.getAttribute('aria-label')).toBeTruthy();
});

it('displays high score for Fast Piggie when provided', () => {
const manifest = {
id: 'fast-piggie',
name: 'Fast Piggie',
description: 'Test desc',
thumbnail: '/images/test.png',
};
const progress = { highScore: 42 };
const card = createGameCard(manifest, progress);
const scoreElem = card.querySelector('.game-high-score');
expect(scoreElem).not.toBeNull();
expect(scoreElem.textContent).toContain('42');
});

it('displays all-time best threshold for Field of View when provided', () => {
const manifest = {
id: 'field-of-view',
name: 'Field of View',
description: 'Test desc',
thumbnail: '/images/test.png',
};
const progress = { bestThresholdMs: 84.2 };
const card = createGameCard(manifest, progress);
const scoreElem = card.querySelector('.game-high-score');
expect(scoreElem).not.toBeNull();
expect(scoreElem.textContent).toContain('All-time Best Threshold: 84.2ms');
});

it('shows no-data text for Field of View when no best threshold exists', () => {
const manifest = {
id: 'field-of-view',
name: 'Field of View',
description: 'Test desc',
thumbnail: '/images/test.png',
};
const progress = {};
const card = createGameCard(manifest, progress);
const scoreElem = card.querySelector('.game-high-score');
expect(scoreElem).not.toBeNull();
expect(scoreElem.textContent).toContain('No data yet');
});
});
83 changes: 83 additions & 0 deletions app/games/field-of-view/audio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* audio.js - Audio feedback for the Field of View game.
*
* Manages a shared AudioContext for the session and plays short tones on
* each trial outcome. Creating a single context and reusing it avoids
* per-trial instantiation overhead and browser auto-play limits.
*
* @file Field of View audio feedback helpers.
*/

/** @type {AudioContext|null} Shared audio context reused across trials. */
let _audioCtx = null;

/**
* Return the shared AudioContext, creating it on first use.
* Returns null when the Web Audio API is unavailable.
*
* @returns {AudioContext|null}
*/
export function getAudioContext() {
if (_audioCtx && _audioCtx.state !== 'closed') {
return _audioCtx;
}
const AudioCtx = (typeof AudioContext !== 'undefined' && AudioContext)
|| (typeof window !== 'undefined' && window.webkitAudioContext)
|| null;
if (!AudioCtx) return null;
try {
_audioCtx = new AudioCtx();
} catch {
return null;
}
return _audioCtx;
}

/**
* Play a short positive/negative sound cue for trial feedback.
*
* Reuses the shared AudioContext created by {@link getAudioContext}.
* Call this after the first user-gesture to satisfy browser autoplay policy.
*
* @param {boolean} isSuccess
*/
export function playFeedbackSound(isSuccess) {
const ctx = getAudioContext();
if (!ctx) return;

try {
if (ctx.state === 'suspended') {
ctx.resume().catch(() => { });
}

const now = ctx.currentTime;

const toneA = ctx.createOscillator();
const gainA = ctx.createGain();
toneA.connect(gainA);
gainA.connect(ctx.destination);

toneA.type = 'sine';
toneA.frequency.setValueAtTime(isSuccess ? 740 : 220, now);
gainA.gain.setValueAtTime(0.0001, now);
gainA.gain.exponentialRampToValueAtTime(0.16, now + 0.02);
gainA.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);
toneA.start(now);
toneA.stop(now + 0.2);

const toneB = ctx.createOscillator();
const gainB = ctx.createGain();
toneB.connect(gainB);
gainB.connect(ctx.destination);

toneB.type = isSuccess ? 'triangle' : 'sawtooth';
toneB.frequency.setValueAtTime(isSuccess ? 940 : 170, now + 0.12);
gainB.gain.setValueAtTime(0.0001, now + 0.12);
gainB.gain.exponentialRampToValueAtTime(0.12, now + 0.15);
gainB.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
toneB.start(now + 0.12);
toneB.stop(now + 0.32);
} catch {
// Ignore audio errors in unsupported environments.
}
}
18 changes: 15 additions & 3 deletions app/games/field-of-view/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,21 @@ export function initGame(options = {}) {
startTimeMs = null;
thresholdHistory = [];

downAfterSuccesses = options.downAfterSuccesses === 3 ? 3 : DEFAULT_DOWN_AFTER_SUCCESSES;
stepUpMs = options.stepUpMs || DEFAULT_STEP_UP_MS;
stepDownMs = options.stepDownMs || DEFAULT_STEP_DOWN_MS;
// Configure staircase success threshold: use a validated integer, defaulting when invalid.
if (Number.isFinite(options.downAfterSuccesses)) {
downAfterSuccesses = Math.max(1, Math.round(options.downAfterSuccesses));
} else {
downAfterSuccesses = DEFAULT_DOWN_AFTER_SUCCESSES;
}

// Configure step sizes with numeric validation and clamping to keep pacing reasonable.
const rawStepUp = Number.isFinite(options.stepUpMs) ? options.stepUpMs : DEFAULT_STEP_UP_MS;
stepUpMs = clamp(rawStepUp, MIN_SOA_MS, MAX_SOA_MS);

const rawStepDown = Number.isFinite(options.stepDownMs)
? options.stepDownMs
: DEFAULT_STEP_DOWN_MS;
stepDownMs = clamp(rawStepDown, MIN_SOA_MS, MAX_SOA_MS);

const desiredBuffer = Number(options.accuracyBufferSize || DEFAULT_ACCURACY_BUFFER_SIZE);
accuracyBufferSize = clamp(Math.round(desiredBuffer), 3, 5);
Expand Down
Loading