Skip to content

Commit 13f022e

Browse files
committed
[v.1.1.0] Tweaks & shortcuts
1 parent 292cc7d commit 13f022e

8 files changed

Lines changed: 678 additions & 26 deletions

File tree

src/App.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { motion } from 'motion/react';
22
import { useCallback, useEffect, useRef, useState } from 'react';
33
import { GameBoard } from './components/GameBoard';
4+
import { InfoModal } from './components/InfoModal';
45
import { ScoreBoard } from './components/ScoreBoard';
56
import { Toast } from './components/Toast';
67
import { initAudio, playGameOverSound, playLineSound, playSquareSound } from './utils/audio';
@@ -46,6 +47,7 @@ export default function App() {
4647
isVisible: false,
4748
});
4849
const [lastMoveTime, setLastMoveTime] = useState<number>(() => Date.now());
50+
const [isInfoOpen, setIsInfoOpen] = useState(false);
4951

5052
const isProcessingRef = useRef(false);
5153

@@ -171,16 +173,48 @@ export default function App() {
171173
}
172174
}, [turn, gameOver, lines, gridSize]);
173175

176+
// Global keyboard shortcuts
177+
useEffect(() => {
178+
const handleGlobalKeyDown = (e: KeyboardEvent) => {
179+
// Don't trigger if user is typing in an input (not currently applicable but good practice)
180+
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
181+
182+
const key = e.key.toLowerCase();
183+
184+
if (key === 'r') {
185+
resetGame();
186+
} else if (key === 't') {
187+
const themeNames = Object.keys(THEMES) as ThemeName[];
188+
const currentIndex = themeNames.indexOf(themeName);
189+
const nextIndex = (currentIndex + 1) % themeNames.length;
190+
setThemeName(themeNames[nextIndex]);
191+
} else if (key >= '1' && key <= '6') {
192+
const levelIndex = parseInt(key, 10) - 1;
193+
if (levelIndex < LEVELS.length) {
194+
const selectedSize = LEVELS[levelIndex];
195+
if (selectedSize <= maxUnlocked) {
196+
resetGame(selectedSize);
197+
}
198+
}
199+
} else if (key === '?' || key === 'h') {
200+
setIsInfoOpen((prev) => !prev);
201+
}
202+
};
203+
204+
window.addEventListener('keydown', handleGlobalKeyDown);
205+
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
206+
}, [resetGame, themeName, maxUnlocked]);
207+
174208
// Player move warning timer
175209
useEffect(() => {
176210
if (turn === 'P' && !gameOver) {
177-
const interval = setInterval(() => {
178-
const now = Date.now();
179-
if (now - lastMoveTime >= 30000 && !toast.isVisible) {
180-
setToast({ message: "Still there? It's your turn!", isVisible: true });
211+
const timer = setInterval(() => {
212+
const timeSinceLastMove = Date.now() - lastMoveTime;
213+
if (timeSinceLastMove >= 30000 && !toast.isVisible) {
214+
setToast({ message: 'Are you still there? Your turn!', isVisible: true });
181215
}
182216
}, 1000);
183-
return () => clearInterval(interval);
217+
return () => clearInterval(timer);
184218
}
185219
}, [turn, gameOver, lastMoveTime, toast.isVisible]);
186220

@@ -202,6 +236,7 @@ export default function App() {
202236
theme={theme}
203237
themeName={themeName}
204238
onThemeChange={setThemeName}
239+
onInfoOpen={() => setIsInfoOpen(true)}
205240
/>
206241

207242
<div className="mt-4 flex aspect-square w-full max-w-[512px] items-center justify-center">
@@ -226,6 +261,8 @@ export default function App() {
226261
key={size}
227262
onClick={() => isUnlocked && resetGame(size)}
228263
disabled={!isUnlocked}
264+
aria-label={`Set grid size to ${size}x${size}${!isUnlocked ? ' (Locked)' : ''}`}
265+
aria-current={gridSize === size ? 'true' : 'false'}
229266
className={`relative transition-all duration-300 ${
230267
!isUnlocked
231268
? 'cursor-not-allowed opacity-30'
@@ -246,6 +283,7 @@ export default function App() {
246283
onClose={() => setToast((prev) => ({ ...prev, isVisible: false }))}
247284
theme={theme}
248285
/>
286+
<InfoModal isOpen={isInfoOpen} onClose={() => setIsInfoOpen(false)} theme={theme} />
249287
</div>
250288
);
251289
}

src/components/GameBoard.test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { THEMES } from '../utils/theme';
5+
import { GameBoard } from './GameBoard';
6+
7+
// Mock motion/react to avoid animation issues
8+
vi.mock('motion/react', () => ({
9+
motion: {
10+
div: ({
11+
children,
12+
className,
13+
style,
14+
onClick,
15+
...props
16+
}: React.HTMLAttributes<HTMLDivElement>) => (
17+
<div className={className} style={style} onClick={onClick} {...props}>
18+
{children}
19+
</div>
20+
),
21+
},
22+
}));
23+
24+
describe('GameBoard', () => {
25+
const defaultProps = {
26+
gridSize: 3,
27+
lines: {},
28+
squares: {},
29+
onLineClick: vi.fn(),
30+
turn: 'P' as const,
31+
theme: THEMES.sepia,
32+
lastLineId: null,
33+
};
34+
35+
it('renders correct number of dots', () => {
36+
const { container } = render(<GameBoard {...defaultProps} />);
37+
// For gridSize 3, there should be 3x3 = 9 dots
38+
// Using a more specific selector might be better if there are other rounded-full elements
39+
// In GameBoard.tsx, dots use ${theme.dot} which is 'bg-[#d3c5a3]' in sepia
40+
const sepiaDots = container.querySelectorAll('.bg-\\[\\#d3c5a3\\]');
41+
expect(sepiaDots.length).toBe(9);
42+
});
43+
44+
it('renders horizontal and vertical lines', () => {
45+
const { container } = render(<GameBoard {...defaultProps} />);
46+
// Grid 3x3 has 2 horizontal lines per row (3 rows) and 2 vertical lines per column (3 columns)
47+
// Actually, it's (gridSize * (gridSize - 1)) * 2 lines total
48+
// Horizontal: 3 rows * 2 lines each = 6
49+
// Vertical: 3 columns * 2 lines each = 6
50+
// Total lines: 12
51+
52+
// Each line is a div with group relative flex cursor-pointer
53+
const lines = container.querySelectorAll('.cursor-pointer');
54+
expect(lines.length).toBe(12);
55+
});
56+
57+
it('calls onLineClick when an available line is clicked', () => {
58+
render(<GameBoard {...defaultProps} />);
59+
// Find a line (they are the cursor-pointer divs with role="button")
60+
const lines = screen.getAllByRole('button', { name: /line/i });
61+
fireEvent.click(lines[0]);
62+
expect(defaultProps.onLineClick).toHaveBeenCalled();
63+
});
64+
65+
it('displays owned lines with correct player color', () => {
66+
const linesState = { 'h-0-0': 'P' as const, 'v-0-0': 'C' as const };
67+
const { container } = render(<GameBoard {...defaultProps} lines={linesState} />);
68+
69+
// P1 line (player) in sepia is bg-[#8c3a3a]
70+
const p1Line = container.querySelector('.bg-\\[\\#8c3a3a\\]');
71+
expect(p1Line).toBeInTheDocument();
72+
73+
// P2 line (computer) in sepia is bg-[#3a6b58]
74+
const p2Line = container.querySelector('.bg-\\[\\#3a6b58\\]');
75+
expect(p2Line).toBeInTheDocument();
76+
});
77+
78+
it('displays completed squares with correct player color', () => {
79+
const squaresState = { 's-0-0': 'P' as const };
80+
const { container } = render(<GameBoard {...defaultProps} squares={squaresState} />);
81+
82+
// P1 square in sepia is bg-[#8c3a3a]/30
83+
const p1Square = container.querySelector('.bg-\\[\\#8c3a3a\\]\\/30');
84+
expect(p1Square).toBeInTheDocument();
85+
});
86+
87+
it('marks the last line with a pulse effect', () => {
88+
const linesState = { 'h-0-0': 'P' as const };
89+
const { container } = render(
90+
<GameBoard {...defaultProps} lines={linesState} lastLineId="h-0-0" />,
91+
);
92+
93+
// Pulse effect in sepia is bg-[#8c3a3a] with blur-[3px]
94+
const pulse = container.querySelector('.blur-\\[3px\\]');
95+
expect(pulse).toBeInTheDocument();
96+
});
97+
});

src/components/GameBoard.tsx

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,65 @@ export const GameBoard: React.FC<GameBoardProps> = ({
2121
theme,
2222
lastLineId,
2323
}) => {
24+
const boardRef = React.useRef<HTMLDivElement>(null);
25+
2426
const dotClass = gridSize <= 12 ? 'w-2 h-2' : 'w-1.5 h-1.5';
2527
const hLineClass = gridSize <= 12 ? 'h-2' : 'h-1.5';
2628
const vLineClass = gridSize <= 12 ? 'w-2' : 'w-1.5';
2729

30+
const handleKeyDown = (e: React.KeyboardEvent, lineId: string) => {
31+
const [type, rStr, cStr] = lineId.split('-');
32+
const r = parseInt(rStr, 10);
33+
const c = parseInt(cStr, 10);
34+
35+
let nextId: string | null = null;
36+
37+
switch (e.key) {
38+
case 'ArrowUp':
39+
e.preventDefault();
40+
if (type === 'h') {
41+
if (r > 0) nextId = `v-${r - 1}-${c}`;
42+
} else {
43+
nextId = `h-${r}-${c}`;
44+
}
45+
break;
46+
case 'ArrowDown':
47+
e.preventDefault();
48+
if (type === 'h') {
49+
nextId = `v-${r}-${c}`;
50+
} else {
51+
if (r < gridSize - 1) nextId = `h-${r + 1}-${c}`;
52+
}
53+
break;
54+
case 'ArrowLeft':
55+
e.preventDefault();
56+
if (type === 'v') {
57+
if (c > 0) nextId = `h-${r}-${c - 1}`;
58+
} else {
59+
nextId = `v-${r}-${c}`;
60+
}
61+
break;
62+
case 'ArrowRight':
63+
e.preventDefault();
64+
if (type === 'v') {
65+
nextId = `h-${r}-${c}`;
66+
} else {
67+
if (c < gridSize - 1) nextId = `v-${r}-${c + 1}`;
68+
}
69+
break;
70+
case 'Enter':
71+
case ' ':
72+
e.preventDefault();
73+
if (!lines[lineId]) onLineClick(lineId);
74+
break;
75+
}
76+
77+
if (nextId) {
78+
const nextEl = boardRef.current?.querySelector(`[data-line-id="${nextId}"]`) as HTMLElement;
79+
nextEl?.focus();
80+
}
81+
};
82+
2883
const renderDotsAndLines = () => {
2984
const elements = [];
3085

@@ -51,15 +106,21 @@ export const GameBoard: React.FC<GameBoardProps> = ({
51106
elements.push(
52107
<div
53108
key={lineId}
54-
className={`${hLineClass} group relative flex w-full cursor-pointer items-center justify-center`}
109+
data-line-id={lineId}
110+
role="button"
111+
tabIndex={0}
112+
aria-label={`Horizontal line at row ${r + 1}, column ${c + 1}`}
113+
aria-pressed={!!owner}
114+
className={`${hLineClass} group relative flex w-full cursor-pointer items-center justify-center rounded-sm outline-none`}
55115
onClick={() => !owner && onLineClick(lineId)}
116+
onKeyDown={(e) => handleKeyDown(e, lineId)}
56117
>
57118
{/* Invisible expanded hit area */}
58119
<div className="absolute -top-3 right-0 -bottom-3 left-0 z-10" />
59120

60121
{!owner && (
61122
<div
62-
className={`absolute h-[2px] w-full bg-transparent ${theme.hoverLine} transition-colors duration-300`}
123+
className={`absolute h-[2px] w-full bg-transparent ${theme.hoverLine} transition-colors duration-300 group-focus:scale-x-110 group-focus:bg-current group-focus:opacity-40`}
63124
/>
64125
)}
65126
{owner && (
@@ -107,15 +168,21 @@ export const GameBoard: React.FC<GameBoardProps> = ({
107168
elements.push(
108169
<div
109170
key={lineId}
110-
className={`${vLineClass} group relative flex h-full cursor-pointer items-center justify-center`}
171+
data-line-id={lineId}
172+
role="button"
173+
tabIndex={0}
174+
aria-label={`Vertical line at row ${r + 1}, column ${c + 1}`}
175+
aria-pressed={!!owner}
176+
className={`${vLineClass} group relative flex h-full cursor-pointer items-center justify-center rounded-sm outline-none`}
111177
onClick={() => !owner && onLineClick(lineId)}
178+
onKeyDown={(e) => handleKeyDown(e, lineId)}
112179
>
113180
{/* Invisible expanded hit area */}
114181
<div className="absolute top-0 -right-3 bottom-0 -left-3 z-10" />
115182

116183
{!owner && (
117184
<div
118-
className={`absolute h-full w-[2px] bg-transparent ${theme.hoverLine} transition-colors duration-300`}
185+
className={`absolute h-full w-[2px] bg-transparent ${theme.hoverLine} transition-colors duration-300 group-focus:scale-y-110 group-focus:bg-current group-focus:opacity-40`}
119186
/>
120187
)}
121188
{owner && (
@@ -181,6 +248,9 @@ export const GameBoard: React.FC<GameBoardProps> = ({
181248

182249
return (
183250
<div
251+
ref={boardRef}
252+
role="grid"
253+
aria-label="Dots and Boxes Game Board"
184254
className="grid h-full w-full gap-0"
185255
style={{
186256
gridTemplateColumns: `repeat(${gridSize - 1}, max-content 1fr) max-content`,

0 commit comments

Comments
 (0)