Skip to content

Commit cb41efe

Browse files
committed
feat: implement interactive MBTI loading screen with sequential animations
1 parent f1f441f commit cb41efe

3 files changed

Lines changed: 189 additions & 24 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
'use client';
2+
3+
import React, { useEffect, useState, useMemo } from 'react';
4+
import { motion, AnimatePresence } from 'framer-motion';
5+
import Image from 'next/image';
6+
7+
interface LoadingScreenProps {
8+
message?: string;
9+
}
10+
11+
const MBTI_GROUPS = [
12+
{
13+
name: 'Analysts',
14+
color: '#E0D7FF', // Purple-ish
15+
textColor: '#5D2FB7',
16+
characters: ['INTJ', 'INTP', 'ENTJ', 'ENTP'],
17+
},
18+
{
19+
name: 'Diplomats',
20+
color: '#D7FFD7', // Green-ish
21+
textColor: '#2D812D',
22+
characters: ['INFJ', 'INFP', 'ENFJ', 'ENFP'],
23+
},
24+
{
25+
name: 'Sentinels',
26+
color: '#D7F3FF', // Blue-ish
27+
textColor: '#2B6DA1',
28+
characters: ['ISTJ', 'ISFJ', 'ESTJ', 'ESFJ'],
29+
},
30+
{
31+
name: 'Explorers',
32+
color: '#FFF7D7', // Yellow-ish
33+
textColor: '#A17D1F',
34+
characters: ['ISTP', 'ISFP', 'ESTP', 'ESFP'],
35+
},
36+
];
37+
38+
export default function LoadingScreen({
39+
message = 'Loading...',
40+
}: LoadingScreenProps) {
41+
const [activeStep, setActiveStep] = useState(0);
42+
43+
// Pick one random character from each group
44+
const selectedCharacters = useMemo(() => {
45+
return MBTI_GROUPS.map((group) => {
46+
const randomIndex = Math.floor(Math.random() * group.characters.length);
47+
return {
48+
id: group.characters[randomIndex],
49+
groupColor: group.color,
50+
textColor: group.textColor,
51+
};
52+
});
53+
}, []);
54+
55+
useEffect(() => {
56+
const timer = setInterval(() => {
57+
setActiveStep((prev) => (prev + 1) % selectedCharacters.length);
58+
}, 1500); // Wait for jump animation to mostly complete
59+
60+
return () => clearInterval(timer);
61+
}, [selectedCharacters.length]);
62+
63+
return (
64+
<motion.div
65+
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center overflow-hidden"
66+
initial={{ backgroundColor: selectedCharacters[0].groupColor }}
67+
animate={{ backgroundColor: selectedCharacters[activeStep].groupColor }}
68+
transition={{ duration: 0.8 }}
69+
>
70+
{/* Retro-pop dot pattern background */}
71+
<div
72+
className="absolute inset-0 opacity-40"
73+
style={{
74+
backgroundImage: 'radial-gradient(circle, #fff 2px, transparent 2px)',
75+
backgroundSize: '24px 24px',
76+
}}
77+
/>
78+
79+
<div className="relative z-10 flex flex-col items-center">
80+
{/* Characters Row */}
81+
<div className="mb-12 flex items-end justify-center gap-4 sm:gap-8">
82+
{selectedCharacters.map((char, index) => {
83+
const isActive = index === activeStep;
84+
85+
return (
86+
<div
87+
key={char.id}
88+
className="relative flex flex-col items-center"
89+
>
90+
<motion.div
91+
animate={
92+
isActive
93+
? {
94+
y: [0, -60, 0],
95+
scale: [1, 1.1, 1],
96+
}
97+
: { y: 0, scale: 0.9 }
98+
}
99+
transition={{
100+
duration: 0.6,
101+
ease: 'easeOut',
102+
}}
103+
className="relative h-24 w-24 sm:h-32 sm:w-32"
104+
>
105+
<Image
106+
src={`/images/mbti/${char.id}.png`}
107+
alt={char.id}
108+
fill
109+
className="object-contain"
110+
priority
111+
/>
112+
</motion.div>
113+
114+
{/* Visual indicator / shadow under active char */}
115+
<motion.div
116+
className="mt-2 h-2 rounded-full bg-black/10"
117+
animate={
118+
isActive
119+
? { width: ['40%', '20%', '40%'], opacity: [0.2, 0.1, 0.2] }
120+
: { width: '40%', opacity: 0.2 }
121+
}
122+
transition={{ duration: 0.6 }}
123+
/>
124+
</div>
125+
);
126+
})}
127+
</div>
128+
129+
{/* Loading Text */}
130+
<div className="relative">
131+
<motion.p
132+
key={activeStep}
133+
initial={{ y: 10, opacity: 0 }}
134+
animate={{ y: 0, opacity: 1 }}
135+
className="text-4xl font-black italic tracking-wider sm:text-5xl"
136+
style={{
137+
color: 'black',
138+
WebkitTextStroke: '2px white',
139+
textShadow: '4px 4px 0px rgba(0,0,0,0.1)',
140+
}}
141+
>
142+
{message}
143+
</motion.p>
144+
145+
{/* Pulsing dots indicator */}
146+
<div className="mt-4 flex justify-center gap-2">
147+
{[0, 1, 2].map((i) => (
148+
<motion.div
149+
key={i}
150+
animate={{
151+
scale: [1, 1.5, 1],
152+
opacity: [0.3, 1, 0.3],
153+
}}
154+
transition={{
155+
duration: 0.8,
156+
repeat: Infinity,
157+
delay: i * 0.2,
158+
}}
159+
className="h-3 w-3 rounded-full bg-current"
160+
style={{ color: selectedCharacters[activeStep].textColor }}
161+
/>
162+
))}
163+
</div>
164+
</div>
165+
</div>
166+
167+
{/* Group Name display (Subtle) */}
168+
<div className="absolute bottom-8 left-0 right-0 flex justify-center">
169+
<motion.p
170+
key={activeStep}
171+
initial={{ opacity: 0 }}
172+
animate={{ opacity: 0.4 }}
173+
className="text-sm font-bold uppercase tracking-[0.3em] text-black"
174+
>
175+
{MBTI_GROUPS[activeStep].name}
176+
</motion.p>
177+
</div>
178+
</motion.div>
179+
);
180+
}

frontend/src/features/diagnosis/components/BaselineSurvey.tsx

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
type BaselineAnswers,
1212
type AnswerOption,
1313
} from '@/features/diagnosis/types';
14-
import Spinner from '@/components/ui/Spinner';
14+
import Spinner from '@/components/common/Spinner';
15+
import LoadingScreen from '@/components/common/LoadingScreen';
1516
import { postRegister, submitGame } from '@/lib/api';
1617

1718
type Status = 'answering' | 'loading' | 'error' | 'success';
@@ -79,11 +80,7 @@ export default function BaselineSurvey() {
7980
};
8081

8182
if (status === 'loading') {
82-
return (
83-
<div className="flex w-full max-w-md flex-col items-center gap-4">
84-
<Spinner message="送信中..." />
85-
</div>
86-
);
83+
return <LoadingScreen message="送信中..." />;
8784
}
8885

8986
if (status === 'error') {
@@ -101,19 +98,7 @@ export default function BaselineSurvey() {
10198
}
10299

103100
if (status === 'success') {
104-
return (
105-
<div className="flex w-full max-w-md flex-col items-center gap-4">
106-
<h2 className="text-2xl font-bold">診断完了!</h2>
107-
<p className="text-center text-gray-600">
108-
これからゲームが始まります。
109-
<br />
110-
ゲームでのあなたの行動から、本当の性格を分析します。
111-
</p>
112-
<p className="text-sm text-gray-400">
113-
まもなくゲーム画面に移動します...
114-
</p>
115-
</div>
116-
);
101+
return <LoadingScreen message="ゲームに移動中..." />;
117102
}
118103

119104
return (
@@ -132,9 +117,8 @@ export default function BaselineSurvey() {
132117
{Array.from({ length: totalQuestions }).map((_, i) => (
133118
<div
134119
key={i}
135-
className={`h-6 w-8 rounded-lg transition-colors ${
136-
i <= currentIndex ? 'bg-rose-400' : 'bg-gray-200'
137-
}`}
120+
className={`h-6 w-8 rounded-lg transition-colors ${i <= currentIndex ? 'bg-rose-400' : 'bg-gray-200'
121+
}`}
138122
/>
139123
))}
140124
</div>

frontend/src/features/result/components/ResultPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { resultAtom } from '@/stores/result';
55
import { useResult } from '../hooks/useResult';
66
import AnalyzingView from './AnalyzingView';
77
import ResultReport from './ResultReport';
8+
import LoadingScreen from '@/components/common/LoadingScreen';
89

910
export default function ResultPage() {
1011
const { status, errorMessage, retry } = useResult();
1112
const result = useAtomValue(resultAtom);
1213

1314
if (status === 'loading') {
14-
return <AnalyzingView status="loading" />;
15+
return <LoadingScreen message="分析中..." />;
1516
}
1617

1718
if (status === 'error') {
@@ -28,5 +29,5 @@ export default function ResultPage() {
2829
return <ResultReport data={result} />;
2930
}
3031

31-
return <AnalyzingView status="loading" />;
32+
return <LoadingScreen message="分析中..." />;
3233
}

0 commit comments

Comments
 (0)