diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1e2dd6c8c95e0bb8d1b7194db90598b49229b265 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +img-gen/ + diff --git a/App.css b/App.css new file mode 100644 index 0000000000000000000000000000000000000000..cf5e861a8de5a7b43b553e96774ed55b375e46e9 --- /dev/null +++ b/App.css @@ -0,0 +1,148 @@ +.colorful-semantics { + max-width: 1100px; + margin: 0 0 0 auto; + padding: 2rem; + text-align: center; + font-family: 'Arial', sans-serif; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + font-size: 1.5rem; + font-weight: bold; +} + +.media-container { + position: relative; + width: 100%; + max-width: 900px; + height: 55vh; + max-height: 500px; + margin: 0 auto 3rem; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + border-radius: 12px; + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.25); + background-color: #f0f0f0; +} + +.game-media { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.semantic-boxes { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: nowrap; + margin-bottom: 2rem; +} + +.semantic-box { + /* Let width grow/shrink based on content so long phrases stay on one line */ + width: auto; + min-width: 140px; + padding: 1.4rem 1.6rem; + border-radius: 10px; + border: 3px solid #000; + font-size: 1.6rem; + font-weight: bold; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15); + display: flex; + justify-content: center; + align-items: center; + min-height: 90px; + text-align: center; + white-space: nowrap; /* prevent text from wrapping to a new line */ +} + +.options-container { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; + margin-top: 2rem; +} + +.option { + background-color: #d3d3d3; + border: 3px solid #000; + border-radius: 8px; + padding: 1rem 1.5rem; + font-size: 1.3rem; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; + min-width: 140px; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); +} + +.option:hover { + transform: scale(1.05); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.flying-option { + padding: 0.8rem 1.2rem; + border: 2px solid #000; + border-radius: 6px; + font-size: 1.1rem; + pointer-events: none; + z-index: 100; + min-width: 120px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + white-space: nowrap; + display: flex; + justify-content: center; + align-items: center; +} + +.feedback-message { + color: #ff3c3c; + font-size: 1.2rem; + font-weight: bold; + margin-top: 1.5rem; + min-height: 28px; +} + +.big-question { + position: fixed; + right: 220px; + top: 30%; + font-size: 6rem; + font-weight: bold; + color: #FF69B4; + text-align: left; + text-shadow: 1px 1px 2px rgba(0,0,0,0.2); + z-index: 10; +} + +@media (max-width: 768px) { + .semantic-boxes { + flex-wrap: wrap; + align-items: center; + } + + .semantic-box { + width: 80%; + } + + .options-container { + flex-direction: column; + align-items: center; + } + + .option { + width: 80%; + } +} diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..845d66d9d1b05f52e366840733936a91f0c6f0c3 --- /dev/null +++ b/App.tsx @@ -0,0 +1,299 @@ +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([]); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [gameState, setGameState] = useState(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(null); + const destinationRef = useRef(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) => { + 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 ( +