diff --git a/VERSION b/VERSION index ff365e0..0aec50e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.3 +3.1.4 diff --git a/play-life-web/package.json b/play-life-web/package.json index 3631247..551cb86 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.1.3", + "version": "3.1.4", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/TestWords.jsx b/play-life-web/src/components/TestWords.jsx index cd7a6ab..ca51902 100644 --- a/play-life-web/src/components/TestWords.jsx +++ b/play-life-web/src/components/TestWords.jsx @@ -14,7 +14,7 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики) const [testWords, setTestWords] = useState([]) // Пул слов для показа - const [currentIndex, setCurrentIndex] = useState(0) + const [currentWord, setCurrentWord] = useState(null) // Текущее слово, которое показывается (уже удалено из пула) const [flippedCards, setFlippedCards] = useState(new Set()) const [wordStats, setWordStats] = useState({}) // Локальная статистика const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек @@ -28,11 +28,102 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const wordStatsRef = useRef({}) const processingRef = useRef(false) + // Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами + // excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка) + const redistributeWordsEvenly = (currentPool, allWords, excludeFirstWordId = null) => { + if (currentPool.length === 0 || allWords.length === 0) { + return currentPool + } + + // Подсчитываем, сколько раз каждое слово встречается в текущем пуле + const wordCounts = {} + currentPool.forEach(word => { + wordCounts[word.id] = (wordCounts[word.id] || 0) + 1 + }) + + // Получаем список уникальных слов, которые есть в пуле + const uniqueWordIds = Object.keys(wordCounts).map(id => parseInt(id)) + const uniqueWords = allWords.filter(word => uniqueWordIds.includes(word.id)) + + if (uniqueWords.length === 0) { + return currentPool + } + + // Перемешиваем уникальные слова для случайности порядка + const shuffledUniqueWords = [...uniqueWords] + for (let i = shuffledUniqueWords.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffledUniqueWords[i], shuffledUniqueWords[j]] = [shuffledUniqueWords[j], shuffledUniqueWords[i]] + } + + // Создаём массив с информацией о каждом слове и его количестве вхождений + const wordEntries = shuffledUniqueWords.map(word => ({ + word, + count: wordCounts[word.id] + })) + + // Сортируем по убыванию количества вхождений (слова с большим количеством вхождений размещаем первыми) + wordEntries.sort((a, b) => b.count - a.count) + + const totalSlots = currentPool.length + const newPool = new Array(totalSlots).fill(null) + + // Размещаем каждое слово с максимально возможным расстоянием между его вхождениями + for (const entry of wordEntries) { + const { word, count } = entry + + // Вычисляем идеальный интервал между вхождениями этого слова + const interval = totalSlots / count + + // Размещаем каждое вхождение слова + for (let i = 0; i < count; i++) { + // Начинаем с идеальной позиции + let idealPos = Math.floor(i * interval + Math.random() * interval * 0.5) + idealPos = Math.min(idealPos, totalSlots - 1) + + // Если это исключаемое слово, не размещаем его на позиции 0 + if (word.id === excludeFirstWordId && idealPos === 0) { + idealPos = 1 + } + + // Ищем ближайшую свободную позицию + let placed = false + + // Сначала ищем вперёд + for (let offset = 0; offset < totalSlots && !placed; offset++) { + const pos = (idealPos + offset) % totalSlots + // Пропускаем позицию 0 для исключаемого слова + if (word.id === excludeFirstWordId && pos === 0) { + continue + } + if (newPool[pos] === null) { + newPool[pos] = word + placed = true + } + } + } + } + + // Финальная проверка: если на позиции 0 оказалось исключаемое слово, меняем его с другим + if (excludeFirstWordId !== null && newPool[0] && newPool[0].id === excludeFirstWordId) { + // Ищем первое слово, которое не является исключаемым + for (let i = 1; i < newPool.length; i++) { + if (newPool[i] && newPool[i].id !== excludeFirstWordId) { + // Меняем местами + ;[newPool[0], newPool[i]] = [newPool[i], newPool[0]] + break + } + } + } + + return newPool + } + // Загрузка слов при монтировании useEffect(() => { setWords([]) setTestWords([]) - setCurrentIndex(0) + setCurrentWord(null) setFlippedCards(new Set()) setWordStats({}) wordStatsRef.current = {} @@ -81,16 +172,13 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount)) // Создаем пул, где каждое слово повторяется n раз - const wordPool = [] + let wordPool = [] for (let i = 0; i < n; i++) { wordPool.push(...data) } - // Перемешиваем пул случайным образом - for (let i = wordPool.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]] - } + // Равномерно распределяем слова в пуле + wordPool = redistributeWordsEvenly(wordPool, data) setTestWords(wordPool) setWordStats(stats) @@ -111,16 +199,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC loadWords() }, [wordCount, configId]) - const getCurrentWord = () => { - if (currentIndex < testWords.length && testWords.length > 0) { - return testWords[currentIndex] - } - return null - } - - // Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards) + // Правый счётчик: текущий размер пула + показанные карточки, но не больше maxCards const getRightCounter = () => { - const total = totalAnswers + testWords.length + const total = testWords.length + cardsShown if (maxCards !== null && maxCards > 0) { return Math.min(total, maxCards) } @@ -205,34 +286,39 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC } } - // Проверка условий завершения и показ следующей карточки - const showNextCardOrFinish = (newTestWords, currentCardsShown) => { - // Проверяем, не завершился ли тест (на случай если finishTest уже был вызван) + // Берём карточку из пула (getAndDelete) и показываем её + const showNextCard = () => { + // Проверяем, не завершился ли тест if (isFinishingRef.current || showResults) { return } - // Условие 1: Достигли максимума карточек - if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) { - finishTest() - return - } - - // Условие 2: Пул слов пуст - if (newTestWords.length === 0) { - finishTest() - return - } - - // Показываем следующую карточку и увеличиваем левый счётчик - // Но сначала проверяем, не достигнем ли мы максимума после увеличения - const nextCardsShown = currentCardsShown + 1 - if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) { - finishTest() - return - } - - setCardsShown(nextCardsShown) + // Используем функциональное обновление для получения актуального состояния пула + setTestWords(prevPool => { + // Условие 1: Достигли максимума карточек + const nextCardsShown = cardsShown + 1 + if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) { + finishTest() + return prevPool + } + + // Условие 2: Пул слов пуст + if (prevPool.length === 0) { + finishTest() + return prevPool + } + + // getAndDelete: берём слово из пула и удаляем его + const nextWord = prevPool[0] + const updatedPool = prevPool.slice(1) + + // showCard: показываем карточку + setCurrentWord(nextWord) + setCardsShown(nextCardsShown) + setFlippedCards(new Set()) + + return updatedPool + }) } const handleSuccess = (wordId) => { @@ -265,25 +351,9 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const newTotalAnswers = totalAnswers + 1 setTotalAnswers(newTotalAnswers) - // Убираем только один экземпляр слова из пула (по текущему индексу) - const newTestWords = [...testWords] - newTestWords.splice(currentIndex, 1) - - // Обновляем индекс: если удалили последний элемент, переходим к предыдущему - let newIndex = currentIndex - if (newTestWords.length === 0) { - newIndex = 0 - } else if (currentIndex >= newTestWords.length) { - newIndex = newTestWords.length - 1 - } - - // Обновляем состояние - setTestWords(newTestWords) - setCurrentIndex(newIndex) - setFlippedCards(new Set()) - - // Проверяем условия завершения или показываем следующую карточку - showNextCardOrFinish(newTestWords, cardsShown) + // onSuccess: просто повторяем (showNextCard) + // Карточка уже удалена из пула при показе, просто показываем следующую + showNextCard() // Если тест завершился, не сбрасываем processingRef if (isFinishingRef.current) { @@ -323,18 +393,20 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const newTotalAnswers = totalAnswers + 1 setTotalAnswers(newTotalAnswers) - // Слово остаётся в пуле, переходим к следующему - let newIndex = currentIndex + 1 - if (newIndex >= testWords.length) { - newIndex = 0 - } + // onFailure: возвращаем карточку в пул, сортируем, повторяем + setTestWords(prevPool => { + // cards.add(currentCard): возвращаем слово обратно в пул + let newTestWords = [...prevPool, word] + + // cards.sort(): равномерно перераспределяем слова в пуле + // Передаём wordId, чтобы текущая карточка не оказалась первой (следующей для показа) + newTestWords = redistributeWordsEvenly(newTestWords, words, wordId) + + return newTestWords + }) - setCurrentIndex(newIndex) - setFlippedCards(new Set()) - - // Проверяем условия завершения или показываем следующую карточку - // При failure пул не изменяется - showNextCardOrFinish(testWords, cardsShown) + // repeat(): показываем следующую карточку + showNextCard() // Если тест завершился, не сбрасываем processingRef if (isFinishingRef.current) { @@ -350,8 +422,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC const handleStartTest = () => { setShowPreview(false) - // Показываем первую карточку и увеличиваем левый счётчик - setCardsShown(1) + // Показываем первую карточку (берём из пула) + showNextCard() } const handleFinish = () => { @@ -431,8 +503,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC {error && (
{error}
)} - {!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => { - const word = getCurrentWord() + {!loading && !error && !isFinishingRef.current && currentWord && (() => { + const word = currentWord const isFlipped = flippedCards.has(word.id) const showSide = getRandomSide(word)