v3.1.4: Улучшено равномерное распределение карточек в тесте
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s

- Добавлен учёт текущей карточки при перераспределении (не показывается следующей)
- Исправлен алгоритм равномерного распределения для предотвращения подряд идущих одинаковых карточек
- Исправлен правый счётчик: показывает текущий размер пула + показанные карточки (не больше maxCards)
This commit is contained in:
poignatov
2026-01-06 15:30:30 +03:00
parent 28a45ab81e
commit d9db42a598
3 changed files with 149 additions and 77 deletions

View File

@@ -1 +1 @@
3.1.3
3.1.4

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "3.1.3",
"version": "3.1.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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 && (
<div className="test-error">{error}</div>
)}
{!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)