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