js/app.js
GitHub
"use strict";/* ===================================================================== GuessWord — offline multilingual word-guessing game. Languages: English (Oxford CEFR), Japanese (JLPT N5-N1), Korean (Yonsei 1-6), French / German / Spanish (frequency bands). Semantic ranking via ConceptNet Numberbatch 19.08 (CC BY-SA 4.0) 300-d vectors, int8-quantized, decoded and ranked locally in this file. Distance 1 = the secret word. ===================================================================== *//* CEFR vocab + Numberbatch 300d int8-quantized — generated locally *//* ---------- language packs ---------- */const LANGS = { en:{ name:"English", cn:"英文", badge:"CEFR", levels:["A1","A2","B1","B2","C1","C2"], chars:/^[a-z]+$/ }, ja:{ name:"日本語", cn:"日文", badge:"JLPT", levels:["N5","N4","N3","N2","N1"], chars:/^[\u3040-\u30ff\u4e00-\u9fff\u3005\u30fc\uff66-\uff9fー]+$/ }, ko:{ name:"한국어", cn:"韩文", badge:"延世", levels:["延世1","延世2","延世3","延世4","延世5","延世6"], chars:/^[\uac00-\ud7a3]+$/ }, fr:{ name:"Français", cn:"法文", badge:"词频", levels:["频段1","频段2","频段3","频段4","频段5","频段6"], chars:/^[a-zàâäéèêëîïôöùûüÿçœæ]+$/ }, de:{ name:"Deutsch", cn:"德文", badge:"词频", levels:["频段1","频段2","频段3","频段4","频段5","频段6"], chars:/^[a-zäöüß]+$/ }, es:{ name:"Español", cn:"西班牙文", badge:"词频", levels:["频段1","频段2","频段3","频段4","频段5","频段6"], chars:/^[a-záéíóúüñ]+$/ },};function langCn(){ try{ return LANGS[LANG].cn || "英文"; }catch(e){ return "英文"; } }function phText(){ return "输入一个" + langCn() + "单词"; }/* ---------- online engine (deploy build: ranking + dictionary served by /api) ---------- */let REMOTE = false; // set during boot when the deploy loader is present (window.__gwRemote)let gameToken = null; // remote game handle — the secret stays server-side, encrypted inside itlet pendingNew = null; // in-flight /api/new for the current game (the player may type while it loads)function apiURL(path){ return (window.__gwAPI || "/api") + path; }// one retry on network hiccups / 5xx (the China route to the edge is occasionally lossy)function apiPost(path, data, tries){ const left0 = tries || 2; return new Promise((resolve, reject) => { const run = (left) => { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 10000); fetch(apiURL(path), { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify(data || {}), signal: ctl.signal }) .then(r => { clearTimeout(timer); if(!r.ok){ const e = new Error("HTTP "+r.status); e.http = r.status; throw e; } return r.json(); }) .then(j => { if(j && j.err) throw new Error(j.err); resolve(j); }) .catch(e => { clearTimeout(timer); const retriable = !e.http || e.http >= 500; if(left > 1 && retriable) run(left - 1); else reject(e); }); }; run(left0); });}// preload a language on the server (fire and forget): the first guess of a// game then skips the vector decode on a cold isolateconst WARMED = {};function warmLang(lang){ if(!REMOTE) return; lang = lang || LANG; if(WARMED[lang] && Date.now() - WARMED[lang] < 8*60*1000) return; // don't stack heavy warms WARMED[lang] = Date.now(); try{ apiPost("/warm", { lang }).catch(()=>{}); }catch(e){}}const PACK_SRC = { en:()=>GWD, ja:()=>GW_JA, ko:()=>GW_KO, fr:()=>GW_FR, de:()=>GW_DE, es:()=>GW_ES };const PACK_MAP = { en:()=>GWMAP };const DICT_SRC = { en:()=>GWDICT_B64, ja:()=>GWDICT_JA_B64, ko:()=>GWDICT_KO_B64, fr:()=>GWDICT_FR_B64, de:()=>GWDICT_DE_B64, es:()=>GWDICT_ES_B64 };function decodePack(src, mapStr){ const D2 = src.D, N2 = src.N; const words = src.words.split("|"); const lv = src.level.split("").map(c => +c); // src without .vec = lightweight word list (online mode: vectors stay server-side) let unit = null; if(src.vec){ let bytes; if(typeof src.vec === "string"){ const b64 = atob(src.vec); bytes = new Uint8Array(b64.length); for(let i=0;i<b64.length;i++) bytes[i] = b64.charCodeAt(i); } else { bytes = src.vec; } unit = new Float32Array(N2*D2); const min = src.min, max = src.max; for(let i=0;i<N2;i++) for(let d=0;d<D2;d++) unit[i*D2+d] = min[d] + (bytes[i*D2+d]/255)*(max[d]-min[d]); // normalize rows to unit length so cosine = dot product for(let i=0;i<N2;i++){ let n2 = 0; for(let d=0;d<D2;d++){ const v=unit[i*D2+d]; n2 += v*v; } const inv = 1/Math.sqrt(n2) || 0; for(let d=0;d<D2;d++) unit[i*D2+d] *= inv; } } return { D:D2, N:N2, WORDS:words, LVIDX:lv, UNIT:unit, IDX:new Map(words.map((w,i)=>[w,i])), LEMMA: mapStr ? new Map(mapStr.split(";").map(p=>p.split(":"))) : new Map() };}const packCache = {};function getPack(lang){ if(packCache[lang]) return packCache[lang]; let src = null; try{ src = PACK_SRC[lang] ? PACK_SRC[lang]() : null; }catch(e){ src = null; } if(!src) return null; const mapFn = PACK_MAP[lang]; let mapStr = ""; try{ mapStr = mapFn ? (mapFn() || "") : ""; }catch(e){ mapStr = ""; } return packCache[lang] = decodePack(src, mapStr);}function packAvailable(lang){ try{ return !!(PACK_SRC[lang] && PACK_SRC[lang]()); }catch(e){ return false; } }// load a language: online mode fetches only the word list, offline decodes the full vectorsfunction ensurePack(lang, onProg){ if(packCache[lang]) return Promise.resolve(packCache[lang]); if(REMOTE && window.__loadMeta){ return window.__loadMeta(lang).then(() => { if(onProg) onProg(1); return getPack(lang); }); } if(window.__loadLang && !packAvailable(lang)){ return window.__loadLang(lang, onProg).then(() => getPack(lang)); } return Promise.resolve(getPack(lang));}const levelsOf = lang => (LANGS[lang] || LANGS.en).levels;let LANG = "en";let LEVELS, D, N, WORDS, LVIDX, UNIT, IDX, LEMMA;function activateLang(lang){ const p = getPack(lang) || getPack("en"); LANG = packCache[lang] === p ? lang : "en"; LEVELS = LANGS[LANG].levels; D = p.D; N = p.N; WORDS = p.WORDS; LVIDX = p.LVIDX; UNIT = p.UNIT; IDX = p.IDX; LEMMA = p.LEMMA;}/* ---------- state ---------- */const $ = id => document.getElementById(id);const LSKEY = "guessword-v2";const MODEL_VER = "nb1908b"; // embedding model tag; history saved under an older tag gets distances recomputedlet store = { settings:{theme:"dark", sound:true}, history:[], recent:[], games:[], notebook:[] };try{ const raw = localStorage.getItem(LSKEY); if(raw){ store = Object.assign(store, JSON.parse(raw)); } }catch(e){}if(!Array.isArray(store.notebook)) store.notebook = [];if(!Array.isArray(store.games)) store.games = [];if(typeof store.settings.ac !== "boolean") store.settings.ac = true;if(!LANGS[store.settings.lang]) store.settings.lang = "en";function save(){ try{ localStorage.setItem(LSKEY, JSON.stringify(store)); }catch(e){} }// migrate the old single "current game" slot into the unfinished-games listif(store.current){ const c = store.current; if(c && c.state==="ongoing" && c.board && c.board.length){ store.games.push({ gid: Date.now().toString(36)+Math.random().toString(36).slice(2,6), lo:c.lo, hi:c.hi, lang:c.lang||"en", secret:c.secret, board:c.board.map(e=>({word:e.word, isHint:!!e.isHint})), state:"ongoing", at:Date.now() }); } else if(c && (c.state==="won"||c.state==="lost") && c.secret){ store.history.push({ lo:c.lo, hi:c.hi, secret:c.secret, state:c.state, lang:c.lang||"en", board:(c.board||[]), at:Date.now(), mv:MODEL_VER }); } delete store.current; save();}if(store.games.length > 300) store.games.splice(0, store.games.length - 300);activateLang(store.settings.lang);let selLo = null, selHi = null;let cfg = null;let secret, ranks, board, state, result, marker, hintCount, busy = false, resultsOpen = false;const fmt = n => n.toLocaleString("en-US");const cap = w => w.charAt(0).toUpperCase()+w.slice(1);const rangeLabelFor = (lo, hi, lang) => { const L = levelsOf(lang); return lo===hi ? L[lo] : L[lo]+"–"+L[hi]; };const rangeLabel = (lo, hi) => rangeLabelFor(lo, hi, LANG);function rng(seedStr){ let h=1779033703 ^ seedStr.length; for(let i=0;i<seedStr.length;i++){ h=Math.imul(h ^ seedStr.charCodeAt(i), 3432918353); h = h<<13 | h>>>19; } let t = (h=Math.imul(h ^ h>>>16, 2246822507), h=Math.imul(h ^ h>>>13, 3266489909), (h ^= h>>>16)>>>0); return function(){ t|=0; t = t + 0x6D2B79F5 | 0; let r = Math.imul(t ^ t>>>15, 1|t); r = r + Math.imul(r ^ r>>>7, 61|r) ^ r; return ((r ^ r>>>14)>>>0)/4294967296; };}/* ---------- ranking by cosine similarity ---------- */const rankCache = {};function buildRanks(secretWord, lang){ lang = lang || LANG; const key = lang + "|" + secretWord; if(rankCache[key]) return rankCache[key]; const P = getPack(lang); if(!P) return null; const P_IDX = P.IDX, P_N = P.N, P_D = P.D, P_W = P.WORDS, P_U = P.UNIT; const si = P_IDX.get(secretWord); if(si === undefined) return null; const scores = new Float64Array(P_N); for(let i=0;i<P_N;i++){ if(i===si){ scores[i] = 2; continue; } let dot = 0, off = i*P_D, soff = si*P_D; for(let d=0;d<P_D;d++) dot += P_U[off+d]*P_U[soff+d]; scores[i] = dot + rng(lang+"|"+secretWord+"|"+P_W[i])()*1e-6; } const order = new Array(P_N); for(let i=0;i<P_N;i++) order[i]=i; order.sort((a,b)=> scores[b]-scores[a] || (P_W[a]<P_W[b]?-1:1)); const rankOf = new Map(), wordAt = new Array(P_N), distOf = new Array(P_N); const K = 3000/P_N; let prevDist = 0; for(let r=0;r<P_N;r++){ const i = order[r]; rankOf.set(P_W[i], r+1); wordAt[r] = P_W[i]; // compress ranks into ~1..3000 while keeping distances unique & monotonic const d = Math.max(prevDist+1, 1 + Math.round(r*K)); distOf[r] = d; prevDist = d; } return rankCache[key] = { rankOf, wordAt, distOf, N:P_N, K, lang };}/* ---------- puzzle pools: selected level + the one below ---------- */function poolForRange(lo, hi){ const out = []; for(let i=0;i<N;i++) if(LVIDX[i]>=lo && LVIDX[i]<=hi) out.push(WORDS[i]); return out;}function poolFor(levelIdx){ return poolForRange(Math.max(0, levelIdx-1), levelIdx); }function pickSecret(lo, hi){ const pool = poolForRange(lo, hi); const recent = new Set(store.recent.slice(-160)); const fresh = pool.filter(w => !recent.has(w)); const use = fresh.length > 30 ? fresh : pool; return use[Math.floor(Math.random()*use.length)];}/* ---------- persistence of unfinished games (every unfinished game is kept) ---------- */function newGid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,6); }function findGame(gid){ return store.games.find(g => g.gid === gid) || null; }function latestGame(){ return store.games.length ? store.games[store.games.length-1] : null; }function persistCurrent(){ if(!cfg || state !== "ongoing") return; if(REMOTE && !gameToken) return; // /api/new still in flight: nothing to save yet const rec = { gid: cfg.gid, lo: cfg.lo, hi: cfg.hi, lang: cfg.lang || LANG, state, board: board.map(e => ({ word:e.word, isHint:e.isHint, r:(e.r!=null?e.r:null), d:(e.d!=null?e.d:null), n:(e.n||null) })), at: Date.now() }; if(REMOTE) rec.token = gameToken; else rec.secret = secret; const rest = store.games.filter(g => g.gid !== cfg.gid); rest.push(rec); // most recent activity goes last (home "continue" uses the last) store.games = rest; while(store.games.length > 300) store.games.shift(); save();}function finishToHistory(){ store.history.push({ lo:cfg.lo, hi:cfg.hi, secret, state, lang: cfg.lang || LANG, board:board.map(e=>({word:e.word,isHint:e.isHint,distance:e.distance})), at:Date.now(), mv:MODEL_VER }); while(store.history.length > 300) store.history.shift(); if(secret){ store.recent.push(secret); while(store.recent.length > 200) store.recent.shift(); } if(cfg) store.games = store.games.filter(g => g.gid !== cfg.gid); save();}/* ---------- metrics ---------- */function recalcMetrics(){ let gc = 0, hc = 0; for(const e of board){ if(e.isHint){ hc++; } else gc++; } hintCount = hc; return gc;}/* ---------- validation ---------- */const STOP_EN = new Set(["the","a","an","and","or","but","is","are","was","were","be","been","being","have","has","had","do","does","did","will","would","could","should","may","might","must","can","i","you","he","she","it","we","they"]);const BLOCKED = ["rape","rapes","retard","retards","nigga","niggas","nigger","niggers","faggot","faggots","negro","coon","kike","spastic"];function validateGuess(raw){ const w = raw.trim().toLowerCase(); if(!w.length) return {valid:false, reason:"too-short"}; if(w.length > 45) return {valid:false, reason:"too-long"}; if(!LANGS[LANG].chars.test(w)) return {valid:false, reason:"invalid-character"}; if(LANG==="en" && BLOCKED.some(b => w.startsWith(b))) return {valid:false, reason:"disallowed"}; if(board.some(e => e.word === w)) return {valid:false, reason:"duplicate", word:w}; if(LANG==="en" && STOP_EN.has(w)) return {valid:false, reason:"too-common"}; return {valid:true, word:foldWord(w)};}function foldWord(w){ if(IDX.has(w)) return w; return LEMMA.get(w) || null;}/* ---------- hint target (nearest unused rank closer than best) ---------- */function boardRanks(){ return board.map(e => e.r).filter(x => x != null); }function pickHintRank(giveUp){ const n = ranks.N; const rs = boardRanks(); const used = new Set(rs); if(giveUp) return 1; const best = rs.length ? Math.min(...rs) : null; if(best === null) return Math.max(2, Math.round(1 + (1000-1)/ranks.K)); if(best <= 20){ for(let x=best-1; x>=2; x--) if(!used.has(x)) return x; for(let x=best+1; x<=20; x++) if(!used.has(x)) return x; for(let x=21; x<=n; x++) if(!used.has(x)) return x; return null; } let x = Math.ceil(0.8*best); if(x<=1) x=2; if(x>n) x=n; if(used.has(x)){ for(let e=1; e<=50; e++){ if(x-e>=2 && !used.has(x-e)) return x-e; if(x+e<=n && !used.has(x+e)) return x+e; } } return used.has(x) ? null : x;}/* ---------- sounds ---------- */let AC = null;function tone(freq, dur, type="sine", vol=0.05, when=0){ if(!store.settings.sound) return; try{ AC = AC || new (window.AudioContext||window.webkitAudioContext)(); const o = AC.createOscillator(), g = AC.createGain(); o.type = type; o.frequency.value = freq; g.gain.setValueAtTime(0, AC.currentTime+when); g.gain.linearRampToValueAtTime(vol, AC.currentTime+when+0.012); g.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime+when+dur); o.connect(g); g.connect(AC.destination); o.start(AC.currentTime+when); o.stop(AC.currentTime+when+dur+0.05); }catch(e){}}const sfx = { reveal(){ tone(660,.09); tone(880,.12,"sine",.05,.07); }, error(){ tone(170,.15,"square",.03); }, notfound(){ tone(140,.18,"square",.032); tone(110,.18,"square",.028,.08); }, win(){ [523,659,784,1047].forEach((f,i)=>tone(f,.16,"sine",.06,i*.11)); }, lose(){ tone(330,.24,"sine",.05); tone(220,.34,"sine",.05,.18); }, click(){ tone(1250,.03,"sine",.022); }};/* ---------- icons ---------- */const IC = { bulb:'<svg class="hintIc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 00-3.6 10.8c.7.55 1.1 1.2 1.1 2.2h5c0-1 .4-1.65 1.1-2.2A6 6 0 0012 3z"/></svg>', flag:'<svg class="hintIc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 21V4M5 4h12l-2.5 4L17 12H5"/></svg>', trophy:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8M12 17v4M7 4h10v5a5 5 0 01-10 0V4z"/><path d="M7 6H4a1 1 0 00-1 1c0 2.2 1.8 4 4 4M17 6h3a1 1 0 011 1c0 2.2-1.8 4-4 4"/></svg>', arrow:'<svg class="markerArrow" viewBox="0 0 24 24" fill="currentColor"><path d="M8 4l10 8-10 8z"/></svg>', sun:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>', moon:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>', sndOn:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7M18.4 5.6a9 9 0 010 12.8"/></svg>', sndOff:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M22 9l-6 6M16 9l6 6"/></svg>', bk:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-6-4-6 4V3z"/></svg>', bkFill:'<svg class="ic" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-6-4-6 4V3z"/></svg>', target:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none"/></svg>', bulbSm:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 00-3.6 10.8c.7.55 1.1 1.2 1.1 2.2h5c0-1 .4-1.65 1.1-2.2A6 6 0 0012 3z"/></svg>', tag:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.6 13.4L11 3.8a2 2 0 00-1.4-.6H4a1 1 0 00-1 1v5.6c0 .5.2 1 .6 1.4l9.6 9.6a2 2 0 002.8 0l4.6-4.6a2 2 0 000-2.8z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor" stroke="none"/></svg>', book:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20V3H6.5A2.5 2.5 0 004 5.5v14zM4 19.5A2.5 2.5 0 006.5 22H20v-5"/></svg>', search:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>', clock:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>', x:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>', chevR:'<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>'};/* ---------- rendering ---------- */function barWidth(d){ const n = d > 3000 ? 0.01 : 0.01 + 0.99*Math.pow(Math.min(Math.max((3000-d)/3000,0),1), 3.5); return 100*n;}const barColor = d => d<=300 ? "green" : d<=1500 ? "orange" : "red";function rowHTML(e, withMarker){ const win = e.distance===1 && !e.isHint; const icon = e.isHint ? (e.distance===1 ? IC.flag : IC.bulb) : ""; const right = win ? IC.trophy : fmt(e.distance); const nbOn = store.notebook.includes(e.word); return `<div class="rowWrap">${withMarker?IC.arrow:""} <div class="row"><div class="fill ${barColor(e.distance)}" data-w="${barWidth(e.distance)}"></div> <div class="rowContent"><span class="rowWord" data-w="${e.word}">${icon}${cap(e.word)}</span><button type="button" class="nbT${nbOn?" on":""}" data-w="${e.word}" aria-label="生词本">${nbOn?IC.bkFill:IC.bk}</button><span class="rowNum">${right}</span></div></div></div>`;}function animateRows(container){ const set = () => container.querySelectorAll(".fill").forEach(f => f.style.width = f.dataset.w + "%"); requestAnimationFrame(()=>requestAnimationFrame(set)); setTimeout(set, 120);}function renderResultArea(){ const ra = $("resultArea"); if(result){ if(result.type==="loading") ra.innerHTML = '<div class="msgRow loading">思考中...</div>'; else if(result.type==="preparing") ra.innerHTML = '<div class="msgRow loading">正在准备神秘词…</div>'; else if(result.type==="error") ra.innerHTML = `<div class="msgRow">${result.msg}</div>`; else if(result.type==="success"){ ra.innerHTML = rowHTML(result.guess, true); animateRows(ra); } return; } ra.innerHTML = "";}function renderBoard(){ const b = $("board"); b.innerHTML = board.map(e => rowHTML(e, marker!=null && e.distance===marker)).join(""); animateRows(b); $("sep").style.display = board.length ? "block" : "none"; $("emptyState").style.display = board.length ? "none" : "block";}function renderInputRow(){ const gc = recalcMetrics(); $("guessCounter").textContent = "#"+(gc+1); $("guessCounter").style.display = state==="ongoing" ? "" : "none"; const inp = $("guessInput"); const wasDisabled = inp.disabled; inp.disabled = busy || state!=="ongoing"; if(wasDisabled && !inp.disabled) refocusGameInput(); if(state==="won") inp.placeholder = "猜对了!答案就是 " + cap(secret); else if(state==="lost") inp.placeholder = "没猜中。答案是 " + cap(secret); else inp.placeholder = phText(); let anyHint = true; try{ anyHint = pickHintRank(false) != null; }catch(e){} $("hintItem").classList.toggle("dis", !anyHint); $("menuWrap").style.display = state==="ongoing" ? "" : "none"; $("resultsBtn").classList.toggle("show", state!=="ongoing");}function renderAll(){ renderInputRow(); renderBoard(); renderResultArea(); if(marker!=null){ const el = $("board").querySelector(".markerArrow"); if(el) el.scrollIntoView({block:"nearest", behavior:"smooth"}); }}/* ---------- game actions ---------- */function applyEntry(entry){ const dup = board.find(x => x.word===entry.word); if(dup && !entry.force){ result = {type:"error", msg:`“${cap(entry.word)}”已经猜过了`}; marker = dup.distance; renderAll(); return; } entry.n = board.reduce((m,x)=>Math.max(m, x.n||0), 0) + 1; // 第几猜(结果页名次曲线的横轴) result = {type:"success", guess:entry}; board = [...board, entry].sort((a,b)=>a.distance-b.distance); marker = entry.distance; const win = entry.distance===1; if(win){ const gaveUp = entry.isHint; state = gaveUp ? "lost" : "won"; persistCurrent(); finishToHistory(); setTimeout(()=> gaveUp ? sfx.lose() : sfx.win(), 350); setTimeout(()=>{ if(state!=="ongoing" && !resultsOpen) showResults(); }, 1300); } else { persistCurrent(); setTimeout(sfx.reveal, 120); } renderAll();}async function submitGuess(raw){ if(busy || state!=="ongoing" || !cfg) return false; const v = validateGuess(raw); if(!v.valid){ const M = { "too-short":"词语太短", "too-long":"词语太长", "invalid-character":"请输入有效的" + langCn() + "单词", "disallowed":"不行哦。", "too-common":"这个词太常见了,换一个试试" }; if(v.reason==="duplicate"){ const ex = board.find(e => e.word===v.word); result = {type:"error", msg:`“${cap(v.word)}”已经猜过了`}; marker = ex ? ex.distance : null; renderAll(); } else { result = {type:"error", msg:M[v.reason]}; if(v.reason!=="too-short") sfx.error(); renderResultArea(); } return false; } const word = v.word; if(word==null || !IDX.has(word)){ result = {type:"error", msg:"抱歉,词表里没有这个词"}; sfx.notfound(); renderResultArea(); return false; } busy = true; result = {type:"loading"}; renderInputRow(); renderResultArea(); let r, d; if(REMOTE){ if(!gameToken && !(await ensureToken())){ busy = false; result = {type:"error", msg:"网络异常,请重试"}; sfx.error(); renderResultArea(); return false; } let it = null; try{ const j = await apiPost("/guess", { t: gameToken, words:[word] }); it = j.items && j.items[0]; }catch(e){ busy = false; result = {type:"error", msg:"网络异常,请重试"}; sfx.error(); renderResultArea(); return false; } if(!it){ busy = false; result = {type:"error", msg:"抱歉,词表里没有这个词"}; sfx.notfound(); renderResultArea(); return false; } r = it.r; d = it.d; if(r === 1) secret = word; // guessed the secret — the only way to learn it before the end } else { await new Promise(res => setTimeout(res, 180 + Math.random()*140)); r = ranks.rankOf.get(word); d = ranks.distOf[r-1]; } busy = false; applyEntry({word, r, d, distance:d, isHint:false}); return true;}async function requestHint(giveUp){ if(busy || state!=="ongoing" || !cfg) return; busy = true; result = {type:"loading"}; renderInputRow(); renderResultArea(); if(REMOTE && !gameToken && !(await ensureToken())){ busy = false; result = {type:"error", msg:"网络异常,请重试"}; sfx.error(); renderResultArea(); return; } const tr = giveUp ? 1 : pickHintRank(false); if(tr == null){ busy = false; result = {type:"error", msg:"获取提示失败,请重试"}; sfx.error(); renderResultArea(); return; } let word, d, r; if(REMOTE){ try{ const j = await apiPost("/hint", { t: gameToken, r: tr }); word = j.word; d = j.d; r = j.r || tr; }catch(e){ busy = false; result = {type:"error", msg:"网络异常,请重试"}; sfx.error(); renderResultArea(); return; } if(giveUp) secret = word; // revealed by the rank-1 hint } else { await new Promise(res => setTimeout(res, 240)); word = ranks.wordAt[tr-1]; d = ranks.distOf[tr-1]; r = tr; } busy = false; applyEntry({word, r, d, distance:d, isHint:true, force:giveUp});}function clearResult(){ result = null; marker = null; renderResultArea(); }/* ---------- results & stats ---------- */function showResults(){ resultsOpen = true; $("gameView").style.display = "none"; $("resultsView").style.display = "block"; window.scrollTo(0,0); const won = state==="won"; $("resTitle").textContent = won ? "猜对了!答案就是" : "没猜中。答案是"; const rw = $("resWord"); rw.textContent = cap(secret); rw.dataset.w = secret; rw.className = "resWord "+(won?"won":"lost"); const gc = recalcMetrics(); $("chipGuesses").innerHTML = IC.target + ` ${gc} 次猜测`; $("chipHints").innerHTML = IC.bulbSm + ` ${hintCount} 次提示`; $("chipMode").innerHTML = IC.book + ` ${escHtml(LANGS[LANG].badge)} ${escHtml(rangeLabel(cfg.lo, cfg.hi))}`; renderStats(); renderHomeStats();}function hideResults(){ resultsOpen = false; $("resultsView").style.display = "none"; $("gameView").style.display = "block";}function historyStats(){ let played=0, wins=0, sum=0; for(const g of store.history){ if(g.state!=="won" && g.state!=="lost") continue; played++; if(g.state==="won"){ wins++; sum += (g.board||[]).filter(e=>!e.isHint).length; } } return {played, wins, avg: wins? (sum/wins):0};}// 本局每次猜测的名次曲线(横轴=第几猜,纵轴=该词在词库中的绝对名次,对数刻度,1 在顶部)function rankCurveSVG(){ const pts = board .map(e => ({ w:e.word, n:e.n||0, r:(e.r!=null ? e.r : null), hint:!!e.isHint })) .filter(p => p.r != null && p.r >= 1); if(!pts.length) return ""; pts.sort((a,b) => (a.n||0) - (b.n||0)); let last = 0; for(const p of pts){ if(!p.n) p.n = last + 1; last = p.n; } // 老存档没有次序时按现有顺序补 const NMAX = Math.max(2, N); const W = 520, H = 200, PL = 62, PR = 16, PT = 14, PB = 24; const xs = n => pts.length <= 1 ? (PL + W - PR) / 2 : PL + (n-1)/(pts[pts.length-1].n - 1) * (W - PL - PR); const ys = r => PT + (Math.log(Math.max(1, Math.min(NMAX, r))) / Math.log(NMAX)) * (H - PT - PB); const ticks = [1, 10, 100, 1000].filter(t => t < NMAX).concat([NMAX]); const grid = ticks.map(t => `<line class="rcGrid" x1="${PL}" y1="${ys(t).toFixed(1)}" x2="${W-PR}" y2="${ys(t).toFixed(1)}"/>` + `<text class="rcTick" x="${PL-8}" y="${(ys(t) + (t===NMAX ? -5 : 3.5)).toFixed(1)}">${fmt(t)}</text>`).join(""); const line = pts.length > 1 ? `<polyline class="rcLine" points="${pts.map(p => xs(p.n).toFixed(1)+","+ys(p.r).toFixed(1)).join(" ")}"/>` : ""; const hits = pts.map(p => `<circle class="rcHit" cx="${xs(p.n).toFixed(1)}" cy="${ys(p.r).toFixed(1)}" r="15" data-w="${escHtml(p.w)}"/>`).join(""); const dots = pts.map(p => { const x = xs(p.n).toFixed(1), y = ys(p.r).toFixed(1); const tip = `<title>第 ${p.n} 猜${p.hint?"(提示)":""}:${escHtml(cap(p.w))} · 第 ${fmt(p.r)} 名</title>`; if(p.r === 1) return `<circle class="rcWin" cx="${x}" cy="${y}" r="7.5" data-w="${escHtml(p.w)}">${tip}</circle>`; if(p.hint) return `<rect class="rcHint" x="${(x-5.2).toFixed(1)}" y="${(y-5.2).toFixed(1)}" width="10.4" height="10.4" transform="rotate(45 ${x} ${y})" data-w="${escHtml(p.w)}">${tip}</rect>`; return `<circle class="rcDot" cx="${x}" cy="${y}" r="4.6" data-w="${escHtml(p.w)}">${tip}</circle>`; }).join(""); const endN = pts[pts.length-1].n; const xlab = `<text class="rcTick rcX" x="${PL}" y="${H-4}">第1猜</text>` + (endN > 1 ? `<text class="rcTick rcX" x="${W-PR-10}" y="${H-4}">第${endN}猜</text>` : ""); return `<svg class="rankSvg" viewBox="0 0 ${W} ${H}" role="img" aria-label="本局每次猜测的名次曲线">${grid}${line}${hits}${dots}${xlab}</svg>`;}function renderStats(){ const h = historyStats(); $("statSub").textContent = h.played===0 ? "还没有记录,来玩一局吧!" : (h.wins ? `已完成 ${h.played} 局 · 胜率 ${Math.round(h.wins/h.played*100)}% · 平均 ${h.avg.toFixed(1)} 次猜中` : `已完成 ${h.played} 局 · 快来赢下第一局!`); const svg = rankCurveSVG(); $("rankChart").innerHTML = svg || '<div class="rcEmpty">本局还没有可绘制的猜测记录。</div>'; $("chartCap").textContent = svg ? "本局每次猜测的名次(纵轴:1 = 答案,越靠上越接近,对数刻度) · 橙色菱形为提示 · 点圆点查词典" : "";}function renderHomeStats(){ const h = historyStats(); const el = $("homeStats"); if(h.played===0){ el.innerHTML = ""; return; } el.innerHTML = `<span class="chip">${h.played} 局游戏</span>` + (h.wins ? `<span class="chip">胜率 ${Math.round(h.wins/h.played*100)}%</span>` : "");}/* ---------- share ---------- */function shareText(){ const gc = recalcMetrics(); if(state==="won") return `我在 GuessWord(${LANGS[LANG].badge} ${rangeLabel(cfg.lo, cfg.hi)})用 ${gc} 次猜测猜中了 “${cap(secret)}”!(${hintCount} 次提示)`; return `我在 GuessWord(${LANGS[LANG].badge} ${rangeLabel(cfg.lo, cfg.hi)})里放弃了,答案是 “${cap(secret)}”。`;}async function copyText(s){ try{ await navigator.clipboard.writeText(s); return true; } catch(e){ try{ const ta = document.createElement("textarea"); ta.value = s; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); return true; }catch(e2){ return false; } }}let toastTimer = null;function toast(msg){ const el = $("toast"); el.textContent = msg; el.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(()=> el.classList.remove("show"), 2300);}/* ---------- dialogs ---------- */function openOv(id){ $(id).classList.add("open"); }function closeOv(id){ $(id).classList.remove("open"); }function refocusGameInput(){ const coarse = window.matchMedia && window.matchMedia("(pointer:coarse)").matches; if(coarse) return; if($("game").classList.contains("on") && state==="ongoing" && !busy){ setTimeout(()=>{ try{ $("guessInput").focus(); }catch(e){} }, 60); }}let confirmAction = null;function askConfirm(title, body, action){ $("confirmTitle").textContent = title; $("confirmBody").textContent = body; confirmAction = action; openOv("confirmOverlay");}$("confirmCancel").onclick = ()=> closeOv("confirmOverlay");$("confirmYes").onclick = ()=>{ closeOv("confirmOverlay"); if(confirmAction){ const a = confirmAction; confirmAction = null; a(); } };/* menu */$("menuBtn").onclick = e =>{ e.stopPropagation(); sfx.click(); $("dropdown").classList.toggle("open"); };document.addEventListener("click", e=>{ if(!e.target.closest(".menuWrap")) $("dropdown").classList.remove("open"); });$("hintItem").onclick = ()=>{ $("dropdown").classList.remove("open"); requestHint(false);};$("giveupItem").onclick = ()=>{ $("dropdown").classList.remove("open"); askConfirm("放弃?", "确定要放弃吗?答案会被揭晓,游戏结束。", ()=> requestHint(true));};/* header */$("helpBtn").onclick = ()=> openOv("htpOverlay");$("htpOk").onclick = ()=> closeOv("htpOverlay");$("homeBtn").onclick = ()=>{ if(state==="ongoing" && board.length>0){ askConfirm("回到首页?", "这一局的进度会保留,可以随时继续。", ()=> goHome()); } else goHome();};function goHome(){ hideResults(); switchScreen("home"); refreshHome();}document.querySelectorAll(".overlay").forEach(ov=>{ ov.addEventListener("click", e=>{ if(e.target===ov){ ov.classList.remove("open"); refocusGameInput(); } });});document.addEventListener("keydown", e=>{ if(e.key==="Escape"){ document.querySelectorAll(".overlay.open").forEach(o=>o.classList.remove("open")); $("dropdown").classList.remove("open"); hideAc(); refocusGameInput(); }});/* theme & sound quick toggles (all screens) */function applyTheme(){ document.documentElement.dataset.theme = store.settings.theme || "dark"; document.querySelectorAll(".themeT").forEach(b=>{ b.innerHTML = (store.settings.theme==="light") ? IC.moon : IC.sun; });}function syncSoundUI(){ document.querySelectorAll(".sndT").forEach(b=>{ b.innerHTML = store.settings.sound ? IC.sndOn : IC.sndOff; });}document.querySelectorAll(".themeT").forEach(b=>{ b.onclick = ()=>{ store.settings.theme = store.settings.theme==="light" ? "dark" : "light"; save(); applyTheme(); sfx.click(); };});document.querySelectorAll(".sndT").forEach(b=>{ b.onclick = ()=>{ store.settings.sound = !store.settings.sound; save(); syncSoundUI(); if(store.settings.sound) sfx.click(); };});/* ---------- notebook ---------- */function openNotebook(){ renderNbList(); openOv("nbOverlay"); }function renderNbList(){ const list = $("nbList"), words = store.notebook; $("nbSub").textContent = words.length ? `共 ${words.length} 个词 · 点 × 移除,「复制全部」可导出复习` : ""; if(!words.length){ list.innerHTML = `<div class="nbEmpty">还没有收藏。猜词时点击行尾的书签图标,可以把答案、提示词或你的猜测收进来。</div>`; return; } list.innerHTML = [...words].reverse().map(w=>{ const li = IDX.has(w) ? LVIDX[IDX.get(w)] : null; const tag = li!=null && li<=5 ? `<span class="nbL">${LEVELS[li]}</span>` : ""; return `<div class="nbItem"><span class="nbW" data-w="${w}">${cap(w)}</span>${tag}<button class="nbDel" data-w="${w}" aria-label="移除">×</button></div>`; }).join("");}function toggleNotebook(w){ const i = store.notebook.indexOf(w); const added = i<0; if(added) store.notebook.push(w); else store.notebook.splice(i,1); save(); sfx.click(); document.querySelectorAll(`.nbT[data-w="${w}"]`).forEach(b=>{ b.classList.toggle("on", added); b.innerHTML = added ? IC.bkFill : IC.bk; }); if($("nbOverlay").classList.contains("open")) renderNbList(); toast(added ? `已收藏 ${cap(w)}` : `已移除 ${cap(w)}`);}document.addEventListener("click", e=>{ const t = e.target.closest(".nbT"); if(t && t.dataset.w) toggleNotebook(t.dataset.w);});$("nbList").addEventListener("click", e=>{ const d = e.target.closest(".nbDel"); if(d){ toggleNotebook(d.dataset.w); return; } const wc = e.target.closest(".nbW"); if(wc && wc.dataset.w) openDict(wc.dataset.w);});function nbAdd(){ const inp = $("nbAddInp"); const w = inp.value.trim().toLowerCase(); if(!LANGS[LANG].chars.test(w)){ toast("请输入" + langCn() + "单词"); return; } if(!IDX.has(w)){ toast("词库里没有这个词"); return; } if(store.notebook.includes(w)){ toast("已在生词本中"); return; } store.notebook.push(w); save(); inp.value = ""; renderNbList(); sfx.click(); toast(`已收藏 ${cap(w)}`);}$("nbAddBtn").onclick = nbAdd;$("nbAddInp").addEventListener("keydown", e=>{ if(e.key==="Enter" && !e.isComposing){ e.preventDefault(); nbAdd(); } });$("nbCopy").onclick = async ()=>{ if(!store.notebook.length){ toast("生词本是空的"); return; } (await copyText(store.notebook.map(cap).join(", "))) ? toast(`已复制 ${store.notebook.length} 个词`) : toast("复制失败");};$("nbClose").onclick = ()=>{ closeOv("nbOverlay"); refocusGameInput(); };$("nbHomeBtn").onclick = openNotebook;$("nbItem").onclick = ()=>{ $("dropdown").classList.remove("open"); openNotebook(); };/* ---------- dictionary ---------- */const escHtml = s => String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));const POS_ZH = {noun:"名词",verb:"动词",adj:"形容词",adv:"副词",pron:"代词",prep:"介词",conj:"连词",det:"限定词",particle:"助词",intj:"感叹词",num:"数词",name:"专名",phrase:"短语",abbr:"缩写",symbol:"符号",suffix:"后缀",prefix:"前缀",aux:"助动词",article:"冠词"};const DICT_CACHE = {};const DICT_ONE = new Map(); // online mode: "lang|word" -> entry | null (miss) | 0 (error, not cached)const DICT_ERR = 0;async function dictLookupOne(lang, word){ const key = lang + "|" + word; if(DICT_ONE.has(key)) return DICT_ONE.get(key); let v = null; try{ const r = await fetch(apiURL("/def?lang=" + encodeURIComponent(lang) + "&word=" + encodeURIComponent(word))); if(!r.ok) throw new Error("HTTP " + r.status); const j = await r.json(); v = j.entry || null; }catch(e){ return DICT_ERR; } DICT_ONE.set(key, v); if(DICT_ONE.size > 4000) DICT_ONE.clear(); return v;}let dlSeq = 0, dictLang = "en";function loadDict(lang){ lang = lang || LANG; if(DICT_CACHE[lang]) return Promise.resolve(DICT_CACHE[lang]); if(DICT_CACHE["!"+lang]) return Promise.resolve(null); const fn = DICT_SRC[lang]; let b64 = null; try{ b64 = fn ? fn() : null; }catch(e){ b64 = null; } if(!b64) return Promise.resolve(null); return new Response(new Blob([Uint8Array.from(atob(b64), c=>c.charCodeAt(0))]).stream().pipeThrough(new DecompressionStream("gzip"))).text() .then(t=>{ DICT_CACHE[lang] = JSON.parse(t); return DICT_CACHE[lang]; }) .catch(()=>{ DICT_CACHE["!"+lang] = true; return null; });}function openDict(w, lang){ dictLang = (lang && LANGS[lang] && packAvailable(lang)) ? lang : LANG; openOv("dlOverlay"); $("dlSearch").value = w ? (dictLang==="de" ? w : cap(w)) : ""; renderDict(w ? w.toLowerCase() : ""); setTimeout(()=>{ try{ $("dlSearch").focus(); }catch(e){} }, 60);}async function renderDict(q){ const seq = ++dlSeq, box = $("dlEntry"); const dl = dictLang; if(!q){ box.innerHTML = '<div class="dlEmpty">输入一个单词,或直接点击棋盘、生词本里的词。</div>'; return; } let key = null, e = null; const cands = [q, q.toLowerCase()]; try{ const f = foldWord(q); if(f) cands.push(f); }catch(err){} if(REMOTE){ box.innerHTML = '<div class="dlEmpty">词典加载中…</div>'; for(const c of cands){ if(!c) continue; const ent = await dictLookupOne(dl, c); if(ent === DICT_ERR){ if(seq === dlSeq && $("dlOverlay").classList.contains("open")) box.innerHTML = '<div class="dlEmpty">词典加载失败,请重试。</div>'; return; } if(ent){ key = c; e = ent; break; } } if(seq !== dlSeq || !$("dlOverlay").classList.contains("open")) return; } else { if(!DICT_CACHE[dl]){ box.innerHTML = '<div class="dlEmpty">词典加载中…</div>'; await loadDict(dl); if(seq !== dlSeq || !$("dlOverlay").classList.contains("open")) return; if(!DICT_CACHE[dl]){ box.innerHTML = '<div class="dlEmpty">词典数据加载失败。</div>'; return; } } const DD = DICT_CACHE[dl]; for(const c of cands){ if(c && DD[c]){ key = c; e = DD[c]; break; } } } if(!key){ box.innerHTML = `<div class="dlEmpty">词典未收录 “${escHtml(q)}”。</div>`; return; } const P = getPack(dl); const li = (P && P.IDX.has(key)) ? P.LVIDX[P.IDX.get(key)] : null; const lv = li!=null && li<=5 ? `<span class="dlLv">${escHtml(levelsOf(dl)[li])}</span>` : ""; let disp = key; if(dl === "en" || (dl === "de" && e.p && e.p.every(g=>g.p==="noun"))) disp = cap(key); const head = `<div class="dlHead"><span class="dlWord">${escHtml(disp)}</span>${lv}<button type="button" class="nbT dlNbT" data-w="${escHtml(key)}" aria-label="生词本">${store.notebook.includes(key)?IC.bkFill:IC.bk}</button></div>`; if(e.pos){ // english entries: {us,uk,pos:[{p,s:[{d,t,ex}]}],zh} const ipa = []; if(e.us) ipa.push(`<span class="dlIpa">美 /${escHtml(e.us)}/</span>`); if(e.uk) ipa.push(`<span class="dlIpa">英 /${escHtml(e.uk)}/</span>`); const posHtml = (e.pos||[]).map(pb=>{ const senses = pb.s.map((s,i)=>{ const tag = s.t ? `<i class="dlTag">${escHtml(s.t)}</i>` : ""; const ex = (s.ex||[]).map(x=>`<div class="dlEx">“${escHtml(x)}”</div>`).join(""); return `<div class="dlSense"><span class="dlN">${i+1}.</span><div class="dlSenseBody"><div class="dlDef">${tag}${escHtml(s.d)}</div>${ex}</div></div>`; }).join(""); return `<div class="dlPosBlock"><div class="dlPos">${escHtml(pb.p)}</div>${senses}</div>`; }).join(""); const zh = (e.zh && e.zh.length) ? `<div class="dlZh"><div class="dlZhH">中文释义 · ECDICT</div>${e.zh.map(z=>`<div class="dlZhLine">${escHtml(z)}</div>`).join("")}</div>` : ""; const src = e.src === "e" ? "英文释义来源:ECDICT" : "英文释义来源:Wiktionary (CC BY-SA)"; box.innerHTML = head + (ipa.length ? `<div class="dlIpaRow">${ipa.join("")}</div>` : "") + (posHtml || '<div class="dlEmpty">该词暂无释义数据。</div>') + zh + `<div class="dlSrc">${src} · 中文:ECDICT</div>`; return; } // non-english entries: {rd?, zhw?, p:[{p, s:[{d, z?, ex}]}]} const rd = e.rd ? `<div class="dlIpaRow"><span class="dlIpa">${escHtml(e.rd)}</span></div>` : ""; const zhw = e.zhw ? `<div class="dlIpaRow"><span class="dlIpa">中文: ${escHtml(e.zhw)}</span></div>` : ""; let hasZh = false; const posHtml = (e.p||[]).map(pb=>{ const senses = pb.s.map((s,i)=>{ const ex = (s.ex||[]).map(x=>`<div class="dlEx">“${escHtml(x)}”</div>`).join(""); let def; if(s.z){ hasZh = true; def = `<div class="dlDef">${escHtml(s.z)}</div><div class="dlDefSub">${escHtml(s.d)}</div>`; } else def = `<div class="dlDef">${escHtml(s.d)}</div>`; return `<div class="dlSense"><span class="dlN">${i+1}.</span><div class="dlSenseBody">${def}${ex}</div></div>`; }).join(""); return `<div class="dlPosBlock"><div class="dlPos">${escHtml(POS_ZH[pb.p] || pb.p || "")}</div>${senses}</div>`; }).join(""); box.innerHTML = head + rd + zhw + (posHtml || '<div class="dlEmpty">该词暂无释义数据。</div>') + `<div class="dlSrc">释义来源:Wiktionary (CC BY-SA)${hasZh ? " · 中文译文为离线机器翻译" : ""}</div>`;}function dlQuery(){ renderDict($("dlSearch").value.trim().toLowerCase()); }$("dlGo").onclick = dlQuery;$("dlSearch").addEventListener("keydown", e=>{ if(e.key==="Enter" && !e.isComposing && e.keyCode!==229){ e.preventDefault(); dlQuery(); } });$("dlClose").onclick = ()=>{ closeOv("dlOverlay"); refocusGameInput(); };$("dlHomeBtn").onclick = ()=> openDict("");$("dictItem").onclick = ()=>{ $("dropdown").classList.remove("open"); openDict(""); };document.addEventListener("click", e=>{ const t = e.target.closest(".rowWord"); if(t && t.dataset.w) openDict(t.dataset.w);});document.addEventListener("click", e=>{ const c = e.target.closest(".rankSvg .rcDot, .rankSvg .rcHint, .rankSvg .rcWin"); if(c && c.dataset.w) openDict(c.dataset.w);});/* ---------- history ---------- */function fmtWhen(at){ if(!at) return "—"; const d = new Date(at), now = new Date(); const hm = String(d.getHours()).padStart(2,"0")+":"+String(d.getMinutes()).padStart(2,"0"); const sameDay = a => a.getFullYear()===d.getFullYear() && a.getMonth()===d.getMonth() && a.getDate()===d.getDate(); const yd = new Date(now); yd.setDate(now.getDate()-1); if(sameDay(now)) return "今天 "+hm; if(sameDay(yd)) return "昨天 "+hm; return (d.getMonth()+1)+"/"+d.getDate()+" "+hm;}function histGameRows(g){ const gl = (g.lang && LANGS[g.lang]) ? g.lang : "en"; let dOf = null, rOf = null; if(g.secret){ try{ const rg = buildRanks(g.secret, gl); if(rg){ dOf = rg.distOf; rOf = rg.rankOf; } }catch(e){} } const distOf = e => { if(e.distance && g.mv===MODEL_VER) return e.distance; // finished games store the distance if(e.d != null) return e.d; // unfinished games store it too if(rOf){ const r = rOf.get(e.word); if(r) return dOf[r-1]; } return 3001; }; return [...(g.board||[])].sort((a,b)=>distOf(a)-distOf(b)).map(e=>{ const dist = distOf(e); return `<div class="hRow"><div class="hFill" style="width:${barWidth(dist)}%;background:var(--${barColor(dist)})"></div><span class="hw">${e.isHint?IC.bulbSm:""}${escHtml(cap(e.word))}${e.isHint?'<i class="hHint">提示</i>':""}</span><span class="hn">${dist===1 ? (e.isHint?"揭晓":"答案") : fmt(dist)}</span></div>`; }).join("");}function histRowHTML(g, opts){ const won = g.state==="won"; const gl = (g.lang && LANGS[g.lang]) ? g.lang : "en"; const gc = (g.board||[]).filter(e=>!e.isHint).length; if(opts && opts.unfinished){ return `<div class="histItem histOng" data-gid="${escHtml(g.gid)}"> <div class="histRow ongRow"><span class="histSt o">${IC.clock}</span> <div class="ongMain"> <div class="ongTop"><span class="histW">${escHtml(LANGS[gl].name)}</span><span class="histR">${escHtml(rangeLabelFor(g.lo,g.hi,gl))}</span></div> <div class="ongSub">已猜 ${gc} 次 · ${fmtWhen(g.at)}</div> </div> <button type="button" class="histCont" data-gid="${escHtml(g.gid)}">继续</button> <span class="histChev">${IC.chevR}</span></div> <div class="histDet"><div class="hRows">${histGameRows(g)}</div></div> </div>`; } return `<div class="histItem" data-i="${opts.i}"> <div class="histRow"><span class="histSt ${won?"w":"l"}">${won?IC.trophy:IC.x}</span> <span class="histW" data-w="${escHtml(g.secret)}" data-lang="${escHtml(gl)}">${escHtml(gl==="de" ? g.secret : cap(g.secret))}${gl!=="en"?`<i class="histLang">${escHtml(LANGS[gl].name)}</i>`:""}</span> <span class="histR">${escHtml(rangeLabelFor(g.lo,g.hi,gl))}</span> <span class="histMeta">${gc} 猜 · ${fmtWhen(g.at)}</span> <span class="histChev">${IC.chevR}</span></div> <div class="histDet"><div class="hRows">${histGameRows(g)}</div></div> </div>`;}function histSecHTML(label){ return `<div class="histSec">${label}</div>`;}function renderHist(){ const games = store.history, list = $("histList"); const wins = games.filter(g=>g.state==="won").length; const ong = store.games.slice().reverse(); const parts = []; if(ong.length) parts.push(`${ong.length} 局未完成`); if(games.length) parts.push(`已完成 ${games.length} 局 · 胜 ${wins} · 胜率 ${Math.round(wins/games.length*100)}%`); $("histSub").textContent = parts.length ? parts.join(" · ") : "完成一局后会自动保存在这里。"; if(!games.length && !ong.length){ list.innerHTML = `<div class="nbEmpty">还没有记录。来玩一局,结束的棋局都会存档在此。</div>`; return; } list.innerHTML = (ong.length ? histSecHTML(`未完成的对局 · ${ong.length} · 点“继续”接着玩`) + ong.map(g => histRowHTML(g, {unfinished:true})).join("") : "") + (games.length ? histSecHTML(`已完成 · ${games.length}`) + games.map((g,i) => histRowHTML(g, {i})).join("") : "");}function openHist(){ renderHist(); openOv("histOverlay"); }$("histList").addEventListener("click", e=>{ const cont = e.target.closest(".histCont"); if(cont && cont.dataset.gid){ closeOv("histOverlay"); refocusGameInput(); sfx.click(); resumeGame(cont.dataset.gid); return; } const it = e.target.closest(".histItem"); const w = e.target.closest(".histW"); if(w && (w.dataset.w || w.dataset.rw)){ const word = w.dataset.w || w.dataset.rw; const gl = w.dataset.lang || (it && it.dataset.gid && (findGame(it.dataset.gid)||{}).lang) || "en"; if(word) openDict(word, gl); return; } if(it && e.target.closest(".histRow")) it.classList.toggle("open");});$("histClear").onclick = ()=>{ if(!store.history.length && !store.games.length){ toast("还没有记录"); return; } const n = store.history.length + store.games.length; askConfirm("清空历史记录?", `将删除全部 ${n} 局存档(含未完成),不可恢复。`, ()=>{ store.history = []; store.games = []; save(); renderHist(); renderHomeStats(); toast("已清空历史记录"); });};$("histClose").onclick = ()=>{ closeOv("histOverlay"); refocusGameInput(); };$("histHomeBtn").onclick = openHist;$("histItem").onclick = ()=>{ $("dropdown").classList.remove("open"); openHist(); };/* ---------- autocomplete ---------- */let AC_ORDER = null, AC_ORDER_LANG = null, acList = [], acHl = -1;function acOrder(){ if(!AC_ORDER || AC_ORDER_LANG !== LANG){ AC_ORDER = WORDS.map((w,i)=>i).sort((a,b)=> LVIDX[a]-LVIDX[b] || (WORDS[a]<WORDS[b]?-1:1)); AC_ORDER_LANG = LANG; } return AC_ORDER;}function acSuggest(q){ const guessed = new Set(board.map(e=>e.word)); const out = []; for(const i of acOrder()){ const w = WORDS[i]; if(w && w.length>q.length && w.startsWith(q) && !guessed.has(w) && !STOP_EN.has(w) && !BLOCKED.some(b=>w.startsWith(b))){ out.push(i); if(out.length>=8) break; } } return out;}function hideAc(){ $("acBox").style.display="none"; acList=[]; acHl=-1; }function renderAc(){ const q = $("guessInput").value.trim().toLowerCase(); $("acBox").innerHTML = acList.map((i,ix)=>{ const w = WORDS[i], li = LVIDX[i]; return `<button type="button" class="acItem${ix===acHl?" hl":""}" data-i="${i}"><span><b>${w.slice(0,q.length)}</b>${w.slice(q.length)}</span><span class="lv">${li<=5?LEVELS[li]:""}</span></button>`; }).join(""); $("acBox").style.display="block";}function updateAc(){ const inp = $("guessInput"); if(!store.settings.ac || state!=="ongoing" || inp.disabled){ hideAc(); return; } const q = inp.value.trim().toLowerCase(); const minLen = LANG==="en" ? 3 : 1; if(q.length < minLen || !LANGS[LANG].chars.test(q)){ hideAc(); return; } acList = acSuggest(q); acHl = -1; if(acList.length) renderAc(); else hideAc();}function pickAc(ix, andSubmit){ const inp = $("guessInput"); inp.value = WORDS[acList[ix]]; hideAc(); inp.focus(); if(andSubmit) doSubmit();}function pickAcWord(wi){ // data-i holds the index into WORDS (not the position in acList) const inp = $("guessInput"); const w = WORDS[wi]; if(typeof w === "string") inp.value = w; hideAc(); inp.focus();}$("guessInput").addEventListener("input", updateAc);$("guessInput").addEventListener("blur", ()=> setTimeout(hideAc, 120));$("acBox").addEventListener("mousedown", e=> e.preventDefault());$("acBox").addEventListener("click", e=>{ const it = e.target.closest(".acItem"); if(it) pickAcWord(+it.dataset.i);});function syncAcUI(){ $("acLabel").textContent = "自动补全 · " + (store.settings.ac ? "开" : "关"); }$("acItem").onclick = ()=>{ store.settings.ac = store.settings.ac === false; save(); syncAcUI(); sfx.click(); $("dropdown").classList.remove("open"); if(!store.settings.ac) hideAc();};document.addEventListener("click", e=>{ if(!e.target.closest("#guessForm")) hideAc(); });/* ---------- home & setup ---------- */function switchScreen(id){ document.querySelectorAll(".screen").forEach(s => s.classList.toggle("on", s.id===id)); window.scrollTo(0,0);}function refreshHome(){ renderHomeStats(); const c = latestGame(); const canResume = !!(c && c.board && c.board.length > 0); const b = $("resumeBtn"); b.classList.toggle("show", canResume); if(canResume){ const cl = (c.lang && LANGS[c.lang]) ? c.lang : "en"; const gc = (c.board||[]).filter(e => !e.isHint).length; b.innerHTML = `继续上局 · ${escHtml(LANGS[cl].badge)} ${escHtml(rangeLabelFor(c.lo, c.hi, cl))}${cl!=="en"?` · ${escHtml(LANGS[cl].name)}`:""} · 已猜 ${gc} 次`; b.onclick = ()=>{ sfx.click(); resumeGame(c.gid); }; }}async function resumeGame(gid){ const g = findGame(gid); if(!g) return; busy = true; try{ if(g.lang && g.lang !== LANG){ await ensurePack(g.lang); activateLang(g.lang); store.settings.lang = g.lang; save(); renderLangRow(); try{ refreshLvCards(); }catch(e){} } }catch(e){ busy = false; toast("该语言数据加载失败,请重试"); return; } busy = false; startGame(g.lo, g.hi, g.gid);}$("startBtn").onclick = ()=>{ sfx.click(); selLo = null; selHi = null; refreshLvCards(); renderLvSel(); switchScreen("setup"); warmLang(); // preload the language while the player picks a level range};$("backHome").onclick = ()=>{ switchScreen("home"); refreshHome(); };const LV_META = { en: [{en:"A1", zh:"入门"},{en:"A2", zh:"基础"},{en:"B1", zh:"中级"},{en:"B2", zh:"中高级"},{en:"C1", zh:"高级"},{en:"C2", zh:"精通"}], ja: [{en:"N5", zh:"入门"},{en:"N4", zh:"初级"},{en:"N3", zh:"中级"},{en:"N2", zh:"中高级"},{en:"N1", zh:"高级"}], ko: [{en:"延世1", zh:"入门"},{en:"延世2", zh:"初级"},{en:"延世3", zh:"中级上"},{en:"延世4", zh:"中级下"},{en:"延世5", zh:"高级上"},{en:"延世6", zh:"高级下"}], common6: [{en:"频段1", zh:"最常用"},{en:"频段2", zh:"常用"},{en:"频段3", zh:"较常用"},{en:"频段4", zh:"进阶"},{en:"频段5", zh:"拓展"},{en:"频段6", zh:"低频"}],};const SRC_NOTE = { en: "神秘词取自区间内的牛津/CEFR-J 官方分级词表", ja: "神秘词取自 JLPT N5–N1 词表", ko: "神秘词取自延世韩国语教材 1–6 册词表", common: "神秘词取自语料词频分级表",};const lvMetaOf = lang => LV_META[lang] || LV_META.common6;const srcNoteOf = lang => SRC_NOTE[lang] || SRC_NOTE.common;let LV_COUNTS = [];function refreshLvCards(){ LV_COUNTS = LEVELS.map((_, i)=>LVIDX.reduce((a,l)=>a+(l===i?1:0), 0)); $("lvGrid").innerHTML = lvMetaOf(LANG).map((m,i)=> `<button class="lvCard" data-i="${i}"><span class="nm">${m.en}</span><span class="ds">${m.zh}</span><span class="ct">${LV_COUNTS[i].toLocaleString("en-US")} 词</span></button>`).join(""); const sn = $("srcNote"); if(sn) sn.textContent = srcNoteOf(LANG); $("dictCount").textContent = N.toLocaleString("en-US"); $("secretCount").textContent = LVIDX.filter(l=>l<=5).length.toLocaleString("en-US"); bindLvCards();}/* ---------- 难度选择:点击两点框区间 / 按住拖动框选(鼠标+触屏) / 悬停实时预览 ---------- */const coarsePtr = window.matchMedia && window.matchMedia("(pointer:coarse)").matches;let hoverIdx = null, dragging = false, dragStart = null, suppressClick = false;function renderLvSel(){ const cards = document.querySelectorAll(".lvCard"); let lo = selLo, hi = selHi, pending = false; if(dragging){ if(hoverIdx==null || hoverIdx===dragStart){ lo = dragStart; hi = null; pending = true; } else { lo = Math.min(dragStart, hoverIdx); hi = Math.max(dragStart, hoverIdx); } } else if(selLo!=null && selHi==null){ pending = true; if(hoverIdx!=null && hoverIdx!==selLo){ lo = Math.min(selLo, hoverIdx); hi = Math.max(selLo, hoverIdx); } } cards.forEach((x,i)=>{ const inR = lo!=null && hi!=null && i>=lo && i<=hi; x.classList.toggle("sel", inR); x.classList.toggle("pend", pending && !inR && lo!=null && i===lo); }); const info = $("lvInfo"), begin = $("beginBtn"); const resetBtn = selLo!=null ? `<button class="lvReset" type="button">重置</button>` : ""; if(selLo==null && !dragging){ info.innerHTML = `${IC.tag} ${coarsePtr ? "点一个等级作为起点,再点另一个等级框出区间" : "点击一个等级作为起点,或按住拖动直接框选区间"}`; begin.textContent = "请先选择区间"; begin.classList.remove("ready"); } else if(hi!=null){ const real = selHi!=null && !dragging; info.innerHTML = `${IC.tag} ${real?"难度区间":"预览区间"} <b>${rangeLabel(lo, hi)}</b> · 区间内共 <b>${poolForRange(lo, hi).length.toLocaleString("en-US")}</b> 个候选词 ${real?resetBtn:""}`; begin.textContent = real ? "开始游戏" : "请先选择区间"; begin.classList.toggle("ready", real); } else { info.innerHTML = `${IC.tag} 起点 <b>${LEVELS[lo]}</b> · ${coarsePtr ? "再点一个等级完成区间,点起点本身 = 单一级别" : "点击或拖到另一等级框出区间,再点起点本身 = 单一级别"} ${resetBtn}`; begin.textContent = "请先选择区间"; begin.classList.remove("ready"); } info.classList.add("show");}function lvIdxFromPoint(x, y){ const el = document.elementFromPoint(x, y); const card = el && el.closest ? el.closest(".lvCard") : null; return card ? +card.dataset.i : null;}function bindLvCards(){ document.querySelectorAll(".lvCard").forEach(c=>{ c.addEventListener("pointerdown", e=>{ if(e.pointerType==="mouse" && e.button!==0) return; dragging = true; dragStart = +c.dataset.i; hoverIdx = dragStart; suppressClick = false; renderLvSel(); }); c.addEventListener("mouseenter", ()=>{ if(dragging) return; hoverIdx = +c.dataset.i; renderLvSel(); }); c.onclick = ()=>{ if(suppressClick){ suppressClick = false; return; } sfx.click(); const i = +c.dataset.i; if(selLo==null || selHi!=null){ selLo = i; selHi = null; } else { const lo0 = selLo; selLo = Math.min(lo0, i); selHi = Math.max(lo0, i); } hoverIdx = null; renderLvSel(); }; });}$("lvGrid").addEventListener("mouseleave", ()=>{ hoverIdx = null; if(!dragging) renderLvSel(); });document.addEventListener("pointermove", e=>{ if(!dragging) return; const i = lvIdxFromPoint(e.clientX, e.clientY); if(i!=null && i!==hoverIdx){ hoverIdx = i; renderLvSel(); }});document.addEventListener("pointerup", e=>{ if(!dragging) return; dragging = false; const i = lvIdxFromPoint(e.clientX, e.clientY); if(i!=null) hoverIdx = i; if(hoverIdx!=null && hoverIdx!==dragStart){ selLo = Math.min(dragStart, hoverIdx); selHi = Math.max(dragStart, hoverIdx); hoverIdx = null; sfx.click(); suppressClick = true; setTimeout(()=>{ suppressClick = false; }, 400); } renderLvSel();});document.addEventListener("pointercancel", ()=>{ if(dragging){ dragging = false; renderLvSel(); } });$("lvInfo").addEventListener("click", e=>{ if(e.target.closest(".lvReset")){ sfx.click(); selLo = null; selHi = null; hoverIdx = null; renderLvSel(); }});renderLvSel();$("beginBtn").onclick = ()=>{ if(selLo==null || selHi==null) return; sfx.click(); startGame(selLo, selHi);};/* ---------- game lifecycle ---------- */async function startGame(lo, hi, resumeGid){ const g = (resumeGid != null) ? findGame(resumeGid) : null; cfg = { lo: g ? g.lo : lo, hi: g ? g.hi : hi, lang: LANG, gid: g ? g.gid : newGid() }; secret = undefined; gameToken = null; ranks = null; board = []; state = "ongoing"; result = null; marker = null; resultsOpen = false; hideResults(); $("modeBadge").textContent = LANGS[LANG].badge + " " + rangeLabel(cfg.lo, cfg.hi); switchScreen("game"); // entering the game may need a server round trip (new secret / missing ranks): // show the board right away with a "preparing" note instead of freezing on the old screen const needsServer = REMOTE && (!g || !!g.token); if(needsServer){ result = {type:"preparing"}; renderAll(); } let ready = true; try{ if(g){ // resume a saved game (any language): board comes back as saved if(g.token && REMOTE){ busy = true; ranks = { N, K:3000/N, remote:true }; gameToken = g.token; board = (g.board||[]).map(e => ({ word:e.word, isHint:!!e.isHint, r:e.r, d:e.d, n:e.n, distance:e.d })); if(board.some(e => e.r == null || e.d == null)){ try{ const j = await apiPost("/guess", { t: gameToken, words: board.map(e => e.word) }); board = (j.items || []).map((it, i) => { // the API answers in request order const e = board[i]; return it ? { ...e, r:it.r, d:it.d, distance:it.d } : e; }).filter(e => e.d != null); }catch(err){ toast("存档恢复失败,请重试"); ready = false; } } } else if(g.secret){ if(REMOTE) await fallbackToLocal(); // a local-mode save needs the vector pack secret = g.secret; ranks = buildRanks(secret); board = (g.board||[]).filter(e => IDX.has(e.word)).map(e => { const r = ranks.rankOf.get(e.word); return { word:e.word, isHint:!!e.isHint, r, d: ranks.distOf[r-1], n: e.n, distance: ranks.distOf[r-1] }; }); } else { toast("该存档无法继续,已移除"); store.games = store.games.filter(x => x.gid !== g.gid); save(); ready = false; } board.sort((a,b)=>a.distance-b.distance); } else { if(REMOTE){ // keep the UI responsive: the player can type while the secret is being created, // the first guess/hint awaits pendingNew (see ensureToken) pendingNew = apiPost("/new", { lang: LANG, lo, hi, recent: store.recent.slice(-160) }) .then(j => { gameToken = j.t; ranks = { N, K:3000/N, remote:true }; if(result && result.type === "preparing"){ result = null; renderAll(); } persistCurrent(); return j.t; }) .catch(async () => { pendingNew = null; await fallbackToLocal(); secret = pickSecret(lo, hi); ranks = buildRanks(secret); if(result && result.type === "preparing"){ result = null; renderAll(); } persistCurrent(); return null; }); } else { secret = pickSecret(lo, hi); ranks = buildRanks(secret); persistCurrent(); } } } finally { busy = false; } if(ready){ renderAll(); $("guessInput").focus(); } else switchScreen("home"), refreshHome(); warmLang(); // keep the isolate warm for the first guess}// wait for the in-flight /api/new of the current game (player may have typed early)async function ensureToken(){ if(gameToken || !pendingNew) return !!gameToken; try{ await pendingNew; }catch(e){} return !!gameToken;}// online engine unreachable (offline / server error): degrade to the local vector engineasync function fallbackToLocal(){ if(!window.__loadLang){ toast("在线引擎不可用,请检查网络后重试"); return; } REMOTE = false; toast("在线引擎暂不可用,已切换为本地词库模式"); try{ await window.__loadLang(LANG, f => toast("正在下载本地词库 " + Math.round(f*100) + "%")); delete packCache[LANG]; getPack(LANG); activateLang(LANG); }catch(e){ toast("本地词库下载失败,请检查网络"); }}/* ---------- results buttons ---------- */$("resultsBtn").onclick = ()=>{ sfx.click(); showResults(); };$("resWord").onclick = ()=>{ if(secret) openDict(secret); };$("boardBtn").onclick = ()=>{ if(!cfg) return; hideResults(); };$("newWordBtn").onclick = async ()=>{ if(!cfg) return; sfx.click(); hideResults(); cfg.gid = newGid(); secret = undefined; gameToken = null; pendingNew = null; board = []; state = "ongoing"; result = null; marker = null; if(REMOTE){ result = {type:"preparing"}; pendingNew = apiPost("/new", { lang: LANG, lo: cfg.lo, hi: cfg.hi, recent: store.recent.slice(-160) }) .then(j => { gameToken = j.t; ranks = { N, K:3000/N, remote:true }; if(result && result.type === "preparing"){ result = null; renderAll(); } persistCurrent(); return j.t; }) .catch(async () => { pendingNew = null; secret = pickSecret(cfg.lo, cfg.hi); ranks = buildRanks(secret); if(result && result.type === "preparing"){ result = null; renderAll(); } persistCurrent(); return null; }); } else { secret = pickSecret(cfg.lo, cfg.hi); ranks = buildRanks(secret); persistCurrent(); } renderAll(); $("guessInput").focus();};$("shareBtn").onclick = async ()=>{ if(!cfg) return; (await copyText(shareText())) ? toast("已复制到剪贴板") : toast("复制失败");};/* ---------- form ---------- */function doSubmit(){ hideAc(); const inp = $("guessInput"); submitGuess(inp.value).then(ok => { if(ok) inp.value = ""; });}$("guessForm").addEventListener("submit", e=>{ e.preventDefault(); doSubmit(); });$("guessInput").addEventListener("keydown", e=>{ const acOpen = $("acBox").style.display==="block" && acList.length>0; if(acOpen && (e.key==="ArrowDown" || e.key==="ArrowUp")){ e.preventDefault(); acHl = e.key==="ArrowDown" ? (acHl+1)%acList.length : (acHl-1+acList.length)%acList.length; renderAc(); const el = $("acBox").children[acHl]; if(el) el.scrollIntoView({block:"nearest"}); return; } if(acOpen && e.key==="Tab"){ e.preventDefault(); pickAc(acHl>=0 ? acHl : 0, false); return; } if(acOpen && e.key==="Escape"){ hideAc(); return; } if(e.key==="Enter" && !e.isComposing && e.keyCode!==229){ e.preventDefault(); if(acOpen && acHl>=0) pickAc(acHl, true); else doSubmit(); }});/* placeholder dots */let dotIdx = 0;setInterval(()=>{ const inp = $("guessInput"); if(!cfg || state!=="ongoing" || inp.disabled || inp.value.length>0) return; const dots = ["", ".", "..", "..."]; inp.placeholder = phText() + dots[dotIdx++ % 4];}, 500);$("guessInput").addEventListener("input", ()=>{ const inp = $("guessInput"); if(inp.value.length>0 && !inp.placeholder.startsWith(phText())) inp.placeholder = phText(); if(inp.value.length===0) inp.placeholder = phText();});/* ---------- language switch ---------- */function renderLangRow(){ const row = $("langRow"); if(!row) return; const canLoad = k => packAvailable(k) || !!window.__loadLang; row.innerHTML = Object.keys(LANGS).filter(canLoad).map(k => `<button type="button" class="langBtn${k===LANG?" on":""}" data-lang="${k}">${LANGS[k].name}</button>`).join(""); row.querySelectorAll(".langBtn").forEach(b => { b.onclick = ()=> selectLang(b.dataset.lang); });}async function selectLang(lang){ if(lang===LANG || !LANGS[lang]) return; if(!packAvailable(lang)){ const btn = document.querySelector(`.langBtn[data-lang="${lang}"]`); if(btn) btn.classList.add("busy"); try{ await ensurePack(lang); }catch(e){} if(btn) btn.classList.remove("busy"); if(!packAvailable(lang)){ toast("该语言数据加载失败,请重试"); return; } } sfx.click(); activateLang(lang); store.settings.lang = lang; save(); try{ refreshLvCards(); }catch(e){} renderLangRow(); refreshHome(); warmLang(lang); // preload the newly selected language on the server}/* ---------- boot ---------- */REMOTE = !!(window.__gwRemote && typeof fetch === "function");applyTheme();syncSoundUI();syncAcUI();$("histHomeBtn").innerHTML = IC.clock;$("nbHomeBtn").innerHTML = IC.book;$("dlHomeBtn").innerHTML = IC.search;if(!REMOTE) loadDict();switchScreen("home");refreshHome();renderLangRow();$("demoTrophy").innerHTML = IC.trophy;$("dictCount").textContent = N.toLocaleString("en-US");$("secretCount").textContent = LVIDX.filter(l=>l<=5).length.toLocaleString("en-US");window.__gw = { get answer(){ return secret || null; }, get token(){ return gameToken; }, remote: () => REMOTE, rankOf: w => ranks && ranks.rankOf ? ranks.rankOf.get(w) : undefined, distOf: r => ranks && ranks.distOf ? ranks.distOf[r-1] : undefined, info: () => ({N, levels: LEVELS.map((L,i)=>L+":"+poolFor(i).length)})};warmLang(); // first game click should not wait for a cold isolate