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
29 changes: 24 additions & 5 deletions app/games/fast-piggie/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -72,6 +74,7 @@ export function initGame() {
consecutiveCorrect = 0;
consecutiveWrong = 0;
speedHistory = [];
lowestRoundDisplayMs = null;
}

/**
Expand Down Expand Up @@ -110,6 +113,7 @@ export function stopGame() {
mostRounds,
mostGuineaPigs,
topSpeedMs,
lowestRoundDisplayMs,
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}

Expand All @@ -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;
Expand All @@ -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,
};
}

/**
Expand Down
38 changes: 30 additions & 8 deletions app/games/fast-piggie/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}
}

/**
Expand Down Expand Up @@ -442,6 +463,7 @@ function _resolveRound(wedge) {
outlierWedgeIndex,
slotAssignment,
imageCount,
displayDurationMs,
} = _currentRound;
const { width, height } = _canvas;
const correctWedgeIndex = _getCorrectWedgeIndex(_currentRound);
Expand All @@ -460,7 +482,7 @@ function _resolveRound(wedge) {
}

if (correct) {
game.addScore(imageCount, answerSpeedMs);
game.addScore(imageCount, answerSpeedMs, displayDurationMs);
highlightWedge(
_ctx,
width,
Expand Down Expand Up @@ -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.';
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 38 additions & 1 deletion app/games/fast-piggie/tests/game.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down
51 changes: 51 additions & 0 deletions app/games/fast-piggie/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jest.unstable_mockModule('../game.js', () => ({
mostRounds: 5,
mostGuineaPigs: 3,
topSpeedMs: 1000,
lowestRoundDisplayMs: 50,
})),
}));

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()', () => {
Expand Down
Loading