このコミットが含まれているのは:
2026-06-16 21:52:10 +09:00
コミット 673a5dbd23
2個のファイルの変更413行の追加267行の削除
+12 -1
ファイルの表示
@@ -192,7 +192,10 @@ class GekanatorGamesController < ApplicationController
answer_value = answer['answer'].to_s answer_value = answer['answer'].to_s
next if answer_value.blank? || answer_value == 'unknown' next if answer_value.blank? || answer_value == 'unknown'
question = accepted_questions[answer['question_id'].to_s] question_id = game_answer_question_id(answer)
next if question_id.blank?
question = accepted_questions[question_id.to_s]
next unless learnable_game_answer_question?(question) next unless learnable_game_answer_question?(question)
example = example =
@@ -251,6 +254,7 @@ class GekanatorGamesController < ApplicationController
end end
def learnable_game_answer_question? question def learnable_game_answer_question? question
return false if question.nil?
return true if question.kind == 'post_similarity' return true if question.kind == 'post_similarity'
return false unless question.kind == 'tag' return false unless question.kind == 'tag'
@@ -258,4 +262,11 @@ class GekanatorGamesController < ApplicationController
key = condition[:key].to_s key = condition[:key].to_s
!key.start_with?('nico:') !key.start_with?('nico:')
end end
def game_answer_question_id answer
answer['question_id'] ||
answer[:question_id] ||
answer['questionId'] ||
answer[:questionId]
end
end end
+401 -266
ファイルの表示
@@ -110,6 +110,7 @@ type StoredGekanatorGame = {
savedGameId: number | null savedGameId: number | null
learnedExampleCount?: number | null learnedExampleCount?: number | null
gameSeed?: string gameSeed?: string
questionSuggestionEntryMode?: 'search' | 'new'
questionSuggestionSearch?: string questionSuggestionSearch?: string
questionSuggestionSelectedId?: number | null questionSuggestionSelectedId?: number | null
questionSuggestion: string questionSuggestion: string
@@ -141,6 +142,10 @@ type QuestionBuildMode =
| 'split' | 'split'
| 'confirmation' | 'confirmation'
type QuestionSuggestionEntryMode =
| 'search'
| 'new'
type MascotState = type MascotState =
| 'idle' | 'idle'
| 'thinking_far' | 'thinking_far'
@@ -256,6 +261,7 @@ const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame =>
winningRunTargetId: game.winningRunTargetId ?? null, winningRunTargetId: game.winningRunTargetId ?? null,
winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null, winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null,
learnedExampleCount: game.learnedExampleCount ?? null, learnedExampleCount: game.learnedExampleCount ?? null,
questionSuggestionEntryMode: game.questionSuggestionEntryMode ?? 'search',
questionSuggestionSearch: game.questionSuggestionSearch ?? '', questionSuggestionSearch: game.questionSuggestionSearch ?? '',
questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null, questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null,
askedQuestionBank: game.askedQuestionBank?.map (question => ({ askedQuestionBank: game.askedQuestionBank?.map (question => ({
@@ -1125,17 +1131,31 @@ const applyQuestionAnswerDeltaToScores = ({
if (!(questionUsesPostSimilarityGraphForScoring (question))) if (!(questionUsesPostSimilarityGraphForScoring (question)))
return return
// `post_similarities` is the propagation graph. Directly matched posts
// get only the base delta; only non-direct neighbors get `base delta * cos`.
// When several matched posts point at the same neighbor, keep the largest
// absolute propagated contribution instead of summing all of them.
const propagatedDeltaByPostId = new Map<number, number> ()
matched.forEach (postId => { matched.forEach (postId => {
const post = materialIndex.postById.get (postId) const post = materialIndex.postById.get (postId)
post?.postSimilarityEdges?.forEach (edge => { post?.postSimilarityEdges?.forEach (edge => {
if (!Number.isFinite (edge.cos) || edge.cos <= 0) if (!Number.isFinite (edge.cos) || edge.cos <= 0)
return return
if (matched.has (edge.targetPostId))
return
nextScores.set ( const propagatedDelta = baseDelta * edge.cos
edge.targetPostId, const current = propagatedDeltaByPostId.get (edge.targetPostId)
(nextScores.get (edge.targetPostId) ?? 0) + baseDelta * edge.cos) if (current === undefined || Math.abs (propagatedDelta) > Math.abs (current))
propagatedDeltaByPostId.set (edge.targetPostId, propagatedDelta)
}) })
}) })
propagatedDeltaByPostId.forEach ((propagatedDelta, postId) => {
nextScores.set (
postId,
(nextScores.get (postId) ?? 0) + propagatedDelta)
})
} }
const buildIndexedQuestion = ( const buildIndexedQuestion = (
@@ -1294,7 +1314,7 @@ const buildQuestionsForCandidateIds = (
.forEach (([term]) => { .forEach (([term]) => {
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-contains', text: String (term) }, condition: { type: 'title-contains', text: String (term) },
text: `題名に「${ term }」が含まれる?`, text: `タイトルに「${ term }」が含まれる?`,
kind: 'title', kind: 'title',
priorityWeight: .99, priorityWeight: .99,
materialIndex })) materialIndex }))
@@ -1313,7 +1333,7 @@ const buildQuestionsForCandidateIds = (
if (asciiCount > 0 && asciiCount < total) if (asciiCount > 0 && asciiCount < total)
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-has-ascii' }, condition: { type: 'title-has-ascii' },
text: '題名に英数字が混じっている?', text: 'タイトルに英数字が混じっている?',
kind: 'title', kind: 'title',
priorityWeight: .68, priorityWeight: .68,
materialIndex })) materialIndex }))
@@ -1367,7 +1387,7 @@ const buildQuestionsForCandidateIds = (
.forEach (term => { .forEach (term => {
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-contains', text: term }, condition: { type: 'title-contains', text: term },
text: `題名に「${ term }」が含まれる?`, text: `タイトルに「${ term }」が含まれる?`,
kind: 'title', kind: 'title',
priorityWeight: .99, priorityWeight: .99,
materialIndex })) materialIndex }))
@@ -1386,7 +1406,7 @@ const buildQuestionsForCandidateIds = (
if (materialIndex.titleAsciiPostIds.has (targetPostId)) if (materialIndex.titleAsciiPostIds.has (targetPostId))
addQuestion (buildIndexedQuestion ({ addQuestion (buildIndexedQuestion ({
condition: { type: 'title-has-ascii' }, condition: { type: 'title-has-ascii' },
text: '題名に英数字が混じっている?', text: 'タイトルに英数字が混じっている?',
kind: 'title', kind: 'title',
priorityWeight: .68, priorityWeight: .68,
materialIndex })) materialIndex }))
@@ -1415,6 +1435,8 @@ const candidatePostsForState = ({
recoveredCandidatePosts: Map<number, number> recoveredCandidatePosts: Map<number, number>
}): Post[] => { }): Post[] => {
const dynamicMatchIndex = new Map<string, Set<number>> () const dynamicMatchIndex = new Map<string, Set<number>> ()
const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean =>
answer === 'yes' || answer === 'no'
return posts.filter (post => { return posts.filter (post => {
if (rejectedPostIds.has (post.id)) if (rejectedPostIds.has (post.id))
@@ -1427,7 +1449,7 @@ const candidatePostsForState = ({
return true return true
if (softenedQuestionIds.has (answer.questionId)) if (softenedQuestionIds.has (answer.questionId))
return true return true
if (!(answer.answer === 'yes' || answer.answer === 'no')) if (!(answerAllowsHardFilter (answer.answer)))
return true return true
const question = questionById.get (answer.questionId) const question = questionById.get (answer.questionId)
@@ -2717,6 +2739,7 @@ const GekanatorBackdrop: FC<{
const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)` const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)`
const [activeDirection, setActiveDirection] = useState (nextDirection) const [activeDirection, setActiveDirection] = useState (nextDirection)
const activeDirectionRef = useRef (activeDirection) const activeDirectionRef = useRef (activeDirection)
const guessAnimationControlsRef = useRef<ReturnType<typeof animate>[]> ([])
const flipTimerRef = useRef<number | null> (null) const flipTimerRef = useRef<number | null> (null)
const [displayedBackdropMode, setDisplayedBackdropMode] = const [displayedBackdropMode, setDisplayedBackdropMode] =
useState<'normal' | 'winning_run' | 'guess'> (backdropMode) useState<'normal' | 'winning_run' | 'guess'> (backdropMode)
@@ -2731,15 +2754,30 @@ const GekanatorBackdrop: FC<{
const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed) const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed)
const [isFlippingTiles, setIsFlippingTiles] = useState (false) const [isFlippingTiles, setIsFlippingTiles] = useState (false)
const [isCrossfading, setIsCrossfading] = useState (false) const [isCrossfading, setIsCrossfading] = useState (false)
const renderedSettings = settingsForMode (displayedBackdropMode)
const renderedTileCount = const isLeavingGuessBackdrop = displayedBackdropMode === 'guess' && backdropMode !== 'guess'
renderedSettings.columns * renderedSettings.rows
const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount) const renderBackdropMode = isLeavingGuessBackdrop ? backdropMode : displayedBackdropMode
const isGuessPresentation =
phase === 'guess' || backdropMode === 'guess' || displayedBackdropMode === 'guess' const renderWinningRunCount = (isLeavingGuessBackdrop
? winningRunQuestionCount
: displayedWinningRunCount)
const renderThumbnails = isLeavingGuessBackdrop ? nextThumbnails : displayedThumbnails
const renderIsCrossfading = isCrossfading && !(isLeavingGuessBackdrop)
const renderedSettings = settingsForMode (renderBackdropMode)
const renderedTileCount = renderedSettings.columns * renderedSettings.rows
const renderedScale = scaleForMode (renderBackdropMode, renderWinningRunCount)
const isGuessPresentation = backdropMode === 'guess'
const crossfadeDuration = motionMode === 'calm' ? .95 : .75 const crossfadeDuration = motionMode === 'calm' ? .95 : .75
useEffect (() => { useEffect (() => {
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
if (motionMode === 'off') if (motionMode === 'off')
return return
@@ -2752,9 +2790,11 @@ const GekanatorBackdrop: FC<{
const controls = [ const controls = [
animate (x, 0, { duration, ease }), animate (x, 0, { duration, ease }),
animate (y, 0, { duration, ease })] animate (y, 0, { duration, ease })]
guessAnimationControlsRef.current = controls
return () => { return () => {
controls.forEach (control => control.stop ()) controls.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
} }
}, [isGuessPresentation, motionMode, visualSeed, x, y]) }, [isGuessPresentation, motionMode, visualSeed, x, y])
@@ -2771,13 +2811,21 @@ const GekanatorBackdrop: FC<{
if (motionMode === 'off' || nextThumbnails.length === 0) if (motionMode === 'off' || nextThumbnails.length === 0)
{ {
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
x.set (0) x.set (0)
y.set (0) y.set (0)
return return
} }
if (isGuessPresentation) if (isGuessPresentation)
return {
guessAnimationControlsRef.current.forEach (control => control.stop ())
guessAnimationControlsRef.current = []
x.set (0)
y.set (0)
return
}
const speed = 33.333333 / marqueeDuration const speed = 33.333333 / marqueeDuration
let animationFrame: number let animationFrame: number
@@ -2795,8 +2843,7 @@ const GekanatorBackdrop: FC<{
animationFrame = window.requestAnimationFrame (tick) animationFrame = window.requestAnimationFrame (tick)
return () => window.cancelAnimationFrame (animationFrame) return () => window.cancelAnimationFrame (animationFrame)
}, [ }, [x,
x,
y, y,
marqueeDuration, marqueeDuration,
motionMode, motionMode,
@@ -2809,67 +2856,86 @@ const GekanatorBackdrop: FC<{
setActiveDirection (nextDirection) setActiveDirection (nextDirection)
} }
if (flipTimerRef.current !== null) { if (flipTimerRef.current !== null)
window.clearTimeout (flipTimerRef.current) {
flipTimerRef.current = null window.clearTimeout (flipTimerRef.current)
} flipTimerRef.current = null
}
if (motionMode === 'off') { if (motionMode === 'off')
applyDirection () {
setIsFlippingTiles (false) applyDirection ()
setIsCrossfading (false) setIsFlippingTiles (false)
setFlipVisualSeed (visualSeed) setIsCrossfading (false)
return setFlipVisualSeed (visualSeed)
} return
}
if (backdropMode === 'guess' && guessThumbnail) { if (backdropMode === 'guess' && guessThumbnail)
setIsFlippingTiles (false) {
setIsCrossfading (false) setIsFlippingTiles (false)
setDisplayedBackdropMode ('guess') setIsCrossfading (false)
setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedBackdropMode ('guess')
setDisplayedThumbnails (nextThumbnails) setDisplayedWinningRunCount (winningRunQuestionCount)
setFromThumbnails (nextThumbnails) setDisplayedThumbnails (nextThumbnails)
setToThumbnails (nextThumbnails) setFromThumbnails (nextThumbnails)
setFlipVisualSeed (visualSeed) setToThumbnails (nextThumbnails)
return setFlipVisualSeed (visualSeed)
} return
}
if ( if (displayedBackdropMode === 'winning_run'
displayedBackdropMode === 'winning_run' && backdropMode === 'winning_run')
&& backdropMode === 'winning_run' {
) { applyDirection ()
applyDirection () setDisplayedBackdropMode ('winning_run')
setDisplayedBackdropMode ('winning_run') setDisplayedWinningRunCount (winningRunQuestionCount)
setDisplayedWinningRunCount (winningRunQuestionCount) setDisplayedThumbnails (nextThumbnails)
setDisplayedThumbnails (nextThumbnails) setFromThumbnails (nextThumbnails)
setFromThumbnails (nextThumbnails) setToThumbnails (nextThumbnails)
setToThumbnails (nextThumbnails) setIsFlippingTiles (false)
setIsFlippingTiles (false) setIsCrossfading (false)
setIsCrossfading (false) setFlipVisualSeed (visualSeed)
setFlipVisualSeed (visualSeed) return
return }
}
if (nextThumbnails.length === 0) { if (nextThumbnails.length === 0)
applyDirection () {
setIsFlippingTiles (false) applyDirection ()
setIsCrossfading (false) setIsFlippingTiles (false)
setFlipVisualSeed (visualSeed) setIsCrossfading (false)
return setFlipVisualSeed (visualSeed)
} return
}
if (displayedBackdropMode === 'guess' && backdropMode !== 'guess')
{
applyDirection ()
x.set (0)
y.set (0)
setIsFlippingTiles (false)
setIsCrossfading (false)
setDisplayedBackdropMode (backdropMode)
setDisplayedWinningRunCount (winningRunQuestionCount)
setDisplayedThumbnails (nextThumbnails)
setFromThumbnails (nextThumbnails)
setToThumbnails (nextThumbnails)
setFlipVisualSeed (visualSeed)
return
}
const sameTiles = const sameTiles =
displayedThumbnails.length === nextThumbnails.length displayedThumbnails.length === nextThumbnails.length
&& displayedThumbnails.every ( && displayedThumbnails.every ((thumbnail, index) => thumbnail === nextThumbnails[index])
(thumbnail, index) => thumbnail === nextThumbnails[index])
if (sameTiles && flipVisualSeed === visualSeed) { if (sameTiles && flipVisualSeed === visualSeed)
if ( {
activeDirection.x !== nextDirection.x if (activeDirection.x !== nextDirection.x
|| activeDirection.y !== nextDirection.y || activeDirection.y !== nextDirection.y)
) applyDirection ()
applyDirection ()
return return
} }
const currentThumbnails = const currentThumbnails =
displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails
@@ -2895,13 +2961,13 @@ const GekanatorBackdrop: FC<{
}, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000) }, (shouldCrossfade ? crossfadeDuration : tileFlipDuration) * 1000)
return () => { return () => {
if (flipTimerRef.current !== null) { if (flipTimerRef.current !== null)
window.clearTimeout (flipTimerRef.current) {
flipTimerRef.current = null window.clearTimeout (flipTimerRef.current)
} flipTimerRef.current = null
}
} }
}, [ }, [motionMode,
motionMode,
backdropMode, backdropMode,
displayedBackdropMode, displayedBackdropMode,
guessThumbnail, guessThumbnail,
@@ -2917,118 +2983,120 @@ const GekanatorBackdrop: FC<{
x, x,
y]) y])
const renderTileSet = ({ const renderTileSet = (
mode, { mode,
thumbnails, thumbnails,
settings, settings,
tileCount, tileCount,
scale, scale,
opacity, opacity,
withFlip, withFlip }: { mode: 'normal' | 'winning_run' | 'guess'
}: { thumbnails: string[]
mode: 'normal' | 'winning_run' | 'guess' settings: { columns: number; rows: number; opacity: number }
thumbnails: string[] tileCount: number
settings: { columns: number; rows: number; opacity: number } scale: number
tileCount: number opacity?: number
scale: number withFlip?: boolean }) => {
opacity?: number const guessModeFlg = mode === 'guess'
withFlip?: boolean
}) => ( return (
<motion.div <motion.div
className="absolute inset-0" className="absolute inset-0"
initial={opacity === undefined initial={guessModeFlg
? undefined ? false
: { : (opacity == null
opacity: opacity === 0 ? 1 : 0, ? undefined
: { opacity: opacity === 0 ? 1 : 0, scale, x: '0%', y: '0%' })}
animate={{ opacity: opacity ?? 1,
scale, scale,
x: mode === 'guess' ? guessFocusOffset.x : '0%', x: guessModeFlg ? guessFocusOffset.x : '0%',
y: mode === 'guess' ? guessFocusOffset.y : '0%' }} y: guessModeFlg ? guessFocusOffset.y : '0%' }}
animate={{ opacity: opacity ?? 1, scale, transition={guessModeFlg
x: mode === 'guess' ? guessFocusOffset.x : '0%', ? { duration: 0 }
y: mode === 'guess' ? guessFocusOffset.y : '0%' }} : (mode === 'winning_run'
transition={(mode === 'winning_run' || mode === 'guess') ? { duration: crossfadeDuration, ease: [.16, 1, .3, 1] }
? { duration: crossfadeDuration, ease: [.16, 1, .3, 1] } : { duration: .2 })}>
: { duration: .2 }}> {Array.from ({ length: 9 }, (_, duplicate) => {
{Array.from ({ length: 9 }, (_, duplicate) => { const column = duplicate % 3
const column = duplicate % 3 const row = Math.floor (duplicate / 3)
const row = Math.floor (duplicate / 3)
return ( return (
<motion.div <motion.div
key={`${ mode }:${ duplicate }`} key={`${ mode }:${ duplicate }`}
className="absolute grid overflow-hidden" className="absolute grid overflow-hidden"
layout={mode !== 'normal'} layout={mode === 'winning_run'}
style={{ style={{
left: `${ column * 33.333333 }%`, left: `${ column * 33.333333 }%`,
top: `${ row * 33.333333 }%`, top: `${ row * 33.333333 }%`,
width: '33.333333%', width: '33.333333%',
height: '33.333333%', height: '33.333333%',
gridTemplateColumns: gridTemplateColumns:
`repeat(${ settings.columns }, minmax(0, 1fr))`, `repeat(${ settings.columns }, minmax(0, 1fr))`,
gridTemplateRows: gridTemplateRows:
`repeat(${ settings.rows }, minmax(0, 1fr))` }} `repeat(${ settings.rows }, minmax(0, 1fr))` }}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}> transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
{Array.from ({ length: tileCount }, (_, index) => { {Array.from ({ length: tileCount }, (_, index) => {
const currentThumbnail = const currentThumbnail =
thumbnails[index % Math.max (thumbnails.length, 1)] thumbnails[index % Math.max (thumbnails.length, 1)]
const frontThumbnail = const frontThumbnail =
withFlip withFlip
? fromThumbnails[index % Math.max (fromThumbnails.length, 1)] ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
: currentThumbnail : currentThumbnail
const backThumbnail = const backThumbnail =
withFlip withFlip
? toThumbnails[index % Math.max (toThumbnails.length, 1)] ? toThumbnails[index % Math.max (toThumbnails.length, 1)]
: currentThumbnail : currentThumbnail
const thumbnail = currentThumbnail const thumbnail = currentThumbnail
if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail)) if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
return null return null
return ( return (
<motion.div <motion.div
key={`${ mode }:${ duplicate }:${ index }`} key={`${ mode }:${ duplicate }:${ index }`}
className="relative overflow-hidden" className="relative overflow-hidden"
layout={mode !== 'normal'} layout={mode === 'winning_run'}
transition={{ duration: tileFlipDuration, ease: 'easeInOut' }} transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
style={{ perspective: 1600 }}> style={{ perspective: 1600 }}>
{(mode !== 'normal' || !(withFlip)) {(mode !== 'normal' || !(withFlip))
? ( ? (
<img
src={['intro', 'end'].includes (phase)
? mascotAsset
: thumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{ opacity: settings.opacity }}/>)
: (
<motion.div
className="absolute inset-0"
initial={{ rotateY: 0 }}
animate={{ rotateY: 180 }}
transition={{
duration: tileFlipDuration,
ease: 'easeInOut' }}
style={{ transformStyle: 'preserve-3d' }}>
<img <img
src={backThumbnail} src={['intro', 'end'].includes (phase)
? mascotAsset
: thumbnail}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
style={{ style={{ opacity: settings.opacity }}/>)
backfaceVisibility: 'hidden', : (
opacity: settings.opacity, <motion.div
transform: 'rotateY(180deg)' }}/> className="absolute inset-0"
<img initial={{ rotateY: 0 }}
src={frontThumbnail} animate={{ rotateY: 180 }}
alt="" transition={{
className="absolute inset-0 h-full w-full object-cover" duration: tileFlipDuration,
style={{ ease: 'easeInOut' }}
backfaceVisibility: 'hidden', style={{ transformStyle: 'preserve-3d' }}>
opacity: settings.opacity }}/> <img
</motion.div>)} src={backThumbnail}
</motion.div>) alt=""
})} className="absolute inset-0 h-full w-full object-cover"
</motion.div>) style={{
})} backfaceVisibility: 'hidden',
</motion.div>) opacity: settings.opacity,
transform: 'rotateY(180deg)' }}/>
<img
src={frontThumbnail}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
backfaceVisibility: 'hidden',
opacity: settings.opacity }}/>
</motion.div>)}
</motion.div>)
})}
</motion.div>)
})}
</motion.div>)
}
if (motionMode === 'off' || nextThumbnails.length === 0) if (motionMode === 'off' || nextThumbnails.length === 0)
return ( return (
@@ -3046,15 +3114,16 @@ const GekanatorBackdrop: FC<{
height: 'calc(max(100vw, 100vh) * 3)' }}> height: 'calc(max(100vw, 100vh) * 3)' }}>
<motion.div <motion.div
className="relative h-full w-full"> className="relative h-full w-full">
{isCrossfading {renderIsCrossfading
? ( ? (
<> <>
{renderTileSet ({ {renderTileSet ({
mode: displayedBackdropMode, mode: displayedBackdropMode,
thumbnails: fromThumbnails, thumbnails: fromThumbnails,
settings: renderedSettings, settings: settingsForMode (displayedBackdropMode),
tileCount: renderedTileCount, tileCount: (settingsForMode (displayedBackdropMode).columns
scale: renderedScale, * settingsForMode (displayedBackdropMode).rows),
scale: scaleForMode (displayedBackdropMode, displayedWinningRunCount),
opacity: 0 })} opacity: 0 })}
{renderTileSet ({ {renderTileSet ({
mode: backdropMode, mode: backdropMode,
@@ -3064,13 +3133,14 @@ const GekanatorBackdrop: FC<{
scale: scaleForMode (backdropMode, winningRunQuestionCount), scale: scaleForMode (backdropMode, winningRunQuestionCount),
opacity: 1 })} opacity: 1 })}
</>) </>)
: renderTileSet ({ : (
mode: displayedBackdropMode, renderTileSet ({
thumbnails: displayedThumbnails, mode: renderBackdropMode,
settings: renderedSettings, thumbnails: renderThumbnails,
tileCount: renderedTileCount, settings: renderedSettings,
scale: renderedScale, tileCount: renderedTileCount,
withFlip: isFlippingTiles })} scale: renderedScale,
withFlip: isFlippingTiles && !(isLeavingGuessBackdrop) }))}
</motion.div> </motion.div>
</motion.div> </motion.div>
</div> </div>
@@ -3150,6 +3220,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
storedGame?.savedGameId ?? null) storedGame?.savedGameId ?? null)
const [learnedExampleCount, setLearnedExampleCount] = useState<number | null> ( const [learnedExampleCount, setLearnedExampleCount] = useState<number | null> (
storedGame?.learnedExampleCount ?? null) storedGame?.learnedExampleCount ?? null)
const [questionSuggestionEntryMode, setQuestionSuggestionEntryMode] =
useState<QuestionSuggestionEntryMode> (
storedGame?.questionSuggestionEntryMode ?? 'search')
const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState ( const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState (
storedGame?.questionSuggestionSearch ?? '') storedGame?.questionSuggestionSearch ?? '')
const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] = const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] =
@@ -3190,11 +3263,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
[posts, acceptedQuestions]) [posts, acceptedQuestions])
useEffect (() => { useEffect (() => {
if ( if (posts.length === 0
posts.length === 0
|| storedAskedQuestionBankIds.length === 0 || storedAskedQuestionBankIds.length === 0
|| !(acceptedQuestionsFetched) || !(acceptedQuestionsFetched))
)
return return
const questionById = new Map ( const questionById = new Map (
@@ -3207,8 +3278,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
.map (questionId => questionById.get (questionId)) .map (questionId => questionById.get (questionId))
.filter ((question): question is GekanatorQuestion => question !== undefined)) .filter ((question): question is GekanatorQuestion => question !== undefined))
setStoredAskedQuestionBankIds ([]) setStoredAskedQuestionBankIds ([])
}, [ }, [posts,
posts,
storedAskedQuestionBankIds, storedAskedQuestionBankIds,
acceptedQuestionsFetched, acceptedQuestionsFetched,
askedQuestionBank, askedQuestionBank,
@@ -3250,6 +3320,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
savedGameId, savedGameId,
learnedExampleCount, learnedExampleCount,
gameSeed, gameSeed,
questionSuggestionEntryMode,
questionSuggestionSearch, questionSuggestionSearch,
questionSuggestionSelectedId, questionSuggestionSelectedId,
questionSuggestion, questionSuggestion,
@@ -3293,6 +3364,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
savedGameId, savedGameId,
learnedExampleCount, learnedExampleCount,
gameSeed, gameSeed,
questionSuggestionEntryMode,
questionSuggestionSearch, questionSuggestionSearch,
questionSuggestionSelectedId, questionSuggestionSelectedId,
questionSuggestion, questionSuggestion,
@@ -3355,6 +3427,28 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
question => question.recordId === questionSuggestionSelectedId) question => question.recordId === questionSuggestionSelectedId)
?? null, ?? null,
[acceptedQuestions, questionSuggestionSelectedId]) [acceptedQuestions, questionSuggestionSelectedId])
const canSubmitQuestionSuggestion = useMemo (() => {
if (!(canPersistGame) || reviewCorrectPostId === null)
return false
if (questionSuggestionEntryMode === 'search')
return selectedSuggestedQuestion !== null
return questionSuggestion.trim () !== ''
}, [
canPersistGame,
reviewCorrectPostId,
questionSuggestionEntryMode,
selectedSuggestedQuestion,
questionSuggestion])
const canShowNewQuestionSuggestionButton = useMemo (() => {
return questionSuggestionEntryMode === 'search'
&& questionSuggestionSearch.trim () !== ''
&& searchableSuggestedQuestions.length === 0
}, [
questionSuggestionEntryMode,
questionSuggestionSearch,
searchableSuggestedQuestions.length])
const recentFirstQuestionPenaltyById = useMemo (() => { const recentFirstQuestionPenaltyById = useMemo (() => {
const penalties = new Map<string, number> () const penalties = new Map<string, number> ()
@@ -3494,6 +3588,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
onSuccess: async data => { onSuccess: async data => {
await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () }) await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
setQuestionSuggestionCount (data.count) setQuestionSuggestionCount (data.count)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('') setQuestionSuggestion ('')
@@ -3544,6 +3639,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setGameSeed (createGameSeed ()) setGameSeed (createGameSeed ())
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setQuestionSuggestion ('') setQuestionSuggestion ('')
@@ -3876,6 +3972,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setReviewGuessedPostId (guessedPostId) setReviewGuessedPostId (guessedPostId)
@@ -3895,6 +3992,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
setSelectingCorrectPost (false) setSelectingCorrectPost (false)
@@ -3925,6 +4023,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
} }
const saveAndReset = () => { const saveAndReset = () => {
if (saveMutation.isError)
{
reset ()
return
}
if (!(canPersistGame)) if (!(canPersistGame))
{ {
reset () reset ()
@@ -3948,11 +4052,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const submitQuestionSuggestion = () => { const submitQuestionSuggestion = () => {
const questionText = questionSuggestion.trim () const questionText = questionSuggestion.trim ()
const selectedQuestion = selectedSuggestedQuestion const selectedQuestion = selectedSuggestedQuestion
if ( if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending)
!(canPersistGame)
|| (selectedQuestion === null && !(questionText))
|| questionSuggestionMutation.isPending
)
return return
saveReviewedResult (gekanatorGameId => { saveReviewedResult (gekanatorGameId => {
@@ -4092,6 +4192,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
resetExtraQuestionState () resetExtraQuestionState ()
@@ -4105,6 +4206,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
setSaved (false) setSaved (false)
setSavedGameId (null) setSavedGameId (null)
setLearnedExampleCount (null) setLearnedExampleCount (null)
setQuestionSuggestionEntryMode ('search')
setQuestionSuggestionSearch ('') setQuestionSuggestionSearch ('')
setQuestionSuggestionSelectedId (null) setQuestionSuggestionSelectedId (null)
resetExtraQuestionState () resetExtraQuestionState ()
@@ -4798,62 +4900,101 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<p className="text-sm text-neutral-500"></p> <p className="text-sm text-neutral-500"></p>
<p className="text-xl font-bold"></p> <p className="text-xl font-bold">
{questionSuggestionEntryMode === 'search'
? 'まず既存質問を探してください。'
: '新しい質問を追加します。'}
</p>
</div> </div>
<label className="block space-y-2"> {questionSuggestionEntryMode === 'search'
<span className="font-bold"></span> ? (
<input <>
value={questionSuggestionSearch} <label className="block space-y-2">
onChange={ev => { <span className="font-bold"></span>
setQuestionSuggestionSearch (ev.target.value) <input
setQuestionSuggestionSelectedId (null) value={questionSuggestionSearch}
}} onChange={ev => {
className="w-full rounded border border-yellow-300 bg-white px-3 py-2 setQuestionSuggestionSearch (ev.target.value)
dark:border-red-700 dark:bg-red-950" setQuestionSuggestionSelectedId (null)
placeholder="質問文で検索。前方一致を優先して部分一致も出ます。"/> }}
</label> className="w-full rounded border border-yellow-300 bg-white px-3 py-2
{searchableSuggestedQuestions.length > 0 && ( dark:border-red-700 dark:bg-red-950"
placeholder="質問文で検索。前方一致を優先して部分一致も出ます。"/>
</label>
{searchableSuggestedQuestions.length > 0 && (
<div className="space-y-2">
<div className="font-bold"></div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{searchableSuggestedQuestions.map (question => (
<button
key={question.id}
type="button"
className={cn (
'block w-full rounded border px-3 py-2 text-left',
questionSuggestionSelectedId === (question.recordId ?? null)
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900')}
onClick={() => {
setQuestionSuggestionSelectedId (question.recordId ?? null)
setQuestionSuggestion ('')
}}>
<div className="font-bold">{question.text}</div>
</button>))}
</div>
</div>)}
{selectedSuggestedQuestion && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {selectedSuggestedQuestion.text}
</p>)}
{canShowNewQuestionSuggestionButton && (
<div className="space-y-2">
<p className="text-sm text-neutral-600 dark:text-neutral-300">
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900"
onClick={() => {
setQuestionSuggestionEntryMode ('new')
setQuestionSuggestionSelectedId (null)
setQuestionSuggestionSearch ('')
}}>
</button>
</div>
</div>)}
</>)
: (
<div className="space-y-2"> <div className="space-y-2">
<div className="font-bold"></div> <div className="font-bold"></div>
<div className="max-h-64 space-y-2 overflow-y-auto"> <textarea
{searchableSuggestedQuestions.map (question => ( value={questionSuggestion}
<button onChange={ev => {
key={question.id} setQuestionSuggestion (ev.target.value)
type="button" if (ev.target.value.trim () !== '')
className={cn ( setQuestionSuggestionSelectedId (null)
'block w-full rounded border px-3 py-2 text-left', }}
questionSuggestionSelectedId === (question.recordId ?? null) className="min-h-24 w-full rounded border border-yellow-300
? 'border-pink-600 bg-pink-50 dark:bg-red-900/50' bg-white px-3 py-2 dark:border-red-700
: 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900')} dark:bg-red-950"
onClick={() => { placeholder="適切な既存質問がない場合だけ新規追加してください。"/>
setQuestionSuggestionSelectedId (question.recordId ?? null) <div className="flex flex-wrap gap-2">
setQuestionSuggestion ('') <button
}}> type="button"
<div className="font-bold">{question.text}</div> className="rounded border border-neutral-300 px-4 py-2
<div className="text-sm text-neutral-500 dark:text-neutral-400"> hover:bg-neutral-100 dark:border-neutral-700
{question.kind} dark:hover:bg-red-900"
</div> onClick={() => {
</button>))} setQuestionSuggestionEntryMode ('search')
setQuestionSuggestion ('')
}}>
</button>
</div> </div>
</div>)} </div>)}
<div className="space-y-2">
<div className="font-bold"></div>
<textarea
value={questionSuggestion}
onChange={ev => {
setQuestionSuggestion (ev.target.value)
if (ev.target.value.trim () !== '')
setQuestionSuggestionSelectedId (null)
}}
className="min-h-24 w-full rounded border border-yellow-300
bg-white px-3 py-2 dark:border-red-700
dark:bg-red-950"
placeholder="適切な既存質問がない場合だけ新規追加してください。"/>
</div>
{selectedSuggestedQuestion && (
<p className="text-sm text-neutral-600 dark:text-neutral-300">
: {selectedSuggestedQuestion.text}
</p>)}
<label className="block space-y-2"> <label className="block space-y-2">
<span className="font-bold">稿</span> <span className="font-bold">稿</span>
<select <select
@@ -4886,15 +5027,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
className="rounded border border-yellow-300 px-4 py-2 className="rounded border border-yellow-300 px-4 py-2
hover:bg-yellow-100 dark:border-red-700 hover:bg-yellow-100 dark:border-red-700
dark:hover:bg-red-900 disabled:opacity-50" dark:hover:bg-red-900 disabled:opacity-50"
disabled={ disabled={!(canSubmitQuestionSuggestion)
!(canPersistGame) || saveMutation.isPending
|| reviewCorrectPostId === null || questionSuggestionMutation.isPending}
|| (
questionSuggestion.trim () === ''
&& selectedSuggestedQuestion === null)
|| saveMutation.isPending
|| questionSuggestionMutation.isPending
}
onClick={submitQuestionSuggestion}> onClick={submitQuestionSuggestion}>
</button> </button>