diff --git a/public/wordbanks/word-scramble.txt b/public/wordbanks/word-scramble.txt new file mode 100644 index 0000000..38ee883 --- /dev/null +++ b/public/wordbanks/word-scramble.txt @@ -0,0 +1,163 @@ +react +javascript +component +function +frontend +context +state +effect +variable +programming +apple +banana +orange +mango +grape +peach +cherry +berry +lemon +lime +melon +apricot +avocado +broccoli +carrot +celery +tomato +potato +onion +garlic +pepper +spinach +lettuce +cabbage +cucumber +pumpkin +zucchini +squash +mushroom +almond +cashew +walnut +peanut +pistachio +cereal +biscuit +cookie +donut +muffin +pancake +waffle +crepe +burger +pizza +pasta +sushi +taco +curry +salad +sandwich +omelet +steak +chicken +turkey +salmon +tuna +cod +shrimp +lobster +crab +island +planet +galaxy +universe +rocket +saturn +jupiter +mercury +venus +ear +airplane +airport +station +bridge +tunnel +harbor +library +museum +gallery +theatre +concert +festival +holiday +vacation +adventure +journey +trek +hiking +camping +backpack +camera +picture +portrait +landscape +sunset +sunrise +mountain +valley +river +ocean +beach +desert +forest +jungle +savanna +volcano +earthquake +weather +climate +season +spring +summer +autumn +winter +school +college +university +teacher +student +lecture +exam +homework +assignment +project +science +physics +chemistry +biology +math +algebra +geometry +calculus +history +geography +politics +economy +finance +market +business +startup +product +service +coding +developer +debugger +tester +design +ux +ui +prototype +mockup +logo +brand diff --git a/src/assets/games/WordScramble/word_scramble.png b/src/assets/games/WordScramble/word_scramble.png new file mode 100644 index 0000000..2235b81 Binary files /dev/null and b/src/assets/games/WordScramble/word_scramble.png differ diff --git a/src/data/content.js b/src/data/content.js index 742bb0f..1652e19 100644 --- a/src/data/content.js +++ b/src/data/content.js @@ -8,6 +8,7 @@ import { FortuneCard } from "../pages/activities/FotuneCard"; import { SearchWord } from "../pages/activities/getDefinition"; import { Jitter } from "../pages/games/Jitter"; import { RandomMeme } from "../pages/activities/RandomMemes"; +import { WordScramble } from "../pages/games/WordScramble"; // Use wrapper components that include favorite button functionality import { RandomJokeWithFav } from "../pages/activities_wrappers/RandomJokeWithFav"; import { RandomAnimeQuoteWithFav } from "../pages/activities_wrappers/RandomAnimeQuoteWithFav"; @@ -26,6 +27,7 @@ import { DogHttpCode } from "../pages/activities/DogHttpCode"; import { CatHttpCode } from "../pages/activities/CatHttpCode"; import FlappyBird from "../pages/games/FlappyBird"; import RandomIdentity from "../pages/activities/RandomIdentity"; +import word_scramble_icon from "../assets/games/WordScramble/word_scramble.png"; export const activities = [ { @@ -170,5 +172,12 @@ export const games = [ icon: "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSap9rEhSD7ghcjTSYN6HuXx0wejnzigvKncg&s", urlTerm: "FlappyBird", element: , + }, + { + title: "Word Scramble", + description: "Word chaos! Can you fix it?", + icon: word_scramble_icon, + urlTerm: "WordScramble", + element: , } ]; \ No newline at end of file diff --git a/src/pages/games/WordScramble.js b/src/pages/games/WordScramble.js new file mode 100644 index 0000000..162973e --- /dev/null +++ b/src/pages/games/WordScramble.js @@ -0,0 +1,304 @@ +import React, { useState, useEffect } from "react"; +import "../../styles/pages/games/WordScramble.css"; + +export const WordScramble = () => { + // Minimal fallback words (used if local bank fails) + const FALLBACK_WORDS = [ + "react", + "javascript", + "component", + "function", + "frontend", + "context", + "state", + "effect", + "variable", + "programming", + ]; + + const [words, setWords] = useState(FALLBACK_WORDS); + const [word, setWord] = useState(""); + const [scrambledWord, setScrambledWord] = useState(""); + const [userInput, setUserInput] = useState(""); + const [score, setScore] = useState(0); + const [message, setMessage] = useState("Press Start to Play!"); + const [isPlaying, setIsPlaying] = useState(false); + const [gameOver, setGameOver] = useState(false); + const [loading, setLoading] = useState(true); + const [attempts, setAttempts] = useState(0); + const [revealed, setRevealed] = useState(new Set()); + const [showAnswer, setShowAnswer] = useState(false); + const [hintActive, setHintActive] = useState(false); + + // Fisher-Yates shuffle for letters + const shuffleWord = (w) => { + const a = w.split(""); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a.join(""); + }; + + const pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)]; + // how long a revealed hint letter stays visible (ms) + const HINT_DURATION = 4000; + + // Load a local word bank (public/wordbanks/word-scramble.txt) + const fetchLocalWordBank = async (path = "/wordbanks/word-scramble.txt") => { + const res = await fetch(path); + if (!res.ok) throw new Error("Local word bank not found"); + const txt = await res.text(); + return txt + .split(/\r?\n/) + .map((w) => w.trim().toLowerCase()) + .filter(Boolean); + }; + + // Optionally a simple free API fallback (may be rate-limited) + const fetchRandomFromApi = async (number = 10) => { + try { + const url = `https://random-word-api.herokuapp.com/word?number=${number}`; + const res = await fetch(url); + if (!res.ok) throw new Error("API fetch failed"); + const data = await res.json(); + return data.map((w) => String(w).toLowerCase()); + } catch (e) { + console.warn("Random API fallback failed:", e); + return []; + } + }; + + useEffect(() => { + let mounted = true; + + async function load() { + setLoading(true); + try { + let bank = await fetchLocalWordBank(); + + // Filter clean words: letters only and reasonable length + bank = bank.filter((w) => /^[a-z]+$/.test(w) && w.length >= 4 && w.length <= 12); + + if (!bank.length) { + // try API fallback + const apiWords = await fetchRandomFromApi(50); + bank = apiWords.filter((w) => /^[a-z]+$/.test(w) && w.length >= 4 && w.length <= 12); + } + + if (mounted && bank.length) { + setWords(bank); + // preselect first word + const pick = pickRandom(bank); + setWord(pick); + let s = shuffleWord(pick); + if (s === pick && pick.length > 1) s = shuffleWord(pick.split("").reverse().join("")); + setScrambledWord(s); + } + } catch (err) { + console.error("Word bank load failed:", err); + // keep fallback words + } finally { + if (mounted) setLoading(false); + } + } + + load(); + return () => (mounted = false); + }, []); + + const startGame = () => { + if (!words.length) return; + const randomWord = pickRandom(words); + let scrambled = shuffleWord(randomWord); + if (scrambled === randomWord && randomWord.length > 1) + scrambled = shuffleWord(randomWord.split("").reverse().join("")); + + setWord(randomWord); + setScrambledWord(scrambled); + setUserInput(""); + setAttempts(0); + setRevealed(new Set()); + setShowAnswer(false); + setScore(0); + setIsPlaying(true); + setGameOver(false); + setMessage("Unscramble the word!"); + }; + + const checkAnswer = () => { + const guess = userInput.trim().toLowerCase(); + if (!guess) { + setMessage("Please enter a guess before submitting"); + return; + } + + if (guess === word.toLowerCase()) { + setScore((s) => s + 1); + setMessage("✅ Correct! Loading next word..."); + setTimeout(() => { + nextWord(); + }, 700); + } else { + // wrong answer + setAttempts((a) => a + 1); + const newAttempts = attempts + 1; + if (newAttempts >= 3) { + // after 3 wrong attempts, reveal the answer briefly + revealAnswer(); + } else { + setMessage(`❌ Wrong! Attempts: ${newAttempts}/3`); + } + } + }; + + const nextWord = () => { + if (!words.length) return; + const randomWord = pickRandom(words); + let scrambled = shuffleWord(randomWord); + if (scrambled === randomWord && randomWord.length > 1) + scrambled = shuffleWord(randomWord.split("").reverse().join("")); + + setWord(randomWord); + setScrambledWord(scrambled); + setUserInput(""); + setMessage("Unscramble the word!"); + setAttempts(0); + setRevealed(new Set()); + setShowAnswer(false); + setHintActive(false); + }; + + const revealOneLetter = () => { + if (!word) return; + if (hintActive) { + setMessage("Please wait for the current hint to expire"); + return; + } + const indices = [...Array(word.length).keys()].filter((i) => !revealed.has(i)); + // don't reveal the final remaining letter because that would show the whole word + if (indices.length <= 1) { + setMessage("No more hints available without revealing the whole word"); + return; + } + + const pick = indices[Math.floor(Math.random() * indices.length)]; + const nextSet = new Set(revealed); + nextSet.add(pick); + setRevealed(nextSet); + setMessage(`Hint: one letter revealed (visible for ${Math.round(HINT_DURATION/1000)}s)`); + + // mark hint as active and remove the revealed letter after HINT_DURATION so it's temporary + setHintActive(true); + setTimeout(() => { + setRevealed((prev) => { + const copy = new Set(prev); + copy.delete(pick); + return copy; + }); + setHintActive(false); + }, HINT_DURATION); + }; + + const revealAnswer = () => { + setShowAnswer(true); + setMessage(`Answer: ${word}`); + // then advance to next word after a short pause + setTimeout(() => { + nextWord(); + }, 1200); + }; + + const endGame = () => { + setIsPlaying(false); + setGameOver(true); + setMessage(`Game Over! Your final score: ${score}`); + }; + + // Auto end after 10 points (optional) + useEffect(() => { + if (score >= 10) { + // inline endGame to avoid missing dependency warnings from eslint + setIsPlaying(false); + setGameOver(true); + setMessage(`Game Over! Your final score: ${score}`); + } + }, [score]); + + return ( +
+

Word Scramble

+ + {loading ? ( +

Loading words…

+ ) : isPlaying && !gameOver ? ( +
+
+

Score: {score}

+

{message}

+
+

{scrambledWord}

+
+ {showAnswer + ? word + : word + .split("") + .map((ch, i) => (revealed.has(i) ? ch : "_")).join(" ")} +
+ setUserInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && checkAnswer()} + /> +
+ + + + +
+
+ ) : ( +
+ {!isPlaying ? ( +
+
+

How to play

+
    +
  1. Unscramble the letters to form a valid English word.
  2. +
  3. Type your guess and press Submit or hit Enter.
  4. +
  5. Use Hint to reveal one letter temporarily (cooldown applies).
  6. +
  7. After 3 wrong attempts the answer will be revealed automatically.
  8. +
  9. Each correct answer gives +1 point. The game ends at 10 points.
  10. +
+
+ +
+ ) : ( + + )} +
+ )} +
+ ); +}; + +export default WordScramble; diff --git a/src/styles/pages/games/WordScramble.css b/src/styles/pages/games/WordScramble.css new file mode 100644 index 0000000..1020238 --- /dev/null +++ b/src/styles/pages/games/WordScramble.css @@ -0,0 +1,220 @@ +.word-scramble-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + min-height: 100vh; + padding: 20px 14px 40px 14px; + background: linear-gradient(180deg, #f7fbff 0%, #eef6fb 100%); + color: #0b1220; + font-family: "Poppins", system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial; + text-align: center; +} + +.word-scramble-container h1 { + font-size: 1.9rem; + margin: 18px 0 20px 0; + font-weight: 700; + color: #0b1220; + line-height: 1.1; + z-index: 1; +} + +/* align score and message to match the game card width */ +.game-info { + max-width: 560px; + width: 100%; + margin: 8px auto 14px auto; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 0 12px; + box-sizing: border-box; +} + +/* when game-info is inside the card keep it full width and small bottom margin */ +.scramble-box .game-info { margin: 0 0 12px 0; padding: 0; } + +.score { + background: #ffffff; + padding: 6px 12px; + border-radius: 999px; + font-weight: 600; + color: #0b1220; + border: 1px solid rgba(15,17,36,0.04); + display: inline-block; + min-width: 96px; + text-align: center; +} + +.message { + font-size: 0.95rem; + color: #223045; + text-align: right; + flex: 1; +} + +.scramble-box { + background: #ffffff; + border-radius: 10px; + padding: 18px 18px; + box-shadow: 0 6px 14px rgba(14, 30, 60, 0.06); + max-width: 560px; + width: 100%; + margin-top: 72px; /* increase space between header and card */ + position: relative; + padding-top: 56px; /* extra space for score/message inside header area */ +} + +.scrambled-word { + font-size: 1.6rem; + letter-spacing: 5px; + margin: 4px 0 8px 0; + font-weight: 700; + text-transform: uppercase; + color: #10243a; + display: inline-block; + padding: 6px 10px; + border-radius: 6px; + background: #f7fafc; +} + +.hint-pattern { + font-family: "Courier New", monospace; + letter-spacing: 6px; + color: #10243a; + margin-bottom: 8px; + font-size: 1rem; + text-align: center; +} + +input { + padding: 10px 12px; + font-size: 1rem; + border-radius: 8px; + border: 1px solid rgba(15,17,36,0.06); + outline: none; + width: 240px; + height: 44px; + line-height: 22px; + box-sizing: border-box; + text-align: center; + background: #fff; + color: #10243a; + transition: box-shadow 120ms ease, transform 100ms ease; +} + +input:focus { box-shadow: 0 6px 16px rgba(16,36,58,0.06); transform: translateY(-1px); } + +.controls { + margin-top: 12px; + display: flex; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +.controls button { + cursor: pointer; + padding: 9px 14px; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 700; + transition: transform 100ms ease, opacity 120ms ease; +} + +.controls button:active { transform: translateY(1px); } +.controls button:disabled { opacity: 0.5; cursor: not-allowed; } + +.start-btn { + background: #0ea34a; + color: #fff; + padding: 9px 14px; + border-radius: 8px; + font-weight: 700; + font-size: 0.95rem; + border: none; + box-shadow: none; + transition: transform 100ms ease, opacity 120ms ease; + cursor: pointer; +} +.start-btn:hover { opacity: 0.95; } +.start-btn:active { transform: translateY(1px); } +.start-btn:focus { outline: 3px solid rgba(16,36,58,0.06); outline-offset: 2px; } +.check-btn { background: #f5b400; color: #10243a; } +.skip-btn { background: #2b9fd8; color: #fff; } +.end-btn { background: #e14b4b; color: #fff; } + +/* Rules / pre-play area */ +.preplay { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.rules-box { + background: linear-gradient(180deg, #ffffff, #fbfdff); + border: 1px solid rgba(15,17,36,0.04); + box-shadow: 0 6px 14px rgba(14,30,60,0.04); + padding: 12px 16px; + border-radius: 8px; + max-width: 560px; + width: calc(100% - 40px); + text-align: left; + color: #0b1220; +} +.rules-box h3 { margin: 0 0 8px 0; font-size: 1.05rem; } +.rules-box ol { margin: 0; padding-left: 18px; font-size: 0.95rem; color: #34495e; } +.rules-box li { margin: 6px 0; } + +@media (max-width: 600px) { + .scrambled-word { font-size: 1.4rem; letter-spacing: 4px; } + input { width: 180px; } + .scramble-box { padding: 16px; padding-top: 52px; margin-top: 56px; } + .game-info { padding: 0 8px; } +} + +/* mobile tweak for floating score pill */ +@media (max-width: 420px) { + .scramble-box .score { + top: -22px; + padding: 6px 12px; + min-width: 80px; + } + .scramble-box { margin-top: 56px; } +} + +@media (max-width: 600px) { + .rules-box { width: calc(100% - 28px); padding: 10px 12px; } + .rules-box ol { font-size: 0.92rem; } +} + +/* Positioning for score/message when inside the card header */ +.scramble-box .score { + position: absolute; + top: -26px; /* float slightly higher above the card */ + left: 50%; + transform: translateX(-50%); + margin: 0; + min-width: 96px; + padding: 8px 18px; + background: #ffffff; + border-radius: 999px; + box-shadow: 0 8px 20px rgba(16,36,58,0.12); + border: 1px solid rgba(15,17,36,0.06); + font-weight: 700; + color: #0b1220; + z-index: 5; + text-align: center; +} + +.scramble-box .message { + position: absolute; + top: 28px; /* place message below the highlighted score */ + left: 50%; + transform: translateX(-50%); + margin: 0; + text-align: center; + width: calc(100% - 40px); +} + +/* subtle focus outline for accessibility */ +button:focus, input:focus { outline: 3px solid rgba(16,36,58,0.06); outline-offset: 2px; } +