diff --git a/app/games/fast-piggie/game.js b/app/games/fast-piggie/game.js index d371635..aa3bdf4 100644 --- a/app/games/fast-piggie/game.js +++ b/app/games/fast-piggie/game.js @@ -49,7 +49,9 @@ let consecutiveWrong = 0; let maxScore = 0; let mostRounds = 0; let mostGuineaPigs = 0; -let topSpeedMs = null; // Lower is better +let topSpeedMs = null; // Lower is better — minimum answer response time (ms) +/** Lower is better — minimum display duration actually used in any round. */ +let lowestRoundDisplayMs = null; /** * Session history of display durations in ms, one entry per round. @@ -72,6 +74,7 @@ export function initGame() { consecutiveCorrect = 0; consecutiveWrong = 0; speedHistory = []; + lowestRoundDisplayMs = null; } /** @@ -110,6 +113,7 @@ export function stopGame() { mostRounds, mostGuineaPigs, topSpeedMs, + lowestRoundDisplayMs, }; } @@ -207,8 +211,9 @@ export function calculateWedgeIndex(clickX, clickY, centerX, centerY, radius, we * Also resets the consecutive-wrong counter. * @param {number} [guineaPigsThisRound] - Number of guinea pigs displayed this round. * @param {number} [answerSpeedMs] - Time in ms to answer this round (if tracked). + * @param {number} [displayDurationMs] - Display duration (ms) of this round. */ -export function addScore(guineaPigsThisRound, answerSpeedMs) { +export function addScore(guineaPigsThisRound, answerSpeedMs, displayDurationMs) { score += 1; roundsPlayed += 1; consecutiveCorrect += 1; @@ -237,6 +242,11 @@ export function addScore(guineaPigsThisRound, answerSpeedMs) { if (typeof answerSpeedMs === 'number' && (topSpeedMs === null || answerSpeedMs < topSpeedMs)) { topSpeedMs = answerSpeedMs; } + // Track the minimum display duration actually used in any round + if (typeof displayDurationMs === 'number' + && (lowestRoundDisplayMs === null || displayDurationMs < lowestRoundDisplayMs)) { + lowestRoundDisplayMs = displayDurationMs; + } speedHistory.push(calculateDisplayDuration(speedLevel)); } @@ -245,8 +255,9 @@ export function addScore(guineaPigsThisRound, answerSpeedMs) { * Resets consecutive correct count. After 3 consecutive misses the level * decreases by 2 (minimum 0), making the next round easier. * @param {number} [guineaPigsThisRound] - Number of guinea pigs displayed this round. + * @param {number} [displayDurationMs] - Display duration (ms) of this round. */ -export function addMiss(guineaPigsThisRound) { +export function addMiss(guineaPigsThisRound, displayDurationMs) { roundsPlayed += 1; consecutiveCorrect = 0; consecutiveWrong += 1; @@ -260,15 +271,23 @@ export function addMiss(guineaPigsThisRound) { if (typeof guineaPigsThisRound === 'number' && guineaPigsThisRound > mostGuineaPigs) { mostGuineaPigs = guineaPigsThisRound; } + // Track the minimum display duration actually used in any round + if (typeof displayDurationMs === 'number' + && (lowestRoundDisplayMs === null || displayDurationMs < lowestRoundDisplayMs)) { + lowestRoundDisplayMs = displayDurationMs; + } speedHistory.push(calculateDisplayDuration(speedLevel)); } /** * Get the best stats for this session. - * @returns {object} Best stats: { maxScore, mostRounds, mostGuineaPigs, topSpeedMs } + * @returns {object} Best stats: { maxScore, mostRounds, mostGuineaPigs, topSpeedMs, + * lowestRoundDisplayMs } */ export function getBestStats() { - return { maxScore, mostRounds, mostGuineaPigs, topSpeedMs }; + return { + maxScore, mostRounds, mostGuineaPigs, topSpeedMs, lowestRoundDisplayMs, + }; } /** diff --git a/app/games/fast-piggie/index.js b/app/games/fast-piggie/index.js index e81e2fe..cbe4db0 100644 --- a/app/games/fast-piggie/index.js +++ b/app/games/fast-piggie/index.js @@ -107,6 +107,9 @@ export function drawBoard( return base * 0.24; // increased for more staggering }); + // Capture the outlier's draw parameters to render it last (on top of distractors). + let outlierDraw = null; + for (let i = 0; i < wedgeCount; i += 1) { const startAngle = -Math.PI / 2 + i * angleStep; const endAngle = startAngle + angleStep; @@ -137,14 +140,32 @@ export function drawBoard( const imgCy = cy + Math.sin(midAngle) * imgRadius; const drawH = radius * 0.35; const drawW = drawH * (entry.sw / entry.sh); - ctx.drawImage( - entry.image, entry.sx, 0, entry.sw, entry.sh, - imgCx - drawW / 2, imgCy - drawH / 2, drawW, drawH, - ); + if (imageIdx === outlierIndex) { + // Save outlier draw params — it will be rendered last to appear on top. + outlierDraw = { + entry, imgCx, imgCy, drawH, drawW, + }; + } else { + ctx.drawImage( + entry.image, entry.sx, 0, entry.sw, entry.sh, + imgCx - drawW / 2, imgCy - drawH / 2, drawW, drawH, + ); + } } } } } + + // Draw the outlier (target) image last so it always appears on top of all distractors. + if (outlierDraw) { + const { + entry, imgCx, imgCy, drawH, drawW, + } = outlierDraw; + ctx.drawImage( + entry.image, entry.sx, 0, entry.sw, entry.sh, + imgCx - drawW / 2, imgCy - drawH / 2, drawW, drawH, + ); + } } /** @@ -442,6 +463,7 @@ function _resolveRound(wedge) { outlierWedgeIndex, slotAssignment, imageCount, + displayDurationMs, } = _currentRound; const { width, height } = _canvas; const correctWedgeIndex = _getCorrectWedgeIndex(_currentRound); @@ -460,7 +482,7 @@ function _resolveRound(wedge) { } if (correct) { - game.addScore(imageCount, answerSpeedMs); + game.addScore(imageCount, answerSpeedMs, displayDurationMs); highlightWedge( _ctx, width, @@ -492,7 +514,7 @@ function _resolveRound(wedge) { 'rgba(255, 193, 7, 0.65)', ); } - game.addMiss(imageCount); + game.addMiss(imageCount, displayDurationMs); playFailureSound(); _triggerFlash('wrong'); _feedbackEl.textContent = 'Not quite — the different piggie is highlighted.'; @@ -608,8 +630,8 @@ export default { score: result.score, sessionDurationMs, level: typeof bestStats.maxScore === 'number' ? bestStats.maxScore : undefined, - lowestDisplayTime: typeof bestStats.topSpeedMs === 'number' - ? bestStats.topSpeedMs + lowestDisplayTime: typeof bestStats.lowestRoundDisplayMs === 'number' + ? bestStats.lowestRoundDisplayMs : undefined, }, (prev) => ({ maxPiggies: Math.max( diff --git a/app/games/fast-piggie/tests/game.test.js b/app/games/fast-piggie/tests/game.test.js index 17a5d7a..05c8846 100644 --- a/app/games/fast-piggie/tests/game.test.js +++ b/app/games/fast-piggie/tests/game.test.js @@ -554,12 +554,14 @@ describe('getSpeedLevel()', () => { }); describe('getBestStats()', () => { - it('returns an object with maxScore, mostRounds, mostGuineaPigs, topSpeedMs', () => { + it('returns an object with maxScore, mostRounds, mostGuineaPigs, topSpeedMs,' + + ' lowestRoundDisplayMs', () => { const stats = getBestStats(); expect(stats).toHaveProperty('maxScore'); expect(stats).toHaveProperty('mostRounds'); expect(stats).toHaveProperty('mostGuineaPigs'); expect(stats).toHaveProperty('topSpeedMs'); + expect(stats).toHaveProperty('lowestRoundDisplayMs'); }); it('maxScore reflects the highest score achieved since module load', () => { @@ -599,6 +601,41 @@ describe('getBestStats()', () => { const stats = getBestStats(); expect(stats.mostRounds).toBeGreaterThanOrEqual(2); }); + + it('lowestRoundDisplayMs updates when addScore is called with a displayDurationMs value', () => { + addScore(3, 500, 100); + const stats = getBestStats(); + expect(stats.lowestRoundDisplayMs).toBeLessThanOrEqual(100); + }); + + it('lowestRoundDisplayMs tracks the minimum displayDurationMs across multiple addScore calls', + () => { + addScore(3, 500, 200); + addScore(3, 300, 50); + addScore(3, 400, 150); + const stats = getBestStats(); + expect(stats.lowestRoundDisplayMs).toBe(50); + }); + + it('lowestRoundDisplayMs updates when addMiss is called with a displayDurationMs value', () => { + addMiss(3, 75); + const stats = getBestStats(); + expect(stats.lowestRoundDisplayMs).toBeLessThanOrEqual(75); + }); + + it('lowestRoundDisplayMs is null when no displayDurationMs has been recorded', () => { + const stats = getBestStats(); + expect( + stats.lowestRoundDisplayMs === null || typeof stats.lowestRoundDisplayMs === 'number', + ).toBe(true); + }); + + it('lowestRoundDisplayMs is included in stopGame() return value', () => { + startGame(); + addScore(3, 200, 80); + const result = stopGame(); + expect(result).toHaveProperty('lowestRoundDisplayMs'); + }); }); // ── getSpeedHistory ─────────────────────────────────────────────────────────── diff --git a/app/games/fast-piggie/tests/index.test.js b/app/games/fast-piggie/tests/index.test.js index 24844ab..93e70d9 100644 --- a/app/games/fast-piggie/tests/index.test.js +++ b/app/games/fast-piggie/tests/index.test.js @@ -49,6 +49,7 @@ jest.unstable_mockModule('../game.js', () => ({ mostRounds: 5, mostGuineaPigs: 3, topSpeedMs: 1000, + lowestRoundDisplayMs: 50, })), })); @@ -478,6 +479,13 @@ describe('_handleClick — correct answer (calculateWedgeIndex returns 2)', () = expect(game.addScore).toHaveBeenCalled(); }); + it('calls game.addScore() with displayDurationMs as third argument', () => { + fireClick(); + const callArgs = game.addScore.mock.calls[0]; + // Third argument is displayDurationMs from the current round (2000ms from mock) + expect(callArgs[2]).toBe(2000); + }); + it('calls playSuccessSound (createOscillator called on audio context)', () => { mockAudioCtx.createOscillator.mockClear(); fireClick(); @@ -527,6 +535,13 @@ describe('_handleClick — wrong answer (checkAnswer returns false)', () => { expect(game.addMiss).toHaveBeenCalled(); }); + it('calls game.addMiss() with displayDurationMs as second argument', () => { + fireClick(); + const callArgs = game.addMiss.mock.calls[0]; + // Second argument is displayDurationMs from the current round (2000ms from mock) + expect(callArgs[1]).toBe(2000); + }); + it('calls playFailureSound (createOscillator called on audio context)', () => { mockAudioCtx.createOscillator.mockClear(); fireClick(); @@ -699,6 +714,42 @@ describe('drawBoard()', () => { // and that it's not always 0,1,2) mathRandomSpy.mockRestore(); }); + + it('draws the outlier image last so it appears on top of distractors', () => { + const normalImgEl = { id: 'normal', naturalWidth: 768, naturalHeight: 512 }; + const outlierImgEl = { id: 'outlier', naturalWidth: 768, naturalHeight: 512 }; + const fakeWrappers = [ + { image: normalImgEl, sx: 0, sw: 384, sh: 512 }, + { image: outlierImgEl, sx: 0, sw: 384, sh: 512 }, + ]; + ctx2d.drawImage.mockClear(); + // outlierIndex=0, wedgeCount=4, imageCount=4: outlier is wedge 0 + drawBoard(ctx2d, 500, 500, 4, 4, fakeWrappers, 0, true); + const calls = ctx2d.drawImage.mock.calls; + // The last drawImage call should use the outlier image element + const lastCall = calls[calls.length - 1]; + expect(lastCall[0]).toBe(outlierImgEl); + }); + + it('draws all non-outlier images before the outlier image', () => { + const normalImgEl = { id: 'normal', naturalWidth: 768, naturalHeight: 512 }; + const outlierImgEl = { id: 'outlier', naturalWidth: 768, naturalHeight: 512 }; + const fakeWrappers = [ + { image: normalImgEl, sx: 0, sw: 384, sh: 512 }, + { image: outlierImgEl, sx: 0, sw: 384, sh: 512 }, + ]; + ctx2d.drawImage.mockClear(); + // outlierIndex=2, wedgeCount=4, imageCount=4: 3 normals + 1 outlier + drawBoard(ctx2d, 500, 500, 4, 4, fakeWrappers, 2, true); + const calls = ctx2d.drawImage.mock.calls; + expect(calls).toHaveLength(4); + // The last call must be the outlier + expect(calls[calls.length - 1][0]).toBe(outlierImgEl); + // All calls before last must be normal images + calls.slice(0, -1).forEach((call) => { + expect(call[0]).toBe(normalImgEl); + }); + }); }); describe('highlightWedge()', () => {