|
| 1 | +import React, { useState, useEffect, useRef, useContext } from 'react'; |
| 2 | +import { Link } from 'react-router-dom'; |
| 3 | +import { ArrowLeftIcon, LightbulbIcon, ArrowCounterClockwiseIcon, LadderSimpleIcon } from '@phosphor-icons/react'; |
| 4 | +import useSeo from '../../hooks/useSeo'; |
| 5 | +import { ToastContext } from '../../context/ToastContext'; |
| 6 | + |
| 7 | +// Placeholder dictionary - will be moved or loaded dynamically later |
| 8 | +const DICTIONARY = new Set([ |
| 9 | + 'able', 'acid', 'aged', 'also', 'area', 'army', 'away', 'baby', 'back', 'ball', |
| 10 | + 'band', 'bank', 'bare', 'bark', 'barn', 'base', 'bath', 'bear', 'beat', 'bell', |
| 11 | + 'bend', 'best', 'bill', 'bird', 'bite', 'black', 'blow', 'blue', 'boat', 'body', |
| 12 | + 'bomb', 'bond', 'bone', 'born', 'boss', 'both', 'bowl', 'boys', 'bulk', 'burn', |
| 13 | + 'bush', 'busy', 'call', 'calm', 'came', 'camp', 'card', 'care', 'case', 'cash', |
| 14 | + 'cast', 'cell', 'cent', 'chef', 'chip', 'city', 'club', 'coal', 'coat', |
| 15 | + 'code', 'cold', 'come', 'cook', 'cool', 'copy', 'core', 'cost', 'crew', 'crop', 'dark', |
| 16 | + 'data', 'dawn', 'dead', 'deal', 'dean', 'dear', 'deep', 'deny', 'desk', 'dial', |
| 17 | + 'diet', 'disc', 'dish', 'does', 'done', 'door', 'down', 'draw', 'drew', 'drop', |
| 18 | + 'drug', 'dump', 'dust', 'earn', 'east', 'easy', 'edge', 'else', 'even', 'ever', |
| 19 | + 'face', 'fact', 'fail', 'fair', 'fall', 'farm', 'fast', 'fear', 'feed', 'feel', |
| 20 | + 'feet', 'fell', 'felt', 'file', 'fill', 'film', 'find', 'fine', 'fire', 'firm', |
| 21 | + 'fish', 'five', 'flat', 'flex', 'flow', 'foot', 'form', 'fort', 'four', 'free', |
| 22 | + 'from', 'full', 'fund', 'gain', 'game', 'gate', 'gave', 'gear', 'gene', 'gift', |
| 23 | + 'girl', 'give', 'glad', 'goal', 'goes', 'gold', 'gone', 'good', 'gray', 'grow', |
| 24 | + 'grew', 'hair', 'half', 'hall', 'hand', 'hang', 'hard', 'have', 'head', 'hear', |
| 25 | + 'heat', 'held', 'hell', 'help', 'here', 'hero', 'hide', 'high', 'hill', 'hire', |
| 26 | + 'hold', 'hole', 'home', 'hope', 'host', 'hour', 'huge', 'hung', 'hunt', 'hurt', |
| 27 | + 'idea', 'inch', 'into', 'iron', 'item', 'join', 'jump', 'just', 'keep', 'kept', |
| 28 | + 'kick', 'kill', 'kind', 'king', 'knee', 'knew', 'know', 'lack', 'lady', 'laid', |
| 29 | + 'land', 'last', 'late', 'lead', 'leak', 'lean', 'left', 'lend', 'less', |
| 30 | + 'life', 'lift', 'like', 'limp', 'line', 'link', 'live', 'load', 'long', 'look', |
| 31 | + 'lost', 'loud', 'love', 'lows', 'luck', 'made', 'mail', 'main', 'make', 'male', |
| 32 | + 'many', 'mark', 'mass', 'mean', 'meet', 'menu', 'mere', 'mike', 'mile', 'milk', |
| 33 | + 'mill', 'mind', 'mine', 'miss', 'mode', 'mood', 'moon', 'more', 'most', 'move', |
| 34 | + 'much', 'must', 'name', 'near', 'neck', 'need', 'news', 'next', 'nice', 'nine', |
| 35 | + 'none', 'nose', 'note', 'okay', 'once', 'only', 'open', 'onto', 'oral', 'over', |
| 36 | + 'pace', 'pack', 'page', 'paid', 'pain', 'pair', 'pale', 'palm', 'park', 'part', |
| 37 | + 'pass', 'past', 'path', 'peak', 'pick', 'pile', 'pill', 'pipe', 'plan', 'play', |
| 38 | + 'plot', 'plug', 'plus', 'poem', 'poet', 'pole', 'poll', 'pool', 'poor', 'port', |
| 39 | + 'post', 'pull', 'pure', 'push', 'race', 'raid', 'rail', 'rain', 'rank', 'rare', |
| 40 | + 'rate', 'read', 'real', 'rear', 'rely', 'rent', 'rest', 'rice', 'rich', 'ride', |
| 41 | + 'ring', 'rise', 'risk', 'road', 'rock', 'role', 'roll', 'roof', 'room', 'root', |
| 42 | + 'rose', 'rule', 'runs', 'rush', 'safe', 'said', 'sail', 'sale', 'salt', 'same', |
| 43 | + 'sand', 'save', 'says', 'scan', 'seat', 'seed', 'seek', 'seem', 'seen', 'self', |
| 44 | + 'sell', 'send', 'sense', 'sent', 'sets', 'ship', 'shoe', 'shop', 'shot', 'show', |
| 45 | + 'shut', 'sick', 'side', 'sign', 'sing', 'sink', 'site', 'size', 'skin', 'slip', |
| 46 | + 'slow', 'snap', 'snow', 'soft', 'soil', 'sold', 'some', 'song', 'soon', 'sort', |
| 47 | + 'soul', 'soup', 'spot', 'star', 'stay', 'step', 'stop', 'such', 'suit', |
| 48 | + 'sure', 'take', 'talk', 'tall', 'tape', 'task', 'team', 'tear', 'tell', 'tend', |
| 49 | + 'tens', 'test', 'text', 'than', 'that', 'them', 'then', 'they', 'thin', 'this', |
| 50 | + 'thus', 'tied', 'till', 'time', 'tiny', 'tire', 'told', 'tone', 'took', 'tool', |
| 51 | + 'toss', 'tour', 'town', 'tree', 'trip', 'true', 'turn', 'type', 'unit', |
| 52 | + 'upon', 'used', 'user', 'vary', 'vast', 'very', 'view', 'vote', 'wage', 'wait', |
| 53 | + 'walk', 'wall', 'want', 'warm', 'warn', 'wash', 'wave', 'ways', 'weak', 'wear', |
| 54 | + 'week', 'well', 'went', 'were', 'west', 'what', 'when', 'whom', 'wide', 'wife', |
| 55 | + 'wild', 'will', 'wind', 'wine', 'wing', 'wise', 'wish', 'with', 'wood', |
| 56 | + 'word', 'wore', 'work', 'yard', 'yeah', 'year', 'yell', 'your', 'zero', 'zone' |
| 57 | +]); |
| 58 | + |
| 59 | +const generateRandomWord = (length) => { |
| 60 | + const words = Array.from(DICTIONARY).filter(word => word.length === length); |
| 61 | + if (words.length === 0) return ''; |
| 62 | + return words[Math.floor(Math.random() * words.length)]; |
| 63 | +}; |
| 64 | + |
| 65 | +const isOneLetterDifferent = (word1, word2) => { |
| 66 | + if (word1.length !== word2.length) return false; |
| 67 | + let diffCount = 0; |
| 68 | + for (let i = 0; i < word1.length; i++) { |
| 69 | + if (word1[i] !== word2[i]) { |
| 70 | + diffCount++; |
| 71 | + } |
| 72 | + } |
| 73 | + return diffCount === 1; |
| 74 | +}; |
| 75 | + |
| 76 | +const WordLadderPage = () => { |
| 77 | + useSeo({ |
| 78 | + title: 'Word Ladder | Fezcodex', |
| 79 | + description: 'A fun word transformation game where you change one letter at a time.', |
| 80 | + keywords: ['Fezcodex', 'word ladder', 'word game', 'puzzle', 'logic game'], |
| 81 | + ogTitle: 'Word Ladder | Fezcodex', |
| 82 | + ogDescription: 'A fun word transformation game where you change one letter at a time.', |
| 83 | + ogImage: 'https://fezcode.github.io/logo512.png', |
| 84 | + twitterCard: 'summary_large_image', |
| 85 | + twitterTitle: 'Word Ladder | Fezcodex', |
| 86 | + twitterDescription: 'A fun word transformation game where you change one letter at a time.', |
| 87 | + twitterImage: 'https://fezcode.github.io/logo512.png', |
| 88 | + }); |
| 89 | + |
| 90 | + const { addToast } = useContext(ToastContext); |
| 91 | + |
| 92 | + const [startWord, setStartWord] = useState(''); |
| 93 | + const [endWord, setEndWord] = useState(''); |
| 94 | + const [currentGuess, setCurrentGuess] = useState(''); |
| 95 | + const [ladder, setLadder] = useState([]); |
| 96 | + const [gameStatus, setGameStatus] = useState('playing'); // 'playing', 'won', 'lost' |
| 97 | + const [message, setMessage] = useState(''); |
| 98 | + |
| 99 | + const inputRef = useRef(null); |
| 100 | + |
| 101 | + const initGame = (wordLength = 4) => { |
| 102 | + let newStartWord = generateRandomWord(wordLength); |
| 103 | + let newEndWord = generateRandomWord(wordLength); |
| 104 | + |
| 105 | + // Ensure start and end words are different and a path might exist (basic check) |
| 106 | + while (newStartWord === newEndWord || !newStartWord || !newEndWord || !hasPath(newStartWord, newEndWord, DICTIONARY)) { |
| 107 | + newStartWord = generateRandomWord(wordLength); |
| 108 | + newEndWord = generateRandomWord(wordLength); |
| 109 | + } |
| 110 | + |
| 111 | + setStartWord(newStartWord); |
| 112 | + setEndWord(newEndWord); |
| 113 | + setLadder([newStartWord]); |
| 114 | + setGameStatus('playing'); |
| 115 | + setMessage(''); |
| 116 | + setCurrentGuess(''); |
| 117 | + if (inputRef.current) { |
| 118 | + inputRef.current.focus(); |
| 119 | + } |
| 120 | + }; |
| 121 | + |
| 122 | + useEffect(() => { |
| 123 | + initGame(4); // Always initialize with 4-letter words |
| 124 | + }, []); // Empty dependency array means it runs once on mount |
| 125 | + |
| 126 | + // Simple BFS to check if a path exists (expensive for large dictionaries) |
| 127 | + const hasPath = (start, end, dict) => { |
| 128 | + const queue = [{ word: start, path: [start] }]; |
| 129 | + const visited = new Set([start]); |
| 130 | + |
| 131 | + while (queue.length > 0) { |
| 132 | + const { word, path } = queue.shift(); |
| 133 | + |
| 134 | + if (word === end) return true; |
| 135 | + |
| 136 | + for (const dictWord of dict) { |
| 137 | + if (!visited.has(dictWord) && isOneLetterDifferent(word, dictWord)) { |
| 138 | + visited.add(dictWord); |
| 139 | + queue.push({ word: dictWord, path: [...path, dictWord] }); |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + return false; |
| 144 | + }; |
| 145 | + |
| 146 | + const handleGuessSubmit = (e) => { |
| 147 | + e.preventDefault(); |
| 148 | + const guess = currentGuess.toLowerCase().trim(); |
| 149 | + |
| 150 | + if (!guess) { |
| 151 | + addToast({ message: 'Please enter a word.', type: 'error' }); |
| 152 | + return; |
| 153 | + } |
| 154 | + |
| 155 | + if (guess.length !== startWord.length) { |
| 156 | + addToast({ message: `Your guess must be ${startWord.length} letters long.`, type: 'error' }); |
| 157 | + return; |
| 158 | + } |
| 159 | + |
| 160 | + if (ladder.includes(guess)) { |
| 161 | + addToast({ message: 'You already have this word in your ladder.', type: 'warning' }); |
| 162 | + return; |
| 163 | + } |
| 164 | + |
| 165 | + if (!DICTIONARY.has(guess)) { |
| 166 | + addToast({ message: 'Not a valid word in our dictionary.', type: 'error' }); |
| 167 | + return; |
| 168 | + } |
| 169 | + |
| 170 | + const lastWordInLadder = ladder[ladder.length - 1]; |
| 171 | + if (!isOneLetterDifferent(lastWordInLadder, guess)) { |
| 172 | + addToast({ message: `Your guess must differ by only one letter from "${lastWordInLadder}".`, type: 'error' }); |
| 173 | + return; |
| 174 | + } |
| 175 | + |
| 176 | + const newLadder = [...ladder, guess]; |
| 177 | + setLadder(newLadder); |
| 178 | + setCurrentGuess(''); |
| 179 | + |
| 180 | + if (guess === endWord) { |
| 181 | + setGameStatus('won'); |
| 182 | + setMessage(`Congratulations! You've completed the word ladder in ${newLadder.length - 1} steps!`); |
| 183 | + addToast({ message: 'You won!', type: 'success' }); |
| 184 | + } else { |
| 185 | + setMessage(''); |
| 186 | + } |
| 187 | + if (inputRef.current) { |
| 188 | + inputRef.current.focus(); |
| 189 | + } |
| 190 | + }; |
| 191 | + |
| 192 | + const handleResetGame = () => { |
| 193 | + initGame(4); |
| 194 | + }; |
| 195 | + |
| 196 | + const gameControlsDisabled = gameStatus !== 'playing'; |
| 197 | + |
| 198 | + return ( |
| 199 | + <div className="py-16 sm:py-24"> |
| 200 | + <div className="mx-auto max-w-7xl px-6 lg:px-8 text-gray-300"> |
| 201 | + <Link to="/apps" className="text-article hover:underline flex items-center justify-center gap-2 text-lg mb-4"> |
| 202 | + <ArrowLeftIcon size={24} /> Back to Apps |
| 203 | + </Link> |
| 204 | + <h1 className="text-4xl font-bold tracking-tight sm:text-6xl mb-4 flex items-center justify-center"> |
| 205 | + <span className="codex-color">fc</span> |
| 206 | + <span className="separator-color">::</span> |
| 207 | + <span className="apps-color">apps</span> |
| 208 | + <span className="separator-color">::</span> |
| 209 | + <span className="single-app-color">wl</span> |
| 210 | + </h1> |
| 211 | + <hr className="border-gray-700" /> |
| 212 | + <div className="flex justify-center items-center mt-16"> |
| 213 | + <div className="bg-app-alpha-10 border-app-alpha-50 text-app hover:bg-app/15 group border rounded-lg shadow-2xl p-6 flex flex-col justify-between relative transform transition-all duration-300 ease-in-out scale-105 overflow-hidden h-full w-full max-w-2xl"> |
| 214 | + <div className="absolute top-0 left-0 w-full h-full opacity-10" style={{ backgroundImage: 'radial-gradient(circle, white 1px, transparent 1px)', backgroundSize: '10px 10px' }}></div> |
| 215 | + <div className="relative z-10 p-4"> |
| 216 | + <h2 className="text-3xl font-arvo font-normal mb-2 text-app text-center">Word Ladder</h2> |
| 217 | + <p className="text-center text-app-light mb-4"> |
| 218 | + Transform <span className="font-semibold text-purple-600 dark:text-purple-400 text-lg">{startWord}</span> to{' '} |
| 219 | + <span className="font-semibold text-purple-600 dark:text-purple-400 text-lg">{endWord}</span> by changing one letter at a time. |
| 220 | + Each intermediate word must be a valid English word. |
| 221 | + </p> |
| 222 | + <hr className="border-gray-700 mb-6" /> |
| 223 | + <div className="bg-gray-900/50 border border-app-alpha-50 rounded-md p-4 mb-6"> |
| 224 | + <h3 className="text-xl font-bold mb-4 text-app-light text-center">Your Ladder:</h3> |
| 225 | + <div className="flex flex-wrap gap-2 justify-center"> |
| 226 | + {ladder.map((word, index) => ( |
| 227 | + <span |
| 228 | + key={index} |
| 229 | + className={`px-4 py-2 rounded-full text-lg font-mono shadow-md |
| 230 | + ${index === 0 ? 'bg-green-700 text-green-100' : ''} |
| 231 | + ${index === ladder.length - 1 && gameStatus === 'won' ? 'bg-yellow-700 text-yellow-100' : ''} |
| 232 | + ${index > 0 && !(index === ladder.length - 1 && gameStatus === 'won') ? 'bg-blue-700 text-blue-100' : ''} |
| 233 | + `} |
| 234 | + > |
| 235 | + {word} |
| 236 | + </span> |
| 237 | + ))} |
| 238 | + </div> |
| 239 | + </div> |
| 240 | + |
| 241 | + {!gameControlsDisabled && ( |
| 242 | + <form onSubmit={handleGuessSubmit} className="flex flex-col sm:flex-row gap-4 mb-6"> |
| 243 | + <input |
| 244 | + ref={inputRef} |
| 245 | + type="text" |
| 246 | + value={currentGuess} |
| 247 | + onChange={(e) => setCurrentGuess(e.target.value)} |
| 248 | + maxLength={startWord.length} |
| 249 | + className="flex-grow p-3 bg-gray-900/50 font-mono border rounded-md focus:ring-0 text-app-light border-app-alpha-50 text-center" |
| 250 | + placeholder={`Enter a ${startWord.length}-letter word`} |
| 251 | + disabled={gameControlsDisabled} |
| 252 | + /> |
| 253 | + <button |
| 254 | + type="submit" |
| 255 | + className="px-4 py-2 rounded-md font-semibold bg-tb text-app border-app-alpha-50 hover:bg-app/15 border flex items-center gap-2 justify-center" |
| 256 | + disabled={gameControlsDisabled} |
| 257 | + > |
| 258 | + <LadderSimpleIcon size={24} /> Submit Guess |
| 259 | + </button> |
| 260 | + </form> |
| 261 | + )} |
| 262 | + |
| 263 | + {message && ( |
| 264 | + <div |
| 265 | + className={`p-4 rounded-md text-center font-semibold text-lg my-4 |
| 266 | + ${gameStatus === 'won' ? 'bg-green-700 text-green-100' : 'bg-red-700 text-red-100'} |
| 267 | + `} |
| 268 | + > |
| 269 | + {message} |
| 270 | + </div> |
| 271 | + )} |
| 272 | + |
| 273 | + <div className="flex justify-center mt-4"> |
| 274 | + <button |
| 275 | + onClick={handleResetGame} |
| 276 | + className="px-4 py-2 rounded-md font-semibold bg-tb text-app border-app-alpha-50 hover:bg-app/15 border flex items-center gap-2" |
| 277 | + > |
| 278 | + <ArrowCounterClockwiseIcon size={24} /> New Game |
| 279 | + </button> |
| 280 | + </div> |
| 281 | + |
| 282 | + <div className="bg-app-alpha-10 border border-app-alpha-50 text-app p-4 mt-8 rounded-md"> |
| 283 | + <div className="flex items-center mb-2"> |
| 284 | + <LightbulbIcon size={24} className="mr-3" /> |
| 285 | + <p className="font-bold">How to Play:</p> |
| 286 | + </div> |
| 287 | + <ul className="list-disc list-inside ml-5 text-sm"> |
| 288 | + <li>Start with the given word.</li> |
| 289 | + <li>Change one letter at a time to form a new valid word.</li> |
| 290 | + <li>Continue until you reach the target word.</li> |
| 291 | + <li>Each intermediate word must be a valid English word.</li> |
| 292 | + </ul> |
| 293 | + </div> |
| 294 | + </div> |
| 295 | + </div> |
| 296 | + </div> |
| 297 | + </div> |
| 298 | + </div> |
| 299 | + ); |
| 300 | +}; |
| 301 | + |
| 302 | +export default WordLadderPage; |
0 commit comments