351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import { useState, useRef, useCallback, useEffect, memo, useMemo } from 'react';
|
|
import { colors, radius, shadows } from '../styles/theme';
|
|
|
|
interface CellProps {
|
|
letter: string;
|
|
size: number;
|
|
fontSize: number;
|
|
bg: string;
|
|
border: string;
|
|
textColor: string;
|
|
boxShadow: string;
|
|
animation: string | undefined;
|
|
}
|
|
|
|
const Cell = memo<CellProps>(({ letter, size, fontSize, bg, border, textColor, boxShadow, animation }) => {
|
|
return (
|
|
<div
|
|
style={{
|
|
width: `${size}px`,
|
|
height: `${size}px`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: radius.sm,
|
|
background: bg,
|
|
border: `2px solid ${border}`,
|
|
color: textColor,
|
|
fontSize: `${fontSize}px`,
|
|
fontWeight: bg !== colors.cellDefault ? 700 : 500,
|
|
fontFamily: 'Inter, sans-serif',
|
|
transition: animation ? 'all 0.3s ease' : 'all 0.15s ease',
|
|
pointerEvents: 'none',
|
|
boxShadow: boxShadow,
|
|
animation: animation || undefined,
|
|
willChange: bg !== colors.cellDefault ? 'transform, box-shadow' : 'auto',
|
|
}}
|
|
>
|
|
{letter}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
Cell.displayName = 'Cell';
|
|
|
|
interface LetterGridProps {
|
|
letters: string[][];
|
|
gridSize: number;
|
|
onWordSelect: (cells: [number, number][]) => void;
|
|
foundWords: string[];
|
|
firstLetterCell: [number, number] | null;
|
|
wordPaths: Record<string, [number, number][]>;
|
|
newFoundWord?: string | null;
|
|
}
|
|
|
|
const LetterGrid: React.FC<LetterGridProps> = ({
|
|
letters,
|
|
gridSize,
|
|
onWordSelect,
|
|
foundWords,
|
|
firstLetterCell,
|
|
wordPaths,
|
|
newFoundWord,
|
|
}) => {
|
|
const [selectedCells, setSelectedCells] = useState<[number, number][]>([]);
|
|
const [isSelecting, setIsSelecting] = useState(false);
|
|
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
|
|
const processingRef = useRef(false);
|
|
const touchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastProcessedCellsRef = useRef<string>('');
|
|
|
|
// Адаптивный размер клетки
|
|
const maxGridWidth = Math.min(windowWidth - 32, 400);
|
|
const gapSize = gridSize <= 4 ? 5 : gridSize <= 6 ? 4 : 3;
|
|
const totalGap = gapSize * (gridSize - 1);
|
|
const cellSize = Math.floor((maxGridWidth - totalGap) / gridSize);
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
setWindowWidth(window.innerWidth);
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
if (touchTimeoutRef.current) {
|
|
clearTimeout(touchTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
processingRef.current = false;
|
|
lastProcessedCellsRef.current = '';
|
|
if (touchTimeoutRef.current) {
|
|
clearTimeout(touchTimeoutRef.current);
|
|
touchTimeoutRef.current = null;
|
|
}
|
|
}, [letters]);
|
|
|
|
// useMemo для мгновенного обновления (без задержки useEffect)
|
|
const foundCells = useMemo(() => {
|
|
const fc = new Set<string>();
|
|
foundWords.forEach(word => {
|
|
const path = wordPaths[word];
|
|
if (path) {
|
|
path.forEach(([r, c]) => {
|
|
fc.add(`${r},${c}`);
|
|
});
|
|
}
|
|
});
|
|
return fc;
|
|
}, [foundWords, wordPaths]);
|
|
|
|
const newFoundCells = useMemo(() => {
|
|
const nfc = new Set<string>();
|
|
if (newFoundWord) {
|
|
const path = wordPaths[newFoundWord];
|
|
if (path) {
|
|
path.forEach(([r, c]) => {
|
|
nfc.add(`${r},${c}`);
|
|
});
|
|
}
|
|
}
|
|
return nfc;
|
|
}, [newFoundWord, wordPaths]);
|
|
|
|
const selectedSet = useMemo(() => {
|
|
const ss = new Set<string>();
|
|
selectedCells.forEach(([r, c]) => ss.add(`${r},${c}`));
|
|
return ss;
|
|
}, [selectedCells]);
|
|
|
|
const isAdjacent = (cell1: [number, number], cell2: [number, number]): boolean => {
|
|
const [r1, c1] = cell1;
|
|
const [r2, c2] = cell2;
|
|
return (Math.abs(r1 - r2) === 1 && c1 === c2) || (Math.abs(c1 - c2) === 1 && r1 === r2);
|
|
};
|
|
|
|
const getCellFromTouch = useCallback((clientX: number, clientY: number): [number, number] | null => {
|
|
if (!gridRef.current) return null;
|
|
const rect = gridRef.current.getBoundingClientRect();
|
|
const x = clientX - rect.left;
|
|
const y = clientY - rect.top;
|
|
const col = Math.floor(x / (cellSize + gapSize));
|
|
const row = Math.floor(y / (cellSize + gapSize));
|
|
if (row >= 0 && row < gridSize && col >= 0 && col < gridSize) {
|
|
return [row, col];
|
|
}
|
|
return null;
|
|
}, [gridSize, cellSize, gapSize]);
|
|
|
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (processingRef.current) return;
|
|
|
|
const touch = e.touches[0];
|
|
const cell = getCellFromTouch(touch.clientX, touch.clientY);
|
|
if (cell) {
|
|
const [row, col] = cell;
|
|
const key = `${row},${col}`;
|
|
if (!foundCells.has(key)) {
|
|
setIsSelecting(true);
|
|
setSelectedCells([cell]);
|
|
|
|
if (window.Telegram?.WebApp?.HapticFeedback) {
|
|
window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
|
|
}
|
|
}
|
|
}
|
|
}, [foundCells, getCellFromTouch]);
|
|
|
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
e.preventDefault();
|
|
if (!isSelecting || processingRef.current) return;
|
|
|
|
const touch = e.touches[0];
|
|
const cell = getCellFromTouch(touch.clientX, touch.clientY);
|
|
if (!cell) return;
|
|
|
|
const [row, col] = cell;
|
|
const key = `${row},${col}`;
|
|
if (foundCells.has(key)) return;
|
|
|
|
setSelectedCells(prev => {
|
|
const lastCell = prev[prev.length - 1];
|
|
if (!lastCell) return prev;
|
|
|
|
if (lastCell[0] === row && lastCell[1] === col) return prev;
|
|
|
|
if (!isAdjacent(lastCell, [row, col])) return prev;
|
|
|
|
const alreadySelected = prev.some(([r, c]) => r === row && c === col);
|
|
if (alreadySelected) {
|
|
const prevCell = prev[prev.length - 2];
|
|
if (prevCell && prevCell[0] === row && prevCell[1] === col) {
|
|
return prev.slice(0, -1);
|
|
}
|
|
return prev;
|
|
}
|
|
|
|
return [...prev, [row, col]];
|
|
});
|
|
}, [isSelecting, foundCells, getCellFromTouch]);
|
|
|
|
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!isSelecting || processingRef.current) {
|
|
setIsSelecting(false);
|
|
setSelectedCells([]);
|
|
return;
|
|
}
|
|
|
|
if (selectedCells.length > 1) {
|
|
const cellsKey = selectedCells.map(([r, c]) => `${r},${c}`).join('|');
|
|
|
|
if (cellsKey !== lastProcessedCellsRef.current) {
|
|
processingRef.current = true;
|
|
lastProcessedCellsRef.current = cellsKey;
|
|
|
|
onWordSelect(selectedCells);
|
|
|
|
if (touchTimeoutRef.current) {
|
|
clearTimeout(touchTimeoutRef.current);
|
|
}
|
|
touchTimeoutRef.current = setTimeout(() => {
|
|
processingRef.current = false;
|
|
touchTimeoutRef.current = null;
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
setIsSelecting(false);
|
|
setSelectedCells([]);
|
|
}, [isSelecting, selectedCells, onWordSelect]);
|
|
|
|
const flatLetters = Array.isArray(letters) ? letters.flat() : [];
|
|
|
|
if (flatLetters.length === 0 || flatLetters.length !== gridSize * gridSize) {
|
|
return (
|
|
<div style={{
|
|
padding: '40px',
|
|
textAlign: 'center',
|
|
color: colors.textSecondary,
|
|
fontFamily: 'Inter, sans-serif',
|
|
}}>
|
|
Loading grid...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fontSize = gridSize <= 4
|
|
? Math.max(cellSize * 0.42, 18)
|
|
: gridSize <= 6
|
|
? Math.max(cellSize * 0.4, 15)
|
|
: gridSize <= 8
|
|
? Math.max(cellSize * 0.38, 13)
|
|
: Math.max(cellSize * 0.35, 11);
|
|
|
|
return (
|
|
<div
|
|
ref={gridRef}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${gridSize}, ${cellSize}px)`,
|
|
gap: `${gapSize}px`,
|
|
userSelect: 'none',
|
|
WebkitUserSelect: 'none',
|
|
WebkitTouchCallout: 'none',
|
|
touchAction: 'none',
|
|
justifyContent: 'center',
|
|
margin: '0 auto',
|
|
padding: '8px',
|
|
}}
|
|
>
|
|
<style>{`
|
|
@keyframes cellPopIn {
|
|
0% { transform: scale(0.8); opacity: 0.5; }
|
|
50% { transform: scale(1.15); opacity: 1; }
|
|
100% { transform: scale(1); opacity: 1; }
|
|
}
|
|
@keyframes cellGlow {
|
|
0%, 100% { box-shadow: 0 0 8px rgba(76, 217, 100, 0.3); }
|
|
50% { box-shadow: 0 0 16px rgba(76, 217, 100, 0.6); }
|
|
}
|
|
@keyframes firstLetterPulse {
|
|
0%, 100% { box-shadow: 0 0 6px rgba(255, 215, 0, 0.4); }
|
|
50% { box-shadow: 0 0 14px rgba(255, 215, 0, 0.8); }
|
|
}
|
|
`}</style>
|
|
|
|
{flatLetters.map((letter, index) => {
|
|
const row = Math.floor(index / gridSize);
|
|
const col = index % gridSize;
|
|
const key = `${row},${col}`;
|
|
|
|
const selected = selectedSet.has(key);
|
|
const first = firstLetterCell ? firstLetterCell[0] === row && firstLetterCell[1] === col : false;
|
|
const found = foundCells.has(key);
|
|
const newFound = newFoundCells.has(key);
|
|
|
|
let bg = colors.cellDefault;
|
|
let border = colors.border;
|
|
let textColor = colors.text;
|
|
let animation = '';
|
|
let boxShadow = shadows.cell;
|
|
|
|
if (found) {
|
|
bg = colors.cellFound;
|
|
border = colors.success;
|
|
textColor = '#1a7a3a';
|
|
if (newFound) {
|
|
animation = 'cellPopIn 0.4s ease, cellGlow 1s ease 0.4s';
|
|
}
|
|
} else if (first) {
|
|
bg = colors.cellFirst;
|
|
border = colors.gold;
|
|
textColor = '#b8860b';
|
|
animation = 'firstLetterPulse 1s ease infinite';
|
|
boxShadow = '0 0 10px rgba(255, 215, 0, 0.5)';
|
|
} else if (selected) {
|
|
bg = colors.cellSelected;
|
|
border = colors.accent;
|
|
textColor = colors.accent;
|
|
boxShadow = `0 0 8px ${colors.accent}40`;
|
|
}
|
|
|
|
return (
|
|
<Cell
|
|
key={`${row}-${col}-${index}`}
|
|
letter={letter}
|
|
size={cellSize}
|
|
fontSize={fontSize}
|
|
bg={bg}
|
|
border={border}
|
|
textColor={textColor}
|
|
boxShadow={boxShadow}
|
|
animation={animation || undefined}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LetterGrid; |