Fetching metadata from the HF Docker repository...
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <link rel="preconnect" href="https://unpkg.com" crossorigin /> | |
| <link rel="dns-prefetch" href="//unpkg.com" /> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <title>Semantick</title> | |
| <style> | |
| body { margin: 0; background: #1a1a19; -webkit-tap-highlight-color: transparent; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <script type="text/babel"> | |
| const { useState, useRef, useEffect, useCallback } = React; | |
| const API_BASE = ""; | |
| const LOBBY_SESSION_KEY = "semantick_lobby_session"; | |
| function scoreColor(score) { | |
| return score >= 0.90 ? "#22c55e" : | |
| score >= 0.70 ? "#84cc16" : | |
| score >= 0.50 ? "#eab308" : | |
| score >= 0.30 ? "#f97316" : "#ef4444"; | |
| } | |
| function ScoreBar({ score }) { | |
| const pct = Math.min(score * 100, 100); | |
| const color = scoreColor(score); | |
| return ( | |
| <div style={{ | |
| width: "100%", height: 6, background: "rgba(255,255,255,0.06)", | |
| borderRadius: 3, overflow: "hidden" | |
| }}> | |
| <div style={{ | |
| width: `${pct}%`, height: "100%", background: color, | |
| borderRadius: 3, transition: "width 0.6s cubic-bezier(0.22, 1, 0.36, 1)" | |
| }} /> | |
| </div> | |
| ); | |
| } | |
| function GuessRow({ guess, index, isNew, isBest }) { | |
| return ( | |
| <div className="guess-grid" style={{ | |
| display: "grid", | |
| gridTemplateColumns: "36px 1fr 56px 56px 1fr", | |
| alignItems: "center", | |
| gap: 10, | |
| padding: "10px 16px", | |
| background: isBest ? "rgba(34,197,94,0.08)" : isNew ? "rgba(255,255,255,0.03)" : "transparent", | |
| borderLeft: isBest ? "2px solid #22c55e" : "2px solid transparent", | |
| borderRadius: 4, | |
| animation: isNew ? "slideIn 0.3s ease" : "none", | |
| fontVariantNumeric: "tabular-nums", | |
| }}> | |
| <span style={{ color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace" }}> | |
| {guess.isHint ? "\u2605" : `#${guess.guessNum}`} | |
| </span> | |
| <span style={{ | |
| color: guess.isHint ? "#eab308" : "#e8e6e1", fontSize: 15, | |
| fontFamily: "'DM Mono', monospace", | |
| textTransform: "lowercase", letterSpacing: "0.02em", | |
| fontStyle: guess.isHint ? "italic" : "normal", | |
| }}> | |
| {guess.word} | |
| </span> | |
| <span style={{ | |
| color: scoreColor(guess.score), | |
| fontSize: 15, fontWeight: 600, | |
| fontFamily: "'DM Mono', monospace", textAlign: "right" | |
| }}> | |
| {guess.score.toFixed(2)} | |
| </span> | |
| <span className="rank-col" style={{ | |
| color: "#555", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", textAlign: "right" | |
| }}> | |
| {guess.rank ? `#${guess.rank}` : ""} | |
| </span> | |
| <ScoreBar score={guess.score} /> | |
| </div> | |
| ); | |
| } | |
| function HowToPlay({ show, onToggle }) { | |
| if (!show) return ( | |
| <button | |
| onClick={onToggle} | |
| onMouseDown={e => e.preventDefault()} | |
| style={{ | |
| background: "transparent", border: "none", color: "#666", | |
| fontSize: 12, fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| letterSpacing: "0.05em", padding: "4px 0", marginTop: -8, marginBottom: 8, | |
| }} | |
| > | |
| how to play? | |
| </button> | |
| ); | |
| return ( | |
| <div style={{ | |
| background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 8, padding: "16px 20px", marginBottom: 16, | |
| fontSize: 13, lineHeight: 1.6, color: "#aaa", | |
| fontFamily: "'DM Sans', sans-serif", | |
| }}> | |
| <div style={{ marginBottom: 8 }}> | |
| A secret word has been chosen. Type words to guess it - after each guess you'll see | |
| how <strong style={{ color: "#e8e6e1" }}>semantically similar</strong> your word is to the secret word. | |
| </div> | |
| <div style={{ marginBottom: 8 }}> | |
| <strong style={{ color: "#e8e6e1" }}>Score</strong> (0-1): how close your guess is compared to all words in the dictionary. | |
| Higher is better. <span style={{ color: "#22c55e" }}>Green</span> means very close,{" "} | |
| <span style={{ color: "#ef4444" }}>red</span> means far away. | |
| </div> | |
| <div style={{ marginBottom: 8 }}> | |
| <strong style={{ color: "#e8e6e1" }}>Rank</strong>: your word's position among all ~{"\u2009"}80k words, | |
| sorted by similarity. Rank #1 = the secret word itself. | |
| </div> | |
| <div style={{ marginBottom: 12 }}> | |
| Use <strong style={{ color: "#eab308" }}>hints</strong> to reveal the part of speech, category clues, or similar words at progressively closer ranks (#1000, #100, #10, #9, ... #2). | |
| </div> | |
| <button | |
| onClick={onToggle} | |
| onMouseDown={e => e.preventDefault()} | |
| style={{ | |
| background: "transparent", border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 6, padding: "6px 14px", color: "#888", | |
| fontSize: 12, fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} | |
| > | |
| got it | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // Player color palette for multiplayer | |
| const PLAYER_COLORS = ["#22c55e", "#3b82f6", "#f97316", "#a855f7", "#ec4899", "#14b8a6", "#eab308", "#ef4444"]; | |
| function playerColor(name, players) { | |
| const idx = players.indexOf(name); | |
| return PLAYER_COLORS[idx >= 0 ? idx % PLAYER_COLORS.length : 0]; | |
| } | |
| function buildHintTypeLabels({ | |
| posRevealed, | |
| categoryHints, | |
| definitionText, | |
| definitionDone, | |
| concretenessLabel, | |
| vadLabel, | |
| sensorimotorLabel, | |
| glasgowLabel, | |
| conceptnetHints, | |
| hintsUsed, | |
| }) { | |
| const labels = []; | |
| if (posRevealed) labels.push("Part of Speech"); | |
| if ((categoryHints || []).length > 0) labels.push(`Category x${categoryHints.length}`); | |
| if (definitionText || definitionDone) labels.push("Definition"); | |
| if (concretenessLabel) labels.push("Concreteness"); | |
| if (vadLabel) labels.push("Emotional Tone"); | |
| if (sensorimotorLabel) labels.push("Senses & Action"); | |
| if (glasgowLabel) labels.push("Familiarity & Imagery"); | |
| if ((conceptnetHints || []).length > 0) labels.push(`Semantic Clues x${conceptnetHints.length}`); | |
| if (hintsUsed > 0) labels.push(`Similar Word x${hintsUsed}`); | |
| return labels; | |
| } | |
| function LobbyScreen({ onBack, initialSession, onSessionSave, onSessionClear }) { | |
| const [mode, setMode] = useState(null); // null | "create" | "join" | |
| const [nameInput, setNameInput] = useState(""); | |
| const [codeInput, setCodeInput] = useState(""); | |
| const [error, setError] = useState(""); | |
| const [lobbyCode, setLobbyCode] = useState(null); | |
| const [players, setPlayers] = useState([]); | |
| const [myName, setMyName] = useState(initialSession?.name || ""); | |
| const [playerId, setPlayerId] = useState(initialSession?.playerId || null); | |
| const [isHost, setIsHost] = useState(false); | |
| const [host, setHost] = useState(""); | |
| const [difficulty, setDifficulty] = useState("medium"); | |
| const [lobbyStartHints, setLobbyStartHints] = useState(0); | |
| const [ws, setWs] = useState(null); | |
| const [codeCopied, setCodeCopied] = useState(false); | |
| const [showLeaveLobbyConfirm, setShowLeaveLobbyConfirm] = useState(false); | |
| // Game state (multiplayer) | |
| const [inGame, setInGame] = useState(false); | |
| const [vocabSize, setVocabSize] = useState(0); | |
| const [guesses, setGuesses] = useState([]); | |
| const [guessCount, setGuessCount] = useState(0); | |
| const [input, setInput] = useState(""); | |
| const [loading, setLoading] = useState(false); | |
| const [solved, setSolved] = useState(false); | |
| const [gaveUp, setGaveUp] = useState(false); | |
| const [secretWord, setSecretWord] = useState(""); | |
| const [winner, setWinner] = useState(""); | |
| const [hintsUsed, setHintsUsed] = useState(0); | |
| const [totalHints, setTotalHints] = useState(0); | |
| const [seed, setSeed] = useState(""); | |
| const [showHintMenu, setShowHintMenu] = useState(false); | |
| const [posRevealed, setPosRevealed] = useState(null); | |
| const [categoryHints, setCategoryHints] = useState([]); | |
| const [concretenessLabel, setConcretenessLabel] = useState(null); | |
| const [vadLabel, setVadLabel] = useState(null); | |
| const [sensorimotorLabel, setSensorimotorLabel] = useState(null); | |
| const [glasgowLabel, setGlasgowLabel] = useState(null); | |
| const [definitionText, setDefinitionText] = useState(null); | |
| const [definitionDone, setDefinitionDone] = useState(false); | |
| const [conceptnetHints, setConceptnetHints] = useState([]); | |
| const [hasDefinition, setHasDefinition] = useState(false); | |
| const [hasConcreteness, setHasConcreteness] = useState(false); | |
| const [hasVad, setHasVad] = useState(false); | |
| const [hasSensorimotor, setHasSensorimotor] = useState(false); | |
| const [hasGlasgow, setHasGlasgow] = useState(false); | |
| const [hasConceptnet, setHasConceptnet] = useState(false); | |
| const [hasCategories, setHasCategories] = useState(false); | |
| const [lastResult, setLastResult] = useState(null); | |
| const [latestGuessWord, setLatestGuessWord] = useState(null); | |
| const [showGiveUp, setShowGiveUp] = useState(false); | |
| const [gameError, setGameError] = useState(""); | |
| const inputRef = useRef(null); | |
| const wsRef = useRef(null); | |
| const sessionRef = useRef(initialSession || null); | |
| const reconnectEnabledRef = useRef(true); | |
| const reconnectTimerRef = useRef(null); | |
| const manualLeaveRef = useRef(false); | |
| const HINT_RANKS = [1000, 100, 10, 9, 8, 7, 6, 5, 4, 3, 2]; | |
| const MAX_HINTS = HINT_RANKS.length; | |
| const resetGameState = () => { | |
| setGuesses([]); setGuessCount(0); setSolved(false); setGaveUp(false); | |
| setSecretWord(""); setWinner(""); setHintsUsed(0); setTotalHints(0); | |
| setSeed(""); setShowHintMenu(false); setPosRevealed(null); | |
| setCategoryHints([]); setConcretenessLabel(null); setVadLabel(null); | |
| setSensorimotorLabel(null); setGlasgowLabel(null); setDefinitionText(null); | |
| setDefinitionDone(false); setConceptnetHints([]); setLastResult(null); | |
| setLatestGuessWord(null); setShowGiveUp(false); setGameError(""); | |
| setHasDefinition(false); setHasConcreteness(false); setHasVad(false); | |
| setHasSensorimotor(false); setHasGlasgow(false); | |
| setHasConceptnet(false); setHasCategories(false); | |
| }; | |
| const connectWs = (code, name, reconnectPlayerId = null, isReconnect = false) => { | |
| if (reconnectTimerRef.current) { | |
| clearTimeout(reconnectTimerRef.current); | |
| reconnectTimerRef.current = null; | |
| } | |
| const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; | |
| const pidPart = reconnectPlayerId ? `&player_id=${encodeURIComponent(reconnectPlayerId)}` : ""; | |
| const url = `${proto}//${window.location.host}/api/lobby/${code}/ws?name=${encodeURIComponent(name)}${pidPart}`; | |
| const socket = new WebSocket(url); | |
| let opened = false; | |
| socket.onopen = () => { | |
| opened = true; | |
| setLobbyCode(code); | |
| setMyName(name); | |
| setError(""); | |
| }; | |
| socket.onmessage = (event) => { | |
| const msg = JSON.parse(event.data); | |
| switch (msg.type) { | |
| case "welcome": | |
| setMyName(msg.name); | |
| setHost(msg.host); | |
| setIsHost(msg.name === msg.host); | |
| setPlayers(msg.players); | |
| setLobbyCode(msg.code); | |
| setPlayerId(msg.player_id || reconnectPlayerId || null); | |
| if (msg.player_id || reconnectPlayerId) { | |
| const sess = { code: msg.code, name: msg.name, playerId: msg.player_id || reconnectPlayerId }; | |
| sessionRef.current = sess; | |
| onSessionSave?.(sess); | |
| } | |
| break; | |
| case "player_joined": | |
| setPlayers(msg.players); | |
| if (msg.host) setHost(msg.host); | |
| break; | |
| case "player_left": | |
| setPlayers(msg.players); | |
| if (msg.host) setHost(msg.host); | |
| break; | |
| case "game_started": | |
| resetGameState(); | |
| setInGame(true); | |
| setSeed(msg.seed); | |
| setVocabSize(msg.vocab_size); | |
| setHasDefinition(msg.has_definition || false); | |
| setHasConcreteness(msg.has_concreteness || false); | |
| setHasVad(msg.has_vad || false); | |
| setHasSensorimotor(msg.has_sensorimotor || false); | |
| setHasGlasgow(msg.has_glasgow || false); | |
| setHasConceptnet(msg.has_conceptnet || false); | |
| setHasCategories(msg.has_categories || false); | |
| break; | |
| case "guess_result": | |
| if (msg.duplicate) { | |
| setLastResult({ word: msg.word, score: msg.score, rank: msg.rank, isDuplicate: true, player: msg.player }); | |
| } else { | |
| setGuesses(prev => [...prev, { word: msg.word, score: msg.score, rank: msg.rank, isHint: false, player: msg.player, guessNum: msg.guess_num }]); | |
| setLatestGuessWord(msg.word); | |
| setLastResult({ word: msg.word, score: msg.score, rank: msg.rank, isDuplicate: false, player: msg.player }); | |
| setGuessCount(prev => Math.max(prev, msg.guess_num || 0)); | |
| } | |
| break; | |
| case "hint_result": | |
| if (!msg.already_guessed) { | |
| setGuesses(prev => [...prev, { word: msg.word, score: msg.score, rank: msg.rank, isHint: true, player: msg.player }]); | |
| } | |
| setLatestGuessWord(msg.word); | |
| setHintsUsed(msg.hints_used); | |
| setTotalHints(msg.total_hints != null ? msg.total_hints : (prev => prev + 1)); | |
| break; | |
| case "hint_pos": | |
| setPosRevealed(msg.pos); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_category": | |
| if (msg.categories) setCategoryHints(msg.categories); | |
| else setCategoryHints(prev => [...prev, msg.category]); | |
| if (msg.has_categories !== undefined) setHasCategories(msg.has_categories); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_definition": | |
| setDefinitionText(msg.definition); | |
| setDefinitionDone(msg.done); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_concreteness": | |
| setConcretenessLabel(msg.label); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_vad": | |
| setVadLabel(msg.label); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_sensorimotor": | |
| setSensorimotorLabel(msg.label); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_glasgow": | |
| setGlasgowLabel(msg.label); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "hint_conceptnet": | |
| setConceptnetHints(prev => { | |
| if (prev.some(c => c.relation === msg.relation)) return prev; | |
| return [...prev, { relation: msg.relation, values: msg.values }]; | |
| }); | |
| if (msg.has_more !== undefined) setHasConceptnet(msg.has_more); | |
| if (msg.total_hints != null) setTotalHints(msg.total_hints); | |
| break; | |
| case "game_over": | |
| setSolved(true); | |
| setSecretWord(msg.word); | |
| setWinner(msg.winner); | |
| if (msg.guesses != null) setGuessCount(msg.guesses); | |
| break; | |
| case "gave_up": | |
| setGaveUp(true); | |
| setSecretWord(msg.word); | |
| break; | |
| case "error": | |
| setGameError(msg.message); | |
| setTimeout(() => setGameError(""), 3000); | |
| break; | |
| } | |
| }; | |
| socket.onclose = (event) => { | |
| if (wsRef.current === socket) { | |
| wsRef.current = null; | |
| setWs(null); | |
| } | |
| if (manualLeaveRef.current || !reconnectEnabledRef.current) return; | |
| if (event && event.code === 4004) { | |
| reconnectEnabledRef.current = false; | |
| sessionRef.current = null; | |
| onSessionClear?.(); | |
| setLobbyCode(null); | |
| setError("Lobby no longer exists."); | |
| return; | |
| } | |
| const sess = sessionRef.current; | |
| if (sess) { | |
| reconnectTimerRef.current = setTimeout(() => { | |
| connectWs(sess.code, sess.name, sess.playerId, true); | |
| }, 900); | |
| return; | |
| } | |
| if (!opened && !isReconnect) setError("Connection failed. Check the lobby code."); | |
| }; | |
| setWs(socket); | |
| wsRef.current = socket; | |
| return socket; | |
| }; | |
| // Update isHost when host/myName changes | |
| useEffect(() => { | |
| setIsHost(myName === host); | |
| }, [myName, host]); | |
| useEffect(() => { | |
| if (initialSession) { | |
| sessionRef.current = initialSession; | |
| } | |
| }, [initialSession]); | |
| useEffect(() => { | |
| if (initialSession && !wsRef.current) { | |
| setMode(null); | |
| manualLeaveRef.current = false; | |
| reconnectEnabledRef.current = true; | |
| connectWs(initialSession.code, initialSession.name || "Player", initialSession.playerId || null, true); | |
| } | |
| }, [initialSession]); | |
| useEffect(() => { | |
| return () => { | |
| reconnectEnabledRef.current = false; | |
| if (reconnectTimerRef.current) { | |
| clearTimeout(reconnectTimerRef.current); | |
| reconnectTimerRef.current = null; | |
| } | |
| try { wsRef.current?.close(); } catch {} | |
| }; | |
| }, []); | |
| // Focus input when in game | |
| useEffect(() => { | |
| if (inGame && !solved && !gaveUp && !loading && !('ontouchstart' in window)) { | |
| inputRef.current?.focus(); | |
| } | |
| }); | |
| const sendMsg = (msg) => { | |
| const socket = wsRef.current; | |
| if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(msg)); | |
| }; | |
| const leaveLobbyToMainMenu = () => { | |
| if (!showLeaveLobbyConfirm) { | |
| setShowLeaveLobbyConfirm(true); | |
| return; | |
| } | |
| manualLeaveRef.current = true; | |
| reconnectEnabledRef.current = false; | |
| try { | |
| if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { | |
| wsRef.current.send(JSON.stringify({ type: "leave_lobby" })); | |
| } | |
| } catch {} | |
| try { wsRef.current?.close(); } catch {} | |
| wsRef.current = null; | |
| setWs(null); | |
| setLobbyCode(null); | |
| setInGame(false); | |
| setPlayers([]); | |
| setHost(""); | |
| setMyName(""); | |
| setPlayerId(null); | |
| setShowLeaveLobbyConfirm(false); | |
| sessionRef.current = null; | |
| onSessionClear?.(); | |
| onBack(); | |
| }; | |
| const handleCreate = async () => { | |
| const name = nameInput.trim(); | |
| if (!name) { setError("Enter your name"); return; } | |
| setError(""); | |
| manualLeaveRef.current = false; | |
| reconnectEnabledRef.current = true; | |
| setShowLeaveLobbyConfirm(false); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/lobby/create`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ host_name: name, difficulty }), | |
| }); | |
| const data = await res.json(); | |
| connectWs(data.code, name, data.player_id || null); | |
| } catch { setError("Failed to create lobby"); } | |
| }; | |
| const handleJoin = () => { | |
| const name = nameInput.trim(); | |
| const code = codeInput.trim().toUpperCase(); | |
| if (!name) { setError("Enter your name"); return; } | |
| if (!code || code.length < 4) { setError("Enter a valid lobby code"); return; } | |
| setError(""); | |
| manualLeaveRef.current = false; | |
| reconnectEnabledRef.current = true; | |
| setShowLeaveLobbyConfirm(false); | |
| connectWs(code, name, null); | |
| }; | |
| const handleGuess = () => { | |
| const word = input.trim().toLowerCase().replace(/[^a-z]/g, ""); | |
| if (!word) return; | |
| setInput(""); | |
| setGameError(""); | |
| setShowLeaveLobbyConfirm(false); | |
| sendMsg({ type: "guess", word }); | |
| }; | |
| const sortedGuesses = [...guesses].sort((a, b) => b.score - a.score); | |
| const topScore = sortedGuesses[0]?.score || 0; | |
| const gameOver = solved || gaveUp; | |
| const revealedHintLabels = buildHintTypeLabels({ | |
| posRevealed, | |
| categoryHints, | |
| definitionText, | |
| definitionDone, | |
| concretenessLabel, | |
| vadLabel, | |
| sensorimotorLabel, | |
| glasgowLabel, | |
| conceptnetHints, | |
| hintsUsed, | |
| }); | |
| const btnStyle = { | |
| background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.15)", | |
| borderRadius: 8, padding: "12px 24px", color: "#e8e6e1", fontSize: 15, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", letterSpacing: "0.05em", | |
| transition: "all 0.2s", | |
| }; | |
| const smallBtnStyle = { | |
| ...btnStyle, padding: "8px 16px", fontSize: 13, | |
| }; | |
| // --- Not connected yet: show create/join UI --- | |
| if (!lobbyCode) { | |
| const recovering = Boolean(initialSession && initialSession.code && initialSession.name); | |
| return ( | |
| <div style={{ animation: "slideIn 0.3s ease" }}> | |
| {recovering && ( | |
| <div style={{ textAlign: "center", padding: "12px 0 20px" }}> | |
| <div style={{ color: "#888", fontSize: 13, fontFamily: "'DM Mono', monospace", marginBottom: 10 }}> | |
| reconnecting to lobby... | |
| </div> | |
| <button | |
| onClick={() => { | |
| manualLeaveRef.current = true; | |
| reconnectEnabledRef.current = false; | |
| sessionRef.current = null; | |
| onSessionClear?.(); | |
| onBack(); | |
| }} | |
| style={{ ...smallBtnStyle, background: "transparent", color: "#888" }} | |
| > | |
| Main menu | |
| </button> | |
| </div> | |
| )} | |
| {!mode && !recovering && ( | |
| <div style={{ display: "flex", flexDirection: "column", gap: 12, alignItems: "center" }}> | |
| <button onClick={() => setMode("create")} style={btnStyle}>Create Lobby</button> | |
| <button onClick={() => setMode("join")} style={btnStyle}>Join Lobby</button> | |
| <button | |
| onClick={() => { | |
| manualLeaveRef.current = true; | |
| reconnectEnabledRef.current = false; | |
| try { wsRef.current?.close(); } catch {} | |
| wsRef.current = null; | |
| setWs(null); | |
| onSessionClear?.(); | |
| onBack(); | |
| }} | |
| style={{ ...btnStyle, background: "transparent", color: "#666", fontSize: 12, padding: "8px 16px" }} | |
| > | |
| Back to solo | |
| </button> | |
| </div> | |
| )} | |
| {mode && !recovering && ( | |
| <div style={{ display: "flex", flexDirection: "column", gap: 10, maxWidth: 300, margin: "0 auto" }}> | |
| <input value={nameInput} onChange={e => setNameInput(e.target.value)} | |
| onKeyDown={e => e.key === "Enter" && (mode === "create" ? handleCreate() : handleJoin())} | |
| placeholder="Your name" maxLength={20} | |
| style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 6, padding: "10px 14px", color: "#e8e6e1", fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", outline: "none" }} /> | |
| {mode === "join" && ( | |
| <input value={codeInput} onChange={e => setCodeInput(e.target.value.toUpperCase())} | |
| onKeyDown={e => e.key === "Enter" && handleJoin()} | |
| placeholder="Lobby code" maxLength={6} | |
| style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 6, padding: "10px 14px", color: "#e8e6e1", fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", outline: "none", letterSpacing: "0.2em", textAlign: "center" }} /> | |
| )} | |
| {mode === "create" && ( | |
| <div style={{ display: "flex", gap: 6, justifyContent: "center" }}> | |
| {[{ label: "Easy", value: "easy" }, { label: "Med", value: "medium" }, { label: "Hard", value: "hard" }, { label: "Any", value: null }].map(d => ( | |
| <button key={d.label} onClick={() => setDifficulty(d.value)} | |
| style={{ ...smallBtnStyle, padding: "6px 12px", fontSize: 12, | |
| background: difficulty === d.value ? "rgba(255,255,255,0.12)" : "transparent", | |
| color: difficulty === d.value ? "#e8e6e1" : "#666" }}> | |
| {d.label} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <button onClick={mode === "create" ? handleCreate : handleJoin} style={btnStyle}> | |
| {mode === "create" ? "Create" : "Join"} | |
| </button> | |
| <button onClick={() => { setMode(null); setError(""); }} style={{ ...btnStyle, background: "transparent", color: "#666", fontSize: 12, padding: "6px" }}> | |
| Back | |
| </button> | |
| {error && <div style={{ color: "#f97316", fontSize: 13, fontFamily: "'DM Mono', monospace", textAlign: "center" }}>{error}</div>} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // --- In lobby, game not started --- | |
| if (!inGame) { | |
| return ( | |
| <div style={{ textAlign: "center", animation: "slideIn 0.3s ease" }}> | |
| <div style={{ color: "#666", fontSize: 12, fontFamily: "'DM Mono', monospace", marginBottom: 8 }}>lobby code</div> | |
| <div style={{ | |
| fontSize: 36, fontWeight: 900, fontFamily: "'DM Mono', monospace", | |
| letterSpacing: "0.3em", color: "#e8e6e1", marginBottom: 8, | |
| userSelect: "all", | |
| }}>{lobbyCode}</div> | |
| <button onClick={() => { | |
| navigator.clipboard.writeText(lobbyCode).then(() => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000); }); | |
| }} style={{ ...smallBtnStyle, marginBottom: 20, color: codeCopied ? "#22c55e" : "#888" }}> | |
| {codeCopied ? "Copied!" : "Copy code"} | |
| </button> | |
| <div style={{ color: "#888", fontSize: 13, fontFamily: "'DM Mono', monospace", marginBottom: 12 }}> | |
| Players ({players.length}) | |
| </div> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 8, justifyContent: "center", marginBottom: 24 }}> | |
| {players.map(p => ( | |
| <span key={p} style={{ | |
| background: "rgba(255,255,255,0.06)", border: `1px solid ${playerColor(p, players)}44`, | |
| borderRadius: 16, padding: "6px 14px", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", color: playerColor(p, players), | |
| }}> | |
| {p}{p === host ? " (host)" : ""}{p === myName ? " (you)" : ""} | |
| </span> | |
| ))} | |
| </div> | |
| {isHost ? ( | |
| <div style={{ display: "flex", flexDirection: "column", gap: 12, alignItems: "center" }}> | |
| <button onClick={() => sendMsg({ type: "start_game", difficulty, start_hints: lobbyStartHints })} style={{ ...btnStyle, background: "rgba(34,197,94,0.15)", border: "1px solid rgba(34,197,94,0.3)", color: "#22c55e" }}> | |
| Start Game | |
| </button> | |
| <div style={{ display: "flex", alignItems: "center", gap: 10 }}> | |
| <label style={{ color: "#666", fontSize: 12, fontFamily: "'DM Mono', monospace" }}>start with word hints:</label> | |
| <input type="number" min="0" max="11" value={lobbyStartHints} | |
| onChange={e => setLobbyStartHints(Math.max(0, Math.min(11, parseInt(e.target.value) || 0)))} | |
| style={{ width: 48, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, padding: "6px 8px", color: "#e8e6e1", fontSize: 14, fontFamily: "'DM Mono', monospace", outline: "none", textAlign: "center" }} /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div style={{ color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace" }}> | |
| Waiting for host to start... | |
| </div> | |
| )} | |
| <button | |
| onClick={leaveLobbyToMainMenu} | |
| style={{ | |
| ...smallBtnStyle, | |
| marginTop: 16, | |
| background: showLeaveLobbyConfirm ? "rgba(239,68,68,0.1)" : "transparent", | |
| border: showLeaveLobbyConfirm ? "1px solid rgba(239,68,68,0.35)" : "1px solid rgba(255,255,255,0.12)", | |
| color: showLeaveLobbyConfirm ? "#ef4444" : "#888", | |
| }} | |
| > | |
| {showLeaveLobbyConfirm ? "Confirm main menu?" : "Main menu"} | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // --- In game (multiplayer) --- | |
| return ( | |
| <div style={{ width: "100%" }}> | |
| {/* Player list header */} | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, marginBottom: 12 }}> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> | |
| {players.map(p => ( | |
| <span key={p} style={{ | |
| fontSize: 11, fontFamily: "'DM Mono', monospace", | |
| color: playerColor(p, players), opacity: 0.8, | |
| }}> | |
| {p}{p === myName ? " (you)" : ""} | |
| </span> | |
| ))} | |
| <span style={{ fontSize: 11, fontFamily: "'DM Mono', monospace", color: "#555" }}> | |
| | {lobbyCode} | |
| </span> | |
| </div> | |
| <button | |
| onClick={leaveLobbyToMainMenu} | |
| style={{ | |
| background: showLeaveLobbyConfirm ? "rgba(239,68,68,0.1)" : "transparent", | |
| border: showLeaveLobbyConfirm ? "1px solid rgba(239,68,68,0.35)" : "1px solid rgba(255,255,255,0.12)", | |
| borderRadius: 6, | |
| padding: "6px 10px", | |
| color: showLeaveLobbyConfirm ? "#ef4444" : "#888", | |
| fontSize: 11, | |
| fontFamily: "'DM Mono', monospace", | |
| cursor: "pointer", | |
| }} | |
| > | |
| {showLeaveLobbyConfirm ? "Confirm?" : "Main menu"} | |
| </button> | |
| </div> | |
| {/* Stats */} | |
| <div className="stats-bar" style={{ | |
| display: "flex", justifyContent: "center", gap: 32, marginBottom: 24, padding: "12px 0", | |
| borderTop: "1px solid rgba(255,255,255,0.1)", borderBottom: "1px solid rgba(255,255,255,0.1)", | |
| }}> | |
| {[ | |
| { label: "Guesses", value: guessCount }, | |
| { label: "Best", value: topScore ? topScore.toFixed(2) : "\u2014" }, | |
| { label: "Hints", value: totalHints }, | |
| ].map(s => ( | |
| <div key={s.label} style={{ textAlign: "center" }}> | |
| <div style={{ fontSize: 20, fontWeight: 700, fontFamily: "'DM Mono', monospace", | |
| color: s.label === "Best" && topScore >= 0.70 ? "#84cc16" : "#e8e6e1" }}>{s.value}</div> | |
| <div style={{ fontSize: 11, color: "#666", textTransform: "uppercase", letterSpacing: "0.1em", | |
| fontFamily: "'DM Mono', monospace", marginTop: 2 }}>{s.label}</div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Revealed hints display */} | |
| {(posRevealed || concretenessLabel || vadLabel || sensorimotorLabel || glasgowLabel || definitionText || conceptnetHints.length > 0 || categoryHints.length > 0) && !gameOver && ( | |
| <div style={{ marginBottom: 8, paddingLeft: 4 }}> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> | |
| {posRevealed && <span style={{ background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa", fontFamily: "'DM Mono', monospace" }}>Part of Speech: {posRevealed}</span>} | |
| {concretenessLabel && <span style={{ background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa", fontFamily: "'DM Mono', monospace" }}>Concreteness: {concretenessLabel}</span>} | |
| {vadLabel && <span style={{ background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", fontFamily: "'DM Mono', monospace" }}>Emotional Tone: {vadLabel}</span>} | |
| {sensorimotorLabel && <span style={{ background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", fontFamily: "'DM Mono', monospace" }}>Senses & Action: {sensorimotorLabel}</span>} | |
| {glasgowLabel && <span style={{ background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", fontFamily: "'DM Mono', monospace" }}>Familiarity & Imagery: {glasgowLabel}</span>} | |
| {conceptnetHints.map((cn, i) => <span key={`cn-${i}`} style={{ background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308", fontFamily: "'DM Mono', monospace" }}>Semantic Clue: {cn.relation} ({cn.values.join(", ")})</span>)} | |
| {categoryHints.map((cat, i) => <span key={i} style={{ background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308", fontFamily: "'DM Mono', monospace" }}>Category: {cat}</span>)} | |
| </div> | |
| {definitionText && <div style={{ background: "rgba(59,130,246,0.08)", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 8, padding: "8px 12px", marginTop: 6, fontSize: 13, color: "#93c5fd", fontFamily: "'DM Mono', monospace", lineHeight: 1.5, fontStyle: "italic" }}>{definitionText}</div>} | |
| </div> | |
| )} | |
| {/* Game over */} | |
| {gameOver && ( | |
| <div style={{ marginBottom: 16, animation: "celebrate 0.5s ease" }}> | |
| <div style={{ | |
| background: solved ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)", | |
| border: `1px solid ${solved ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)"}`, | |
| borderRadius: 12, padding: "20px 24px", textAlign: "center", | |
| }}> | |
| <div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, fontWeight: 900, color: solved ? "#22c55e" : "#ef4444", marginBottom: 4 }}> | |
| {solved ? `${winner} solved it!` : "The word was:"} | |
| </div> | |
| <div style={{ fontFamily: "'DM Mono', monospace", fontSize: 20, color: "#e8e6e1", letterSpacing: "0.15em", textTransform: "uppercase", marginTop: 4 }}> | |
| {secretWord} | |
| </div> | |
| {solved && <div style={{ color: "#888", fontSize: 13, marginTop: 8, fontFamily: "'DM Mono', monospace" }}> | |
| in {guessCount} guess{guessCount !== 1 ? "es" : ""}{totalHints > 0 ? ` (${totalHints} hint${totalHints !== 1 ? "s" : ""})` : ""} | |
| </div>} | |
| {isHost ? ( | |
| <button onClick={() => sendMsg({ type: "new_round", difficulty, start_hints: lobbyStartHints })} | |
| style={{ ...btnStyle, marginTop: 16, background: "rgba(34,197,94,0.15)", border: "1px solid rgba(34,197,94,0.3)", color: "#22c55e" }}> | |
| New Round | |
| </button> | |
| ) : ( | |
| <div style={{ color: "#666", fontSize: 13, marginTop: 12, fontFamily: "'DM Mono', monospace" }}> | |
| Waiting for host to start next round... | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Input */} | |
| {!gameOver && ( | |
| <div style={{ display: "flex", gap: 8, marginBottom: 8 }}> | |
| <input ref={inputRef} type="text" value={input} | |
| onChange={e => { setInput(e.target.value); setGameError(""); setShowGiveUp(false); setShowHintMenu(false); setShowLeaveLobbyConfirm(false); }} | |
| onKeyDown={e => e.key === "Enter" && handleGuess()} | |
| placeholder="type a word..." autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck="false" | |
| style={{ flex: 1, background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, padding: "12px 16px", color: "#e8e6e1", fontSize: 16, fontFamily: "'DM Mono', monospace", outline: "none" }} | |
| onFocus={e => e.target.style.borderColor = "rgba(255,255,255,0.35)"} | |
| onBlur={e => e.target.style.borderColor = "rgba(255,255,255,0.15)"} /> | |
| <button onMouseDown={e => e.preventDefault()} onClick={handleGuess} disabled={!input.trim()} | |
| style={{ background: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, padding: "12px 20px", color: "#e8e6e1", fontSize: 14, fontFamily: "'DM Mono', monospace", cursor: "pointer", textTransform: "uppercase", letterSpacing: "0.05em" }}> | |
| Guess | |
| </button> | |
| </div> | |
| )} | |
| {gameError && <div style={{ color: "#f97316", fontSize: 13, fontFamily: "'DM Mono', monospace", marginBottom: 8, paddingLeft: 4 }}>{gameError}</div>} | |
| {lastResult && !gameOver && ( | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", marginBottom: 8, borderRadius: 8, | |
| background: "rgba(255,255,255,0.06)", border: `1px solid ${scoreColor(lastResult.score)}33`, animation: "slideIn 0.3s ease", | |
| }}> | |
| <span style={{ color: scoreColor(lastResult.score), fontSize: 22, fontWeight: 700, fontFamily: "'DM Mono', monospace", minWidth: 52 }}> | |
| {lastResult.score.toFixed(2)} | |
| </span> | |
| <span style={{ color: "#e8e6e1", fontSize: 15, fontFamily: "'DM Mono', monospace", flex: 1 }}> | |
| {lastResult.word} | |
| </span> | |
| <span style={{ color: playerColor(lastResult.player, players), fontSize: 12, fontFamily: "'DM Mono', monospace" }}> | |
| {lastResult.player}{lastResult.isDuplicate ? " (dup)" : ""} | |
| </span> | |
| </div> | |
| )} | |
| {revealedHintLabels.length > 0 && !gameOver && ( | |
| <div className="hint-chip-row"> | |
| {revealedHintLabels.map(label => ( | |
| <span key={label} className="hint-chip">{label}</span> | |
| ))} | |
| </div> | |
| )} | |
| {/* Hint menu & Give up */} | |
| {!gameOver && ( | |
| <div style={{ marginBottom: 8, position: "relative" }}> | |
| <div style={{ display: "flex", gap: 8 }}> | |
| <button onMouseDown={e => e.preventDefault()} | |
| onClick={() => { setShowHintMenu(!showHintMenu); setShowGiveUp(false); setShowLeaveLobbyConfirm(false); }} | |
| style={{ flex: 1, background: "transparent", border: showHintMenu ? "1px solid rgba(234,179,8,0.4)" : "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: "12px", color: showHintMenu ? "#eab308" : "#777", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}> | |
| {showHintMenu ? "Hints \u25B4" : "Hints \u25BE"} | |
| </button> | |
| <button onMouseDown={e => e.preventDefault()} | |
| onClick={() => { if (!showGiveUp) { setShowGiveUp(true); setShowHintMenu(false); setShowLeaveLobbyConfirm(false); } else { sendMsg({ type: "give_up" }); } }} | |
| style={{ flex: 1, background: "transparent", border: showGiveUp ? "1px solid rgba(239,68,68,0.4)" : "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: "12px", color: showGiveUp ? "#ef4444" : "#777", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}> | |
| {showGiveUp ? "Confirm give up?" : "Give up"} | |
| </button> | |
| </div> | |
| {showHintMenu && ( | |
| <div className="hint-panel" style={{ marginTop: 6, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: 6, display: "flex", flexDirection: "column", gap: 4, animation: "slideIn 0.2s ease" }}> | |
| <div className="hint-section-title">Core</div> | |
| <div className="hint-grid"> | |
| {hintsUsed < MAX_HINTS && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{`Similar Word (${MAX_HINTS - hintsUsed} left)`}</button>} | |
| {hasCategories && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_category" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{categoryHints.length > 0 ? `Category: ${categoryHints.join(" \u2192 ")} \u2192 ???` : "Category"}</button>} | |
| {hasDefinition && !definitionDone && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_definition" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{definitionText ? "Reveal More Definition" : "Definition"}</button>} | |
| {!posRevealed && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_pos" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Part of Speech</button>} | |
| </div> | |
| <div className="hint-section-title">Advanced</div> | |
| <div className="hint-grid"> | |
| {hasConcreteness && !concretenessLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_concreteness" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Concreteness</button>} | |
| {hasVad && !vadLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_vad" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Emotional Tone</button>} | |
| {hasSensorimotor && !sensorimotorLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_sensorimotor" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Senses & Action</button>} | |
| {hasGlasgow && !glasgowLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_glasgow" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Familiarity & Imagery</button>} | |
| {hasConceptnet && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_conceptnet" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{conceptnetHints.length > 0 ? `Semantic Clues (${conceptnetHints.length} shown)` : "Semantic Clues"}</button>} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Guesses list (with player names) */} | |
| {guesses.length > 0 && ( | |
| <div> | |
| <div className="mp-guess-grid" style={{ display: "grid", gridTemplateColumns: "56px 36px 1fr 56px 56px 1fr", gap: 10, padding: "8px 16px", color: "#555", fontSize: 11, fontFamily: "'DM Mono', monospace", textTransform: "uppercase", letterSpacing: "0.1em" }}> | |
| <span>Player</span><span>#</span><span>Word</span><span style={{ textAlign: "right" }}>Score</span><span className="rank-col" style={{ textAlign: "right" }}>Rank</span><span></span> | |
| </div> | |
| {sortedGuesses.map((g, i) => ( | |
| <div key={`${g.word}-${i}`} className="mp-guess-grid" style={{ | |
| display: "grid", gridTemplateColumns: "56px 36px 1fr 56px 56px 1fr", alignItems: "center", gap: 10, padding: "10px 16px", | |
| background: i === 0 && guesses.length > 1 ? "rgba(34,197,94,0.08)" : g.word === latestGuessWord ? "rgba(255,255,255,0.03)" : "transparent", | |
| borderLeft: i === 0 && guesses.length > 1 ? "2px solid #22c55e" : "2px solid transparent", | |
| borderRadius: 4, animation: g.word === latestGuessWord ? "slideIn 0.3s ease" : "none", | |
| }}> | |
| <span style={{ color: playerColor(g.player, players), fontSize: 11, fontFamily: "'DM Mono', monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {g.player || ""} | |
| </span> | |
| <span style={{ color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace" }}> | |
| {g.isHint ? "\u2605" : `#${g.guessNum || ""}`} | |
| </span> | |
| <span style={{ color: g.isHint ? "#eab308" : "#e8e6e1", fontSize: 15, fontFamily: "'DM Mono', monospace", textTransform: "lowercase", letterSpacing: "0.02em", fontStyle: g.isHint ? "italic" : "normal" }}> | |
| {g.word} | |
| </span> | |
| <span style={{ color: scoreColor(g.score), fontSize: 15, fontWeight: 600, fontFamily: "'DM Mono', monospace", textAlign: "right" }}> | |
| {g.score.toFixed(2)} | |
| </span> | |
| <span className="rank-col" style={{ color: "#555", fontSize: 12, fontFamily: "'DM Mono', monospace", textAlign: "right" }}> | |
| {g.rank ? `#${g.rank}` : ""} | |
| </span> | |
| <ScoreBar score={g.score} /> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function SemanticGuess() { | |
| const [gameId, setGameId] = useState(null); | |
| const [lobbyMode, setLobbyMode] = useState(false); | |
| const [lobbySession, setLobbySession] = useState(() => { | |
| try { | |
| const raw = localStorage.getItem(LOBBY_SESSION_KEY); | |
| return raw ? JSON.parse(raw) : null; | |
| } catch { | |
| return null; | |
| } | |
| }); | |
| const [vocabSize, setVocabSize] = useState(0); | |
| const [guesses, setGuesses] = useState([]); | |
| const [guessCount, setGuessCount] = useState(0); // actual guesses (excludes hints) | |
| const [input, setInput] = useState(""); | |
| const [loading, setLoading] = useState(false); | |
| const [starting, setStarting] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [solved, setSolved] = useState(false); | |
| const [gaveUp, setGaveUp] = useState(false); | |
| const [secretWord, setSecretWord] = useState(""); | |
| const [showGiveUp, setShowGiveUp] = useState(false); | |
| const [latestGuessWord, setLatestGuessWord] = useState(null); | |
| const [hintsUsed, setHintsUsed] = useState(0); | |
| const [lastResult, setLastResult] = useState(null); | |
| const [showHelp, setShowHelp] = useState(false); | |
| const [seed, setSeed] = useState(""); | |
| const [showHintMenu, setShowHintMenu] = useState(false); | |
| const [posRevealed, setPosRevealed] = useState(null); | |
| const [categoryHints, setCategoryHints] = useState([]); | |
| const [difficulty, setDifficulty] = useState(null); | |
| const [totalHints, setTotalHints] = useState(0); | |
| const [concretenessLabel, setConcretenessLabel] = useState(null); | |
| const [vadLabel, setVadLabel] = useState(null); | |
| const [sensorimotorLabel, setSensorimotorLabel] = useState(null); | |
| const [glasgowLabel, setGlasgowLabel] = useState(null); | |
| const [definitionText, setDefinitionText] = useState(null); | |
| const [definitionDone, setDefinitionDone] = useState(false); | |
| const [conceptnetHints, setConceptnetHints] = useState([]); | |
| const [hasDefinition, setHasDefinition] = useState(false); | |
| const [hasConcreteness, setHasConcreteness] = useState(false); | |
| const [hasVad, setHasVad] = useState(false); | |
| const [hasSensorimotor, setHasSensorimotor] = useState(false); | |
| const [hasGlasgow, setHasGlasgow] = useState(false); | |
| const [hasConceptnet, setHasConceptnet] = useState(false); | |
| const [hasCategories, setHasCategories] = useState(false); | |
| const [shareCopied, setShareCopied] = useState(false); | |
| const [needsDifficultyPick, setNeedsDifficultyPick] = useState(false); | |
| const [showSeedInput, setShowSeedInput] = useState(false); | |
| const [seedInputValue, setSeedInputValue] = useState(""); | |
| const [confirmSeedLoad, setConfirmSeedLoad] = useState(false); | |
| const [showWordSeedLookup, setShowWordSeedLookup] = useState(false); | |
| const [wordSeedInput, setWordSeedInput] = useState(""); | |
| const [wordSeedResult, setWordSeedResult] = useState(""); | |
| const [wordSeedValue, setWordSeedValue] = useState(""); | |
| const [wordSeedLoading, setWordSeedLoading] = useState(false); | |
| const [showMainMenuConfirm, setShowMainMenuConfirm] = useState(false); | |
| const [startHints, setStartHints] = useState(0); | |
| const inputRef = useRef(null); | |
| const listRef = useRef(null); | |
| const HINT_RANKS = [1000, 100, 10, 9, 8, 7, 6, 5, 4, 3, 2]; | |
| const MAX_HINTS = HINT_RANKS.length; | |
| const resetState = () => { | |
| setGuesses([]); | |
| setGuessCount(0); | |
| setSolved(false); | |
| setGaveUp(false); | |
| setSecretWord(""); | |
| setError(""); | |
| setShowGiveUp(false); | |
| setLatestGuessWord(null); | |
| setHintsUsed(0); | |
| setLastResult(null); | |
| setSeed(""); | |
| setShowHintMenu(false); | |
| setPosRevealed(null); | |
| setCategoryHints([]); | |
| setShareCopied(false); | |
| setTotalHints(0); | |
| setConcretenessLabel(null); | |
| setVadLabel(null); | |
| setSensorimotorLabel(null); | |
| setGlasgowLabel(null); | |
| setDefinitionText(null); | |
| setDefinitionDone(false); | |
| setConceptnetHints([]); | |
| setHasDefinition(false); | |
| setHasConcreteness(false); | |
| setHasVad(false); | |
| setHasSensorimotor(false); | |
| setHasGlasgow(false); | |
| setHasConceptnet(false); | |
| setHasCategories(false); | |
| setShowMainMenuConfirm(false); | |
| setShowWordSeedLookup(false); | |
| setWordSeedInput(""); | |
| setWordSeedResult(""); | |
| setWordSeedValue(""); | |
| setWordSeedLoading(false); | |
| }; | |
| const saveLobbySession = useCallback((session) => { | |
| setLobbySession(session); | |
| try { | |
| if (session) localStorage.setItem(LOBBY_SESSION_KEY, JSON.stringify(session)); | |
| else localStorage.removeItem(LOBBY_SESSION_KEY); | |
| } catch {} | |
| }, []); | |
| const goToMainMenu = () => { | |
| if (!showMainMenuConfirm) { setShowMainMenuConfirm(true); return; } | |
| resetState(); | |
| setGameId(null); | |
| setVocabSize(0); | |
| setNeedsDifficultyPick(true); | |
| setLobbyMode(false); | |
| saveLobbySession(null); | |
| setShowMainMenuConfirm(false); | |
| localStorage.removeItem("semantick_game_id"); | |
| if (window.location.search) { | |
| window.history.replaceState({}, "", window.location.pathname); | |
| } | |
| }; | |
| const restoreGame = (gameId, state) => { | |
| setGameId(gameId); | |
| setVocabSize(state.vocab_size); | |
| setSeed(state.seed); | |
| setHintsUsed(state.hints_used); | |
| setSolved(state.solved); | |
| setGaveUp(state.gave_up); | |
| if (state.secret_word) setSecretWord(state.secret_word); | |
| if (state.pos_revealed && state.pos) setPosRevealed(state.pos); | |
| if (state.category_hints) setCategoryHints(state.category_hints); | |
| if (state.difficulty) setDifficulty(state.difficulty); | |
| if (state.total_hints != null) setTotalHints(state.total_hints); | |
| if (state.definition) setDefinitionText(state.definition); | |
| if (state.definition_done) setDefinitionDone(true); | |
| if (state.concreteness_label) setConcretenessLabel(state.concreteness_label); | |
| if (state.vad_label) setVadLabel(state.vad_label); | |
| if (state.sensorimotor_label) setSensorimotorLabel(state.sensorimotor_label); | |
| if (state.glasgow_label) setGlasgowLabel(state.glasgow_label); | |
| if (state.conceptnet_hints) setConceptnetHints(state.conceptnet_hints); | |
| setHasDefinition(state.has_definition || false); | |
| setHasConcreteness(state.has_concreteness || false); | |
| setHasVad(state.has_vad || false); | |
| setHasSensorimotor(state.has_sensorimotor || false); | |
| setHasGlasgow(state.has_glasgow || false); | |
| setHasConceptnet(state.has_conceptnet || false); | |
| setHasCategories(state.has_categories || false); | |
| // Restore guesses from server state | |
| let gc = 0; | |
| const restored = state.guesses.map(g => { | |
| if (!g.isHint) gc++; | |
| return { word: g.word, score: g.score, rank: g.rank, isHint: g.isHint, guessNum: g.isHint ? null : gc }; | |
| }); | |
| setGuesses(restored); | |
| setGuessCount(gc); | |
| }; | |
| const fireAutoHints = async (gid, count) => { | |
| if (!count || count < 1) return; | |
| for (let i = 0; i < count; i++) { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gid}/hint`, { method: "POST" }); | |
| if (!res.ok) break; | |
| const d = await res.json(); | |
| if (!d.already_guessed) { | |
| setGuesses(prev => [...prev, { word: d.word, score: d.score, rank: d.rank, isHint: true }]); | |
| } | |
| setLatestGuessWord(d.word); | |
| setHintsUsed(d.hints_used); | |
| setTotalHints(prev => prev + 1); | |
| } catch { | |
| break; | |
| } | |
| } | |
| // Re-fetch availability flags after auto-hints | |
| try { | |
| const stateRes = await fetch(`${API_BASE}/api/game/${gid}`); | |
| if (stateRes.ok) { | |
| const state = await stateRes.json(); | |
| setHasDefinition(state.has_definition || false); | |
| setHasConcreteness(state.has_concreteness || false); | |
| setHasVad(state.has_vad || false); | |
| setHasSensorimotor(state.has_sensorimotor || false); | |
| setHasGlasgow(state.has_glasgow || false); | |
| setHasConceptnet(state.has_conceptnet || false); | |
| setHasCategories(state.has_categories || false); | |
| } | |
| } catch {} | |
| }; | |
| const startNewGame = useCallback(async (forceSeed, overrideDifficulty, hintCount) => { | |
| setStarting(true); | |
| setNeedsDifficultyPick(false); | |
| resetState(); | |
| // Clear seed from URL when starting a fresh game without a forced seed | |
| if (!forceSeed && window.location.search) { | |
| window.history.replaceState({}, "", window.location.pathname); | |
| } | |
| const diff = overrideDifficulty !== undefined | |
| ? overrideDifficulty | |
| : (forceSeed ? null : difficulty); | |
| try { | |
| const params = new URLSearchParams(); | |
| if (forceSeed) params.set("seed", forceSeed); | |
| if (diff) params.set("difficulty", diff); | |
| const qs = params.toString() ? `?${params.toString()}` : ""; | |
| const res = await fetch(`${API_BASE}/api/new-game${qs}`, { method: "POST" }); | |
| const data = await res.json(); | |
| setGameId(data.game_id); | |
| setVocabSize(data.vocab_size); | |
| setSeed(data.seed); | |
| setShowSeedInput(false); | |
| setConfirmSeedLoad(false); | |
| setSeedInputValue(""); | |
| localStorage.setItem("semantick_game_id", data.game_id); | |
| // Fetch game state to get availability flags | |
| try { | |
| const stateRes = await fetch(`${API_BASE}/api/game/${data.game_id}`); | |
| if (stateRes.ok) { | |
| const state = await stateRes.json(); | |
| setHasDefinition(state.has_definition || false); | |
| setHasConcreteness(state.has_concreteness || false); | |
| setHasVad(state.has_vad || false); | |
| setHasSensorimotor(state.has_sensorimotor || false); | |
| setHasGlasgow(state.has_glasgow || false); | |
| setHasConceptnet(state.has_conceptnet || false); | |
| setHasCategories(state.has_categories || false); | |
| } | |
| } catch {} | |
| // Update URL with seed for sharing | |
| if (data.seed) { | |
| const newUrl = `${window.location.pathname}?seed=${encodeURIComponent(data.seed)}`; | |
| window.history.replaceState({}, "", newUrl); | |
| } | |
| // Fire auto-hints if requested | |
| const autoHints = hintCount !== undefined ? hintCount : startHints; | |
| if (autoHints > 0) { | |
| await fireAutoHints(data.game_id, autoHints); | |
| } | |
| } catch { | |
| setError("Can't connect to server. Is it running?"); | |
| } | |
| setStarting(false); | |
| inputRef.current?.focus(); | |
| }, [difficulty, startHints]); | |
| const lookupWordSeed = useCallback(async () => { | |
| const word = wordSeedInput.trim().toLowerCase(); | |
| if (!word) { | |
| setWordSeedResult("enter a word"); | |
| setWordSeedValue(""); | |
| return; | |
| } | |
| setWordSeedLoading(true); | |
| setWordSeedResult(""); | |
| setWordSeedValue(""); | |
| try { | |
| const params = new URLSearchParams(); | |
| params.set("word", word); | |
| const res = await fetch(`${API_BASE}/api/seed-for-word?${params.toString()}`); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| setWordSeedResult(err.detail || "lookup failed"); | |
| return; | |
| } | |
| const data = await res.json(); | |
| if (data.found && data.seed) { | |
| setWordSeedValue(data.seed); | |
| setWordSeedResult(`seed: ${data.seed}`); | |
| } else { | |
| setWordSeedResult(data.message || "word not in secret words list"); | |
| } | |
| } catch { | |
| setWordSeedResult("lookup failed"); | |
| } finally { | |
| setWordSeedLoading(false); | |
| } | |
| }, [wordSeedInput]); | |
| // On mount: try to resume from localStorage, or check URL seed, or start new | |
| useEffect(() => { | |
| const init = async () => { | |
| setStarting(true); | |
| if (lobbySession && lobbySession.code && lobbySession.name) { | |
| setLobbyMode(true); | |
| setNeedsDifficultyPick(false); | |
| setStarting(false); | |
| return; | |
| } | |
| // Check URL for seed parameter | |
| const urlSeed = new URLSearchParams(window.location.search).get("seed"); | |
| // Try to resume existing game (only if no seed in URL) | |
| if (!urlSeed) { | |
| const savedGameId = localStorage.getItem("semantick_game_id"); | |
| if (savedGameId) { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${savedGameId}`); | |
| if (res.ok) { | |
| const state = await res.json(); | |
| restoreGame(savedGameId, state); | |
| setStarting(false); | |
| return; | |
| } | |
| } catch {} | |
| // Game expired or server restarted, clear and start fresh | |
| localStorage.removeItem("semantick_game_id"); | |
| } | |
| } | |
| setStarting(false); | |
| if (urlSeed) { | |
| startNewGame(urlSeed); | |
| } else { | |
| setNeedsDifficultyPick(true); | |
| } | |
| }; | |
| init(); | |
| }, []); | |
| // Keep input focused after every state change (desktop) | |
| useEffect(() => { | |
| if (!solved && !gaveUp && !loading && !starting) { | |
| // Only auto-focus on non-touch devices to avoid mobile keyboard popup | |
| if (!('ontouchstart' in window)) { | |
| inputRef.current?.focus(); | |
| } | |
| } | |
| }); | |
| // Keyboard shortcut: Enter to start new game when game is over | |
| useEffect(() => { | |
| const handleKeyDown = (e) => { | |
| if (e.key === "Enter" && (solved || gaveUp)) { | |
| e.preventDefault(); | |
| startNewGame(); | |
| } | |
| }; | |
| window.addEventListener("keydown", handleKeyDown); | |
| return () => window.removeEventListener("keydown", handleKeyDown); | |
| }, [solved, gaveUp, startNewGame]); | |
| const refocusInput = () => { | |
| // Re-focus input after actions, even on mobile, if it was already focused | |
| setTimeout(() => inputRef.current?.focus(), 0); | |
| }; | |
| const handleGuess = async () => { | |
| const word = input.trim().toLowerCase().replace(/[^a-z]/g, ""); | |
| if (!word || !gameId) return; | |
| const existingIdx = guesses.findIndex(g => g.word === word); | |
| if (existingIdx !== -1) { | |
| setLatestGuessWord(word); | |
| setLastResult({ word, score: guesses[existingIdx].score, rank: guesses[existingIdx].rank, isDuplicate: true }); | |
| setError(""); | |
| setInput(""); | |
| refocusInput(); | |
| return; | |
| } | |
| setInput(""); | |
| setError(""); | |
| setLoading(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/guess`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ word }), | |
| }); | |
| if (res.status === 422) { | |
| const data = await res.json(); | |
| setError(`"${word}" ${data.detail || "is not in the dictionary"}`); | |
| setLoading(false); | |
| return; | |
| } | |
| if (!res.ok) throw new Error("Server error"); | |
| const data = await res.json(); | |
| const newGuessCount = guessCount + 1; | |
| setGuessCount(newGuessCount); | |
| setGuesses(prev => [...prev, { | |
| word: data.word, | |
| score: data.score, | |
| rank: data.rank, | |
| guessNum: newGuessCount, | |
| }]); | |
| setLatestGuessWord(data.word); | |
| setLastResult({ word: data.word, score: data.score, rank: data.rank, isDuplicate: false }); | |
| if (data.solved) { | |
| setSolved(true); | |
| setSecretWord(data.word); | |
| } | |
| } catch { | |
| setError("Request failed. Check server connection."); | |
| } | |
| setLoading(false); | |
| refocusInput(); | |
| }; | |
| const handleGiveUp = async () => { | |
| if (!showGiveUp) { setShowGiveUp(true); return; } | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/give-up`, { method: "POST" }); | |
| const data = await res.json(); | |
| setSecretWord(data.secret_word); | |
| setGaveUp(true); | |
| } catch { | |
| setError("Request failed."); | |
| } | |
| }; | |
| const handleHint = async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint`, { method: "POST" }); | |
| if (!res.ok) throw new Error("Server error"); | |
| const data = await res.json(); | |
| setHintsUsed(data.hints_used); | |
| setTotalHints(prev => prev + 1); | |
| if (data.already_guessed) { | |
| // Word already in the list - just highlight it | |
| setLatestGuessWord(data.word); | |
| setLastResult({ word: data.word, score: data.score, rank: data.rank, isDuplicate: true }); | |
| } else { | |
| setGuesses(prev => [...prev, { | |
| word: data.word, | |
| score: data.score, | |
| rank: data.rank, | |
| isHint: true, | |
| }]); | |
| setLatestGuessWord(data.word); | |
| } | |
| } catch { | |
| setError("Request failed."); | |
| } | |
| }; | |
| const sortedGuesses = [...guesses].sort((a, b) => b.score - a.score); | |
| const topScore = sortedGuesses[0]?.score || 0; | |
| const gameOver = solved || gaveUp; | |
| const revealedHintLabels = buildHintTypeLabels({ | |
| posRevealed, | |
| categoryHints, | |
| definitionText, | |
| definitionDone, | |
| concretenessLabel, | |
| vadLabel, | |
| sensorimotorLabel, | |
| glasgowLabel, | |
| conceptnetHints, | |
| hintsUsed, | |
| }); | |
| return ( | |
| <div style={{ | |
| minHeight: "100vh", background: "#1a1a19", color: "#e8e6e1", | |
| fontFamily: "'DM Sans', 'Helvetica Neue', sans-serif", | |
| display: "flex", flexDirection: "column", alignItems: "center", | |
| }}> | |
| <style>{` | |
| @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;700&family=Playfair+Display:wght@700;900&display=swap'); | |
| @keyframes slideIn { | |
| from { opacity: 0; transform: translateY(8px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 0.4; } | |
| 50% { opacity: 1; } | |
| } | |
| @keyframes celebrate { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.02); } | |
| 100% { transform: scale(1); } | |
| } | |
| @keyframes loadSlide { | |
| 0% { transform: translateX(-120px); } | |
| 100% { transform: translateX(300px); } | |
| } | |
| input::placeholder { color: #555; } | |
| * { box-sizing: border-box; } | |
| .hint-panel { | |
| max-height: 52vh; | |
| overflow-y: auto; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| .hint-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 4px; | |
| } | |
| .hint-section-title { | |
| color: #666; | |
| font-size: 11px; | |
| font-family: 'DM Mono', monospace; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin: 2px 2px 4px; | |
| } | |
| .hint-chip-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| margin-bottom: 8px; | |
| } | |
| .hint-chip { | |
| background: rgba(234,179,8,0.08); | |
| border: 1px solid rgba(234,179,8,0.2); | |
| border-radius: 999px; | |
| padding: 3px 9px; | |
| color: #b9b9b9; | |
| font-size: 11px; | |
| font-family: 'DM Mono', monospace; | |
| } | |
| /* Mobile responsive overrides */ | |
| @media (max-width: 480px) { | |
| .guess-grid { grid-template-columns: 28px 1fr 48px 1fr !important; gap: 6px !important; padding: 8px 10px !important; } | |
| .guess-grid .rank-col { display: none !important; } | |
| .mp-guess-grid { grid-template-columns: 44px 28px 1fr 48px 1fr !important; gap: 6px !important; padding: 8px 8px !important; } | |
| .mp-guess-grid .rank-col { display: none !important; } | |
| .stats-bar { gap: 16px !important; } | |
| .main-wrap { padding-top: 24px !important; } | |
| .hint-btn { min-height: 44px !important; display: flex !important; align-items: center !important; } | |
| .hint-grid { grid-template-columns: 1fr !important; } | |
| .hint-panel { max-height: 58vh !important; } | |
| } | |
| `}</style> | |
| <div className="main-wrap" style={{ width: "100%", maxWidth: 600, padding: "40px 24px 0" }}> | |
| {/* Header */} | |
| <div style={{ textAlign: "center", marginBottom: 32 }}> | |
| <h1 style={{ | |
| fontFamily: "'Playfair Display', serif", fontSize: 36, fontWeight: 900, | |
| margin: 0, letterSpacing: "-0.02em", | |
| background: "linear-gradient(135deg, #e8e6e1 0%, #a8a49c 100%)", | |
| WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", | |
| }}> | |
| Semantick | |
| </h1> | |
| <p style={{ | |
| color: "#666", fontSize: 13, marginTop: 6, | |
| fontFamily: "'DM Mono', monospace", | |
| letterSpacing: "0.08em", textTransform: "uppercase", | |
| }}> | |
| guess the word by meaning | |
| </p> | |
| </div> | |
| {/* Loading indicator */} | |
| {starting && !gameId && !needsDifficultyPick && !lobbyMode && ( | |
| <div style={{ | |
| textAlign: "center", padding: "40px 0", | |
| animation: "pulse 1.5s infinite", | |
| }}> | |
| <div style={{ | |
| color: "#666", fontSize: 14, fontFamily: "'DM Mono', monospace", | |
| letterSpacing: "0.08em", | |
| }}>loading...</div> | |
| <div style={{ | |
| width: 120, height: 3, background: "rgba(255,255,255,0.06)", | |
| borderRadius: 2, margin: "16px auto 0", overflow: "hidden", | |
| }}> | |
| <div style={{ | |
| width: "40%", height: "100%", background: "rgba(255,255,255,0.2)", | |
| borderRadius: 2, animation: "loadSlide 1.2s ease-in-out infinite", | |
| }} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Lobby mode */} | |
| {lobbyMode && ( | |
| <LobbyScreen | |
| initialSession={lobbySession} | |
| onSessionSave={saveLobbySession} | |
| onSessionClear={() => saveLobbySession(null)} | |
| onBack={() => { | |
| setLobbyMode(false); | |
| setNeedsDifficultyPick(true); | |
| }} | |
| /> | |
| )} | |
| {/* How to play */} | |
| {!lobbyMode && <HowToPlay show={showHelp} onToggle={() => setShowHelp(!showHelp)} />} | |
| {/* Stats */} | |
| {!needsDifficultyPick && !lobbyMode && <div className="stats-bar" style={{ | |
| display: "flex", justifyContent: "center", gap: 32, | |
| marginBottom: 24, padding: "12px 0", | |
| borderTop: "1px solid rgba(255,255,255,0.1)", | |
| borderBottom: "1px solid rgba(255,255,255,0.1)", | |
| }}> | |
| {[ | |
| { label: "Guesses", value: guessCount }, | |
| { label: "Best", value: topScore ? topScore.toFixed(2) : "\u2014" }, | |
| { label: "Hints", value: totalHints }, | |
| { label: "Pool", value: vocabSize ? `${Math.round(vocabSize / 1000)}k` : "\u2014" }, | |
| ].map(s => ( | |
| <div key={s.label} style={{ textAlign: "center" }}> | |
| <div style={{ | |
| fontSize: 20, fontWeight: 700, | |
| fontFamily: "'DM Mono', monospace", | |
| color: s.label === "Best" && topScore >= 0.70 ? "#84cc16" : "#e8e6e1" | |
| }}>{s.value}</div> | |
| <div style={{ | |
| fontSize: 11, color: "#666", textTransform: "uppercase", | |
| letterSpacing: "0.1em", fontFamily: "'DM Mono', monospace", marginTop: 2 | |
| }}>{s.label}</div> | |
| </div> | |
| ))} | |
| </div>} | |
| {/* Seed display & share (mid-game) */} | |
| {seed && !needsDifficultyPick && !lobbyMode && ( | |
| <div style={{ | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| gap: 8, marginBottom: 12, flexWrap: "wrap", | |
| }}> | |
| <span style={{ | |
| color: "#555", fontSize: 12, fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Seed: {seed.length > 16 ? seed.slice(0, 16) + "\u2026" : seed} | |
| </span> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => { | |
| const url = `${window.location.origin}${window.location.pathname}?seed=${encodeURIComponent(seed)}`; | |
| navigator.clipboard.writeText(url).then(() => { | |
| setShareCopied(true); | |
| setTimeout(() => setShareCopied(false), 2000); | |
| }); | |
| }} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "6px 12px", color: shareCopied ? "#22c55e" : "#888", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", letterSpacing: "0.03em", | |
| }} | |
| > | |
| {shareCopied ? "Copied!" : "Share"} | |
| </button> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => { setShowSeedInput(v => !v); setConfirmSeedLoad(false); setSeedInputValue(""); }} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "6px 12px", color: "#888", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", letterSpacing: "0.03em", | |
| }} | |
| > | |
| Load seed | |
| </button> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => { | |
| setShowWordSeedLookup(v => !v); | |
| setWordSeedInput(""); | |
| setWordSeedResult(""); | |
| setWordSeedValue(""); | |
| }} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "6px 12px", color: "#888", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", letterSpacing: "0.03em", | |
| }} | |
| > | |
| Word to seed | |
| </button> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={goToMainMenu} | |
| style={{ | |
| background: showMainMenuConfirm ? "rgba(239,68,68,0.1)" : "rgba(255,255,255,0.06)", | |
| border: showMainMenuConfirm ? "1px solid rgba(239,68,68,0.3)" : "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "6px 12px", color: showMainMenuConfirm ? "#ef4444" : "#888", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", letterSpacing: "0.03em", | |
| }} | |
| > | |
| {showMainMenuConfirm ? "Leave game?" : "Menu"} | |
| </button> | |
| </div> | |
| )} | |
| {/* Seed input */} | |
| {showSeedInput && ( | |
| <div style={{ | |
| display: "flex", gap: 6, marginBottom: 12, alignItems: "center", | |
| }}> | |
| <input | |
| type="text" | |
| value={seedInputValue} | |
| onChange={e => { setSeedInputValue(e.target.value); setConfirmSeedLoad(false); }} | |
| placeholder="paste a seed..." | |
| style={{ | |
| flex: 1, background: "rgba(255,255,255,0.04)", | |
| border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "8px 12px", color: "#e8e6e1", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", outline: "none", | |
| }} | |
| /> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => { | |
| if (!confirmSeedLoad) { setConfirmSeedLoad(true); return; } | |
| const s = seedInputValue.trim(); | |
| if (s) { setShowSeedInput(false); startNewGame(s); } | |
| }} | |
| disabled={!seedInputValue.trim()} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: `1px solid ${confirmSeedLoad ? "rgba(249,115,22,0.4)" : "rgba(255,255,255,0.1)"}`, | |
| borderRadius: 6, padding: "4px 10px", | |
| color: confirmSeedLoad ? "#f97316" : "#888", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", | |
| }} | |
| > | |
| {confirmSeedLoad ? "Confirm?" : "Go"} | |
| </button> | |
| </div> | |
| )} | |
| {/* Secret-word to seed lookup */} | |
| {showWordSeedLookup && !lobbyMode && ( | |
| <div style={{ | |
| display: "flex", gap: 6, marginBottom: 12, alignItems: "center", flexWrap: "wrap", | |
| }}> | |
| <input | |
| type="text" | |
| value={wordSeedInput} | |
| onChange={e => { setWordSeedInput(e.target.value); setWordSeedResult(""); setWordSeedValue(""); }} | |
| onKeyDown={e => e.key === "Enter" && !wordSeedLoading && lookupWordSeed()} | |
| placeholder="enter secret word..." | |
| style={{ | |
| flex: 1, minWidth: 180, background: "rgba(255,255,255,0.04)", | |
| border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "8px 12px", color: "#e8e6e1", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", outline: "none", | |
| }} | |
| /> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={lookupWordSeed} | |
| disabled={wordSeedLoading} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 6, padding: "6px 12px", | |
| color: "#888", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} | |
| > | |
| {wordSeedLoading ? "..." : "Find"} | |
| </button> | |
| {wordSeedValue && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => { | |
| setShowWordSeedLookup(false); | |
| startNewGame(wordSeedValue, null); | |
| }} | |
| style={{ | |
| background: "rgba(34,197,94,0.15)", | |
| border: "1px solid rgba(34,197,94,0.3)", | |
| borderRadius: 6, padding: "6px 12px", | |
| color: "#22c55e", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} | |
| > | |
| Play | |
| </button> | |
| )} | |
| {wordSeedResult && ( | |
| <div style={{ | |
| width: "100%", color: wordSeedValue ? "#22c55e" : "#f97316", | |
| fontSize: 12, fontFamily: "'DM Mono', monospace", marginTop: 2, | |
| }}> | |
| {wordSeedResult} | |
| </div> | |
| )} | |
| <div style={{ | |
| width: "100%", color: "#666", | |
| fontSize: 11, fontFamily: "'DM Mono', monospace", | |
| }}> | |
| lookup uses the full secret-word list (all difficulties) | |
| </div> | |
| </div> | |
| )} | |
| {/* Difficulty picker (shown before first game) */} | |
| {needsDifficultyPick && !lobbyMode && ( | |
| <div style={{ | |
| textAlign: "center", padding: "20px 0", marginBottom: 16, | |
| animation: "slideIn 0.3s ease", | |
| }}> | |
| <div style={{ | |
| color: "#888", fontSize: 13, fontFamily: "'DM Mono', monospace", | |
| marginBottom: 12, | |
| }}> | |
| choose difficulty | |
| </div> | |
| <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}> | |
| {[ | |
| { label: "Easy", value: "easy" }, | |
| { label: "Medium", value: "medium" }, | |
| { label: "Hard", value: "hard" }, | |
| { label: "Any", value: null }, | |
| ].map(d => ( | |
| <button | |
| key={d.label} | |
| onClick={() => { setDifficulty(d.value); startNewGame(undefined, d.value, startHints); }} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, | |
| padding: "12px 24px", color: "#e8e6e1", fontSize: 15, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| letterSpacing: "0.05em", transition: "all 0.2s", | |
| }} | |
| > | |
| {d.label} | |
| </button> | |
| ))} | |
| </div> | |
| <div style={{ | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| gap: 10, marginTop: 16, | |
| }}> | |
| <label style={{ | |
| color: "#666", fontSize: 12, fontFamily: "'DM Mono', monospace", | |
| }}> | |
| start with word hints: | |
| </label> | |
| <input | |
| type="number" min="0" max="11" value={startHints} | |
| onChange={e => setStartHints(Math.max(0, Math.min(11, parseInt(e.target.value) || 0)))} | |
| style={{ | |
| width: 48, background: "rgba(255,255,255,0.04)", | |
| border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, | |
| padding: "6px 8px", color: "#e8e6e1", fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", outline: "none", | |
| textAlign: "center", | |
| }} | |
| /> | |
| </div> | |
| <div style={{ marginTop: 16, borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 16 }}> | |
| <button onClick={() => setLobbyMode(true)} style={{ | |
| background: "rgba(59,130,246,0.1)", border: "1px solid rgba(59,130,246,0.3)", | |
| borderRadius: 8, padding: "12px 24px", color: "#3b82f6", fontSize: 15, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| letterSpacing: "0.05em", transition: "all 0.2s", | |
| }}> | |
| Multiplayer | |
| </button> | |
| </div> | |
| <div style={{ marginTop: 10 }}> | |
| <button | |
| onClick={() => { | |
| setShowWordSeedLookup(v => !v); | |
| setWordSeedInput(""); | |
| setWordSeedResult(""); | |
| setWordSeedValue(""); | |
| }} | |
| style={{ | |
| background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.12)", | |
| borderRadius: 8, padding: "8px 16px", color: "#aaa", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| letterSpacing: "0.03em", transition: "all 0.2s", | |
| }} | |
| > | |
| Word to seed | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Input */} | |
| {!gameOver && !needsDifficultyPick && !lobbyMode && ( | |
| <div style={{ display: "flex", gap: 8, marginBottom: 8 }}> | |
| <input | |
| ref={inputRef} | |
| type="text" | |
| value={input} | |
| onChange={e => { setInput(e.target.value); setError(""); setShowGiveUp(false); setShowHintMenu(false); setShowMainMenuConfirm(false); }} | |
| onKeyDown={e => e.key === "Enter" && !loading && handleGuess()} | |
| placeholder="type a word..." | |
| autoComplete="off" | |
| autoCorrect="off" | |
| autoCapitalize="off" | |
| spellCheck="false" | |
| disabled={loading || starting} | |
| style={{ | |
| flex: 1, background: "rgba(255,255,255,0.06)", | |
| border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, | |
| padding: "12px 16px", color: "#e8e6e1", fontSize: 16, | |
| fontFamily: "'DM Mono', monospace", outline: "none", | |
| transition: "border-color 0.2s", | |
| }} | |
| onFocus={e => e.target.style.borderColor = "rgba(255,255,255,0.35)"} | |
| onBlur={e => e.target.style.borderColor = "rgba(255,255,255,0.15)"} | |
| /> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={handleGuess} | |
| disabled={loading || !input.trim() || starting} | |
| style={{ | |
| background: loading ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.12)", | |
| border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, | |
| padding: "12px 20px", | |
| color: loading ? "#555" : "#e8e6e1", fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", cursor: loading ? "wait" : "pointer", | |
| transition: "all 0.2s", textTransform: "uppercase", letterSpacing: "0.05em", | |
| }} | |
| > | |
| {loading ? "\u00B7\u00B7\u00B7" : "Guess"} | |
| </button> | |
| </div> | |
| )} | |
| {error && ( | |
| <div style={{ | |
| color: "#f97316", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", marginBottom: 8, paddingLeft: 4 | |
| }}>{error}</div> | |
| )} | |
| {lastResult && !loading && !gameOver && ( | |
| <div style={{ | |
| display: "flex", alignItems: "center", gap: 12, | |
| padding: "10px 16px", marginBottom: 8, borderRadius: 8, | |
| background: lastResult.isDuplicate ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.06)", | |
| border: `1px solid ${scoreColor(lastResult.score)}33`, | |
| animation: "slideIn 0.3s ease", | |
| }}> | |
| <span style={{ | |
| color: scoreColor(lastResult.score), fontSize: 22, fontWeight: 700, | |
| fontFamily: "'DM Mono', monospace", minWidth: 52, | |
| }}> | |
| {lastResult.score.toFixed(2)} | |
| </span> | |
| <span style={{ | |
| color: "#e8e6e1", fontSize: 15, | |
| fontFamily: "'DM Mono', monospace", flex: 1, | |
| }}> | |
| {lastResult.word} | |
| </span> | |
| <span style={{ | |
| color: "#777", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| rank #{lastResult.rank}{lastResult.isDuplicate ? " (already guessed)" : ""} | |
| </span> | |
| </div> | |
| )} | |
| {loading && ( | |
| <div style={{ | |
| color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace", | |
| animation: "pulse 1.5s infinite", marginBottom: 8, paddingLeft: 4, | |
| }}>scoring...</div> | |
| )} | |
| {/* Revealed hints display */} | |
| {(posRevealed || concretenessLabel || vadLabel || sensorimotorLabel || glasgowLabel || definitionText || conceptnetHints.length > 0 || categoryHints.length > 0) && !gameOver && !needsDifficultyPick && !lobbyMode && ( | |
| <div style={{ marginBottom: 8, paddingLeft: 4 }}> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}> | |
| {posRevealed && ( | |
| <span style={{ | |
| background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Part of Speech: {posRevealed} | |
| </span> | |
| )} | |
| {concretenessLabel && ( | |
| <span style={{ | |
| background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Concreteness: {concretenessLabel} | |
| </span> | |
| )} | |
| {vadLabel && ( | |
| <span style={{ | |
| background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Emotional Tone: {vadLabel} | |
| </span> | |
| )} | |
| {sensorimotorLabel && ( | |
| <span style={{ | |
| background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Senses & Action: {sensorimotorLabel} | |
| </span> | |
| )} | |
| {glasgowLabel && ( | |
| <span style={{ | |
| background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Familiarity & Imagery: {glasgowLabel} | |
| </span> | |
| )} | |
| {conceptnetHints.map((cn, i) => ( | |
| <span key={`cn-${i}`} style={{ | |
| background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Semantic Clue: {cn.relation} ({cn.values.join(", ")}) | |
| </span> | |
| ))} | |
| {categoryHints.map((cat, i) => ( | |
| <span key={i} style={{ | |
| background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)", | |
| borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308", | |
| fontFamily: "'DM Mono', monospace", | |
| }}> | |
| Category: {cat} | |
| </span> | |
| ))} | |
| </div> | |
| {definitionText && ( | |
| <div style={{ | |
| background: "rgba(59,130,246,0.08)", border: "1px solid rgba(59,130,246,0.2)", | |
| borderRadius: 8, padding: "8px 12px", marginTop: 6, | |
| fontSize: 13, color: "#93c5fd", fontFamily: "'DM Mono', monospace", | |
| lineHeight: 1.5, fontStyle: "italic", | |
| }}> | |
| {definitionText} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {revealedHintLabels.length > 0 && !gameOver && !needsDifficultyPick && !lobbyMode && ( | |
| <div className="hint-chip-row"> | |
| {revealedHintLabels.map(label => ( | |
| <span key={label} className="hint-chip">{label}</span> | |
| ))} | |
| </div> | |
| )} | |
| {/* Hint menu & Give up */} | |
| {!gameOver && !needsDifficultyPick && !lobbyMode && ( | |
| <div style={{ marginBottom: 8, position: "relative" }}> | |
| <div style={{ display: "flex", gap: 8 }}> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => { setShowHintMenu(!showHintMenu); setShowGiveUp(false); }} | |
| style={{ | |
| flex: 1, background: "transparent", | |
| border: showHintMenu ? "1px solid rgba(234,179,8,0.4)" : "1px solid rgba(255,255,255,0.12)", | |
| borderRadius: 8, padding: "12px", | |
| color: showHintMenu ? "#eab308" : "#777", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", letterSpacing: "0.05em", | |
| }} | |
| > | |
| {showHintMenu ? "Hints \u25B4" : "Hints \u25BE"} | |
| </button> | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={handleGiveUp} | |
| style={{ | |
| flex: 1, background: "transparent", | |
| border: showGiveUp ? "1px solid rgba(239,68,68,0.4)" : "1px solid rgba(255,255,255,0.12)", | |
| borderRadius: 8, padding: "12px", | |
| color: showGiveUp ? "#ef4444" : "#777", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", letterSpacing: "0.05em", | |
| }} | |
| > | |
| {showGiveUp ? "Confirm give up?" : "Give up"} | |
| </button> | |
| </div> | |
| {showHintMenu && ( | |
| <div className="hint-panel" style={{ | |
| marginTop: 6, background: "rgba(255,255,255,0.04)", | |
| border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8, | |
| padding: 6, display: "flex", flexDirection: "column", gap: 4, | |
| animation: "slideIn 0.2s ease", | |
| }}> | |
| <div className="hint-section-title">Core</div> | |
| <div className="hint-grid"> | |
| {hintsUsed < MAX_HINTS && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={() => handleHint()} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", | |
| borderRadius: 6, padding: "8px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| {`Similar Word (${MAX_HINTS - hintsUsed} left)`} | |
| </button> | |
| )} | |
| {hasCategories && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/category`, { method: "POST" }); | |
| if (res.status === 400) { setHasCategories(false); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setCategoryHints(prev => [...prev, data.category]); | |
| setTotalHints(data.total_hints); | |
| if (!data.has_more) setHasCategories(false); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| {categoryHints.length > 0 | |
| ? `Category: ${categoryHints.join(" \u2192 ")} \u2192 ???` | |
| : "Category"} | |
| </button> | |
| )} | |
| {hasDefinition && !definitionDone && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/definition`, { method: "POST" }); | |
| if (res.status === 400) { setDefinitionDone(true); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setDefinitionText(data.definition); | |
| setDefinitionDone(data.done); | |
| setTotalHints(data.total_hints); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| {definitionText ? "Reveal More Definition" : "Definition"} | |
| </button> | |
| )} | |
| {!posRevealed && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/pos`, { method: "POST" }); | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setPosRevealed(data.pos); | |
| setTotalHints(data.total_hints); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| Part of Speech | |
| </button> | |
| )} | |
| </div> | |
| <div className="hint-section-title">Advanced</div> | |
| <div className="hint-grid"> | |
| {hasConcreteness && !concretenessLabel && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/concreteness`, { method: "POST" }); | |
| if (res.status === 400) { setHasConcreteness(false); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setConcretenessLabel(data.concreteness); | |
| setTotalHints(data.total_hints); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| Concreteness | |
| </button> | |
| )} | |
| {hasVad && !vadLabel && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/vad`, { method: "POST" }); | |
| if (res.status === 400) { setHasVad(false); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setVadLabel(data.label); | |
| setTotalHints(data.total_hints); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| Emotional Tone | |
| </button> | |
| )} | |
| {hasSensorimotor && !sensorimotorLabel && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/sensorimotor`, { method: "POST" }); | |
| if (res.status === 400) { setHasSensorimotor(false); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setSensorimotorLabel(data.label); | |
| setTotalHints(data.total_hints); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| Senses & Action | |
| </button> | |
| )} | |
| {hasGlasgow && !glasgowLabel && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/glasgow`, { method: "POST" }); | |
| if (res.status === 400) { setHasGlasgow(false); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setGlasgowLabel(data.label); | |
| setTotalHints(data.total_hints); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| Familiarity & Imagery | |
| </button> | |
| )} | |
| {hasConceptnet && ( | |
| <button | |
| onMouseDown={e => e.preventDefault()} | |
| onClick={async () => { | |
| try { | |
| const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/conceptnet`, { method: "POST" }); | |
| if (res.status === 400) { setHasConceptnet(false); return; } | |
| if (!res.ok) throw new Error(); | |
| const data = await res.json(); | |
| setConceptnetHints(prev => [...prev, { relation: data.relation, values: data.values }]); | |
| setTotalHints(data.total_hints); | |
| if (!data.has_more) setHasConceptnet(false); | |
| } catch { setError("Request failed."); } | |
| }} | |
| style={{ | |
| background: "transparent", | |
| border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, | |
| padding: "10px 12px", textAlign: "left", | |
| color: "#999", fontSize: 13, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| }} className="hint-btn" | |
| > | |
| {conceptnetHints.length > 0 | |
| ? `Semantic Clues (${conceptnetHints.length} shown)` | |
| : "Semantic Clues"} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Solved / Gave Up */} | |
| {gameOver && !lobbyMode && ( | |
| <div style={{ | |
| width: "100%", maxWidth: 600, padding: "0 24px", marginBottom: 16, | |
| animation: "celebrate 0.5s ease", | |
| }}> | |
| <div style={{ | |
| background: solved ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)", | |
| border: `1px solid ${solved ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)"}`, | |
| borderRadius: 12, padding: "20px 24px", textAlign: "center", | |
| }}> | |
| <div style={{ | |
| fontFamily: "'Playfair Display', serif", fontSize: 28, fontWeight: 900, | |
| color: solved ? "#22c55e" : "#ef4444", marginBottom: 4, | |
| }}> | |
| {solved ? "Solved!" : "The word was:"} | |
| </div> | |
| <div style={{ | |
| fontFamily: "'DM Mono', monospace", fontSize: 20, | |
| color: "#e8e6e1", letterSpacing: "0.15em", | |
| textTransform: "uppercase", marginTop: 4, | |
| }}> | |
| {secretWord} | |
| </div> | |
| {solved && ( | |
| <div style={{ color: "#888", fontSize: 13, marginTop: 8, fontFamily: "'DM Mono', monospace" }}> | |
| in {guessCount} guess{guessCount !== 1 ? "es" : ""}{totalHints > 0 ? ` (${totalHints} hint${totalHints !== 1 ? "s" : ""})` : ""} | |
| </div> | |
| )} | |
| <div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap", marginTop: 16 }}> | |
| <button | |
| onClick={() => startNewGame()} | |
| style={{ | |
| background: "rgba(255,255,255,0.1)", | |
| border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, | |
| padding: "10px 24px", color: "#e8e6e1", fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| letterSpacing: "0.05em", textTransform: "uppercase", | |
| }} | |
| > | |
| New Game | |
| </button> | |
| <button | |
| onClick={() => { | |
| const status = solved | |
| ? `Solved in ${guessCount} guess${guessCount !== 1 ? "es" : ""}` | |
| : `Gave up after ${guessCount} guess${guessCount !== 1 ? "es" : ""}`; | |
| const hintParts = []; | |
| if (posRevealed) hintParts.push("pos"); | |
| if (concretenessLabel) hintParts.push("concreteness"); | |
| if (vadLabel) hintParts.push("vad"); | |
| if (sensorimotorLabel) hintParts.push("sensorimotor"); | |
| if (glasgowLabel) hintParts.push("glasgow"); | |
| if (definitionText) hintParts.push("definition"); | |
| if (categoryHints.length) hintParts.push(`${categoryHints.length} category`); | |
| if (conceptnetHints.length) hintParts.push(`${conceptnetHints.length} semantic`); | |
| if (hintsUsed) hintParts.push(`${hintsUsed} word`); | |
| const hintLine = hintParts.length ? `\nHints: ${hintParts.join(", ")}` : ""; | |
| const url = `${window.location.origin}${window.location.pathname}?seed=${encodeURIComponent(seed)}`; | |
| const text = `Semantick - ${status}${hintLine}\n${url}`; | |
| navigator.clipboard.writeText(text).then(() => { | |
| setShareCopied(true); | |
| setTimeout(() => setShareCopied(false), 3000); | |
| }); | |
| }} | |
| style={{ | |
| background: shareCopied ? "rgba(34,197,94,0.15)" : "rgba(255,255,255,0.06)", | |
| border: `1px solid ${shareCopied ? "rgba(34,197,94,0.3)" : "rgba(255,255,255,0.15)"}`, borderRadius: 8, | |
| padding: "10px 24px", color: shareCopied ? "#22c55e" : "#aaa", fontSize: 14, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| letterSpacing: "0.05em", textTransform: "uppercase", | |
| transition: "all 0.2s", | |
| }} | |
| > | |
| {shareCopied ? "Copied!" : "Share result"} | |
| </button> | |
| </div> | |
| {/* Difficulty selector */} | |
| <div style={{ marginTop: 12 }}> | |
| <div style={{ color: "#555", fontSize: 11, fontFamily: "'DM Mono', monospace", textAlign: "center", marginBottom: 6 }}> | |
| next game difficulty | |
| </div> | |
| <div style={{ display: "flex", gap: 6, justifyContent: "center" }}> | |
| {[ | |
| { label: "Easy", value: "easy" }, | |
| { label: "Medium", value: "medium" }, | |
| { label: "Hard", value: "hard" }, | |
| { label: "Any", value: null }, | |
| ].map(d => ( | |
| <button | |
| key={d.label} | |
| onClick={() => setDifficulty(d.value)} | |
| style={{ | |
| background: difficulty === d.value ? "rgba(255,255,255,0.12)" : "transparent", | |
| border: difficulty === d.value ? "1px solid rgba(255,255,255,0.25)" : "1px solid rgba(255,255,255,0.1)", | |
| borderRadius: 6, padding: "5px 12px", | |
| color: difficulty === d.value ? "#e8e6e1" : "#666", fontSize: 12, | |
| fontFamily: "'DM Mono', monospace", cursor: "pointer", | |
| transition: "all 0.2s", | |
| }} | |
| > | |
| {d.label} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div style={{ color: "#555", fontSize: 11, marginTop: 8, fontFamily: "'DM Mono', monospace" }}> | |
| or press Enter for a new game | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Guesses list */} | |
| {guesses.length > 0 && !lobbyMode && ( | |
| <div ref={listRef} style={{ | |
| width: "100%", maxWidth: 600, padding: "0 24px", | |
| }}> | |
| <div className="guess-grid" style={{ | |
| display: "grid", | |
| gridTemplateColumns: "36px 1fr 56px 56px 1fr", | |
| gap: 10, padding: "8px 16px", | |
| color: "#555", fontSize: 11, | |
| fontFamily: "'DM Mono', monospace", | |
| textTransform: "uppercase", letterSpacing: "0.1em", | |
| }}> | |
| <span>#</span> | |
| <span>Word</span> | |
| <span style={{ textAlign: "right" }}>Score</span> | |
| <span className="rank-col" style={{ textAlign: "right" }}>Rank</span> | |
| <span></span> | |
| </div> | |
| {sortedGuesses.map((g, i) => ( | |
| <GuessRow | |
| key={`${g.word}-${i}`} | |
| guess={g} | |
| index={i + 1} | |
| isNew={g.word === latestGuessWord} | |
| isBest={i === 0 && guesses.length > 1} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| ReactDOM.createRoot(document.getElementById("root")).render(<SemanticGuess />); | |
| </script> | |
| </body> | |
| </html> | |