to-where / src /App.tsx
elyor-ml's picture
to where game
0f4962a
import { useState, useEffect, useRef } from 'react'
import './App.css'
// Question interface
interface Question {
file: string;
who: string;
doing: string;
what: string;
to_where: string;
distractors: string[];
}
// State for one round
interface GameState {
frames: string[];
frameIndex: number;
who: string;
doing: string;
what: string;
answer: string;
choices: string[];
}
function App() {
const [questions, setQuestions] = useState<Question[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [gameState, setGameState] = useState<GameState | null>(null);
const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight });
const [feedbackMsg, setFeedbackMsg] = useState('');
const [isAnimating, setIsAnimating] = useState(false);
const [animationElement, setAnimationElement] = useState<{ content: string, position: { x: number, y: number } } | null>(null);
const [isFilled, setIsFilled] = useState(false);
const wrongMessages = ["Wrong, try again", "Not correct, try again", "Error, come again"];
const animationRef = useRef<HTMLDivElement>(null);
const destinationRef = useRef<HTMLDivElement>(null);
// Colors
const ORANGE = "#FF8C00";
const YELLOW = "#F0E61E";
const GREEN = "#00B900";
const _UNUSED_PINK_ORIGINAL = "#FF69B4"; // Keeping original for context, effectively replaced
const _UNUSED_BLUE_ORIGINAL = "#007BFF"; // Keeping original for context, effectively replaced
const LIGHT_BLUE = "#87CEEB"; // New light blue color
const PINK = LIGHT_BLUE; // PINK is now LIGHT_BLUE for animation
const BLUE = LIGHT_BLUE; // BLUE is now LIGHT_BLUE for the target box
// Load questions on mount
useEffect(() => {
const loadQuestions = async () => {
try {
const response = await fetch('/to-where.json');
if (!response.ok) {
throw new Error('Failed to load questions');
}
const data: Question[] = await response.json();
// Shuffle questions
const shuffled = [...data].sort(() => Math.random() - 0.5);
setQuestions(shuffled);
} catch (error) {
console.error('Error loading questions:', error);
}
};
loadQuestions();
// Set up window resize handler
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Prepare game state when questions load or change
useEffect(() => {
if (questions.length > 0) {
prepareRound(questions[currentQuestionIndex]);
}
}, [questions, currentQuestionIndex]);
// Handle video/image frame updates
useEffect(() => {
if (!gameState || !gameState.frames.length) return;
const frameInterval = setInterval(() => {
if (!isAnimating && !isFilled) {
setGameState(prev => {
if (!prev) return prev;
const nextIndex = (prev.frameIndex + 1) % prev.frames.length;
return { ...prev, frameIndex: nextIndex };
});
}
}, 100); // Adjust frame rate as needed
return () => clearInterval(frameInterval);
}, [gameState, isAnimating, isFilled]);
// Process animation completion and next question timing
useEffect(() => {
if (isFilled) {
const timer = setTimeout(() => {
// Move to next question
setCurrentQuestionIndex(prev => (prev + 1) % questions.length);
setIsFilled(false);
setFeedbackMsg('');
setAnimationElement(null);
}, 1000);
return () => clearTimeout(timer);
}
}, [isFilled, questions.length]);
// Animation effect
useEffect(() => {
if (isAnimating && animationElement && animationRef.current && destinationRef.current) {
const destRect = destinationRef.current.getBoundingClientRect();
const targetPosition = {
x: destRect.left + destRect.width / 2,
y: destRect.top + destRect.height / 2
};
const anim = animationRef.current;
anim.style.transition = 'transform 500ms linear';
anim.style.transform = `translate(${targetPosition.x - animationElement.position.x}px, ${targetPosition.y - animationElement.position.y}px)`;
const handleAnimationEnd = () => {
setIsAnimating(false);
setIsFilled(true);
};
anim.addEventListener('transitionend', handleAnimationEnd);
return () => anim.removeEventListener('transitionend', handleAnimationEnd);
}
}, [isAnimating, animationElement]);
// Prepare a new game round
const prepareRound = (question: Question) => {
// Shuffle the distractors first, then take two
const shuffledDistractors = [...question.distractors].sort(() => Math.random() - 0.5);
const selectedDistractors = shuffledDistractors.slice(0, 2);
const choices = [question.to_where, ...selectedDistractors];
// Shuffle the final 3 choices
const shuffledChoices = [...choices].sort(() => Math.random() - 0.5);
// Determine if media is image or video
const isVideo = question.file.endsWith('.mp4');
const frames = isVideo
? [question.file] // For simplicity, we'll just use the path for videos
: [question.file]; // Same for images
setGameState({
frames,
frameIndex: 0,
who: question.who,
doing: question.doing,
what: question.what,
answer: question.to_where,
choices: shuffledChoices
});
setFeedbackMsg('');
setIsAnimating(false);
setAnimationElement(null);
setIsFilled(false);
};
// Handle option selection
const handleOptionClick = (option: string, event: React.MouseEvent<HTMLDivElement>) => {
if (isAnimating || isFilled || !gameState) return;
const rect = event.currentTarget.getBoundingClientRect();
const position = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
if (option === gameState.answer) {
// Correct answer
setAnimationElement({
content: option,
position
});
setIsAnimating(true);
setFeedbackMsg('');
} else {
// Wrong answer
setFeedbackMsg(wrongMessages[Math.floor(Math.random() * wrongMessages.length)]);
}
};
// Render current media (image or video)
const renderMedia = () => {
if (!gameState || !gameState.frames.length) return null;
const currentFrame = gameState.frames[gameState.frameIndex];
const isVideo = currentFrame.endsWith('.mp4');
if (isVideo) {
return (
<video
className="game-media"
src={currentFrame}
autoPlay
loop
muted
/>
);
} else {
return (
<img
className="game-media"
src={currentFrame}
alt="Game visual"
/>
);
}
};
if (!gameState) {
return <div className="loading">Loading questions...</div>;
}
return (
<div className="colorful-semantics">
<div className="big-question" style={{ color: LIGHT_BLUE }}>To where? →</div>
<div className="media-container">
{renderMedia()}
</div>
<div className="semantic-boxes">
{/* Who - Orange */}
<div className="semantic-box" style={{ backgroundColor: ORANGE }}>
{gameState.who}
</div>
{/* Doing - Yellow */}
<div className="semantic-box" style={{ backgroundColor: YELLOW }}>
{gameState.doing}
</div>
{/* What - Green */}
{gameState.what && (
<div className="semantic-box" style={{ backgroundColor: GREEN }}>
{gameState.what}
</div>
)}
{/* To Where - Blue (now Light Blue) */}
<div
className="semantic-box"
style={{ backgroundColor: LIGHT_BLUE }}
ref={destinationRef}
>
{isFilled ? gameState.answer : "to where? →"}
</div>
</div>
<div className="options-container">
{!isFilled && gameState.choices.map((option, index) => (
<div
key={index}
className="option"
onClick={(e) => handleOptionClick(option, e)}
>
{option}
</div>
))}
</div>
{feedbackMsg && (
<div className="feedback-message">{feedbackMsg}</div>
)}
{isAnimating && animationElement && (
<div
className="flying-option"
ref={animationRef}
style={{
backgroundColor: LIGHT_BLUE,
position: 'fixed',
left: animationElement.position.x,
top: animationElement.position.y,
transform: 'translate(-50%, -50%)'
}}
>
{animationElement.content}
</div>
)}
</div>
);
}
export default App;