Spaces:
Sleeping
Sleeping
| 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; | |