@mblinkov/whats-missing 20.0.19 → 20.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Game.tsx CHANGED
@@ -1,404 +1,602 @@
1
- import { useState, useEffect, useMemo, useRef } from "react";
1
+ import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
2
2
  import { themes } from "./themes";
3
3
  import { styles } from "./Game.styles";
4
4
  import type { Theme } from "./themes";
5
5
 
6
+
6
7
  type ImageItem = { src: string; name: string };
7
8
  type RoundResult = {
8
- round: number;
9
- answer: string;
10
- correct: string;
11
- result: "correct" | "almost" | "wrong";
9
+ round: number;
10
+ answer: string;
11
+ correct: string;
12
+ result: "correct" | "almost" | "wrong";
12
13
  };
13
14
 
15
+
14
16
  // ✅ базовый reset
15
17
  const globalReset = () => {
16
18
  const style = document.createElement("style");
17
19
  style.textContent = `
18
- #whats-missing-root, #whats-missing-root * {
19
- box-sizing: border-box;
20
- font-family: "Geist", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
21
- }
22
- #whats-missing-root img {
23
- max-width: 100%;
24
- height: auto;
25
- display: block;
26
- user-select: none;
27
- }
28
- html, body { margin: 0; padding: 0; }
20
+ /* scope only inside game root */
21
+ #whats-missing-root, #whats-missing-root * { box-sizing: border-box; }
22
+ #whats-missing-root img { max-width:100%; height:auto; display:block; user-select:none; }
29
23
  `;
30
24
  document.head.appendChild(style);
31
25
  };
32
26
 
33
- // простая функция сравнения
27
+
34
28
  const levenshtein = (a: string, b: string) => {
35
- const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
36
- for (let i = 0; i <= a.length; i++) dp[i][0] = i;
37
- for (let j = 0; j <= b.length; j++) dp[0][j] = j;
38
- for (let i = 1; i <= a.length; i++) {
39
- for (let j = 1; j <= b.length; j++) {
40
- dp[i][j] =
41
- a[i - 1] === b[j - 1]
42
- ? dp[i - 1][j - 1]
43
- : Math.min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]) + 1;
44
- }
45
- }
46
- return dp[a.length][b.length];
29
+ const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
30
+ for (let i = 0; i <= a.length; i++) dp[i][0] = i;
31
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
32
+ for (let i = 1; i <= a.length; i++) {
33
+ for (let j = 1; j <= b.length; j++) {
34
+ dp[i][j] =
35
+ a[i - 1] === b[j - 1]
36
+ ? dp[i - 1][j - 1]
37
+ : Math.min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]) + 1;
38
+ }
39
+ }
40
+ return dp[a.length][b.length];
47
41
  };
48
42
 
49
- export default function Game() {
50
- const containerRef = useRef<HTMLDivElement>(null);
51
43
 
52
- useEffect(() => {
53
- globalReset();
54
- return () => {
55
- document.body.style.overflow = "";
56
- };
57
- }, []);
58
-
59
- const [theme, setTheme] = useState<Theme | null>(null);
60
- const [files, setFiles] = useState<ImageItem[]>([]);
61
- const [rounds, setRounds] = useState(4);
62
- const [started, setStarted] = useState(false);
63
- const [currentRound, setCurrentRound] = useState(1);
64
- const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
65
- const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
66
- const [readyTime, setReadyTime] = useState(3);
67
- const [memorizeTime, setMemorizeTime] = useState(10);
68
- const [timeLeft, setTimeLeft] = useState(20);
69
- const [score, setScore] = useState(0);
70
- const [finished, setFinished] = useState(false);
71
- const [inputValue, setInputValue] = useState("");
72
- const [answered, setAnswered] = useState(false);
73
- const [animating, setAnimating] = useState(false);
74
- const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(null);
75
- const [resultsTable, setResultsTable] = useState<RoundResult[]>([]);
76
- const [usedHidden, setUsedHidden] = useState<string[]>([]);
77
- const [isMobile, setIsMobile] = useState(false);
78
- const [containerSize, setContainerSize] = useState<number | null>(null);
79
- const [pageScale, setPageScale] = useState<number>(1);
80
-
81
- // ✅ адаптив под мобилки, планшеты и десктоп
82
- useEffect(() => {
83
- const resize = () => {
84
- const mobile = window.innerWidth < 768;
85
- setIsMobile(mobile);
86
- if (mobile) {
87
- // mobile: fluid full-width layout
88
- setContainerSize(null);
89
- return;
90
- }
91
- // tablets and desktops: fit into a centered square (max 1000)
92
- const safeFactor = 0.97;
93
- const maxSquare = 1000;
94
- const available = Math.min(window.innerWidth, window.innerHeight);
95
- const sizePx = Math.max(360, Math.round(available * safeFactor)); // don't go too small
96
- const finalSize = Math.min(sizePx, maxSquare);
97
- setContainerSize(finalSize);
98
- };
44
+ export function useNeutralizeBodyZoom(
45
+ ref: React.RefObject<HTMLDivElement | null>,
46
+ opts: { minDesktopWidth?: number; forceResize?: boolean } = { minDesktopWidth: 1200, forceResize: false }
47
+ ) {
48
+ const saved = useRef<Record<string, string | null> | null>(null);
49
+ // извлекаем значение, чтобы использовать в deps
50
+ const minDesktopWidth = opts.minDesktopWidth ?? 1200;
51
+ const forceResize = !!opts.forceResize;
99
52
 
100
- const detectBodyScale = () => {
53
+ useLayoutEffect(() => {
54
+ if (typeof window === "undefined" || !ref || !ref.current) return;
55
+ const el = ref.current;
56
+
57
+ const getBodyScale = (): number => {
101
58
  try {
102
- const el = document.body;
103
- const cs = getComputedStyle(el);
104
- const zoomVal = parseFloat((cs.zoom as string) || "");
105
- if (zoomVal && !isNaN(zoomVal) && zoomVal > 0) {
106
- setPageScale(zoomVal);
107
- return;
108
- }
109
- const tf = cs.transform;
59
+ const body = document.body;
60
+ const cs = getComputedStyle(body) as CSSStyleDeclaration & {
61
+ zoom?: string;
62
+ webkitTransform?: string;
63
+ mozTransform?: string;
64
+ };
65
+
66
+ const zoomInline = parseFloat((body.style && body.style.zoom) || "");
67
+ if (zoomInline && !isNaN(zoomInline) && zoomInline > 0) return zoomInline;
68
+
69
+ const zoomComputed = parseFloat((cs.zoom as string) || "");
70
+ if (zoomComputed && !isNaN(zoomComputed) && zoomComputed > 0) return zoomComputed;
71
+
72
+ const tf = cs.transform || cs.webkitTransform || cs.mozTransform;
110
73
  if (tf && tf !== "none") {
111
74
  const m = tf.match(/matrix\(([^)]+)\)/);
112
75
  if (m) {
113
76
  const parts = m[1].split(",").map((p) => parseFloat(p));
114
77
  const a = parts[0];
115
- if (a && !isNaN(a)) {
116
- setPageScale(a);
117
- return;
118
- }
78
+ if (a && !isNaN(a)) return a;
119
79
  }
120
80
  }
121
81
  } catch (e) {
122
82
  // ignore
123
83
  }
124
- setPageScale(1);
84
+ return 1;
85
+ };
86
+
87
+ const apply = () => {
88
+ // only on desktop view
89
+ if (minDesktopWidth && window.innerWidth < minDesktopWidth) {
90
+ restore();
91
+ return;
92
+ }
93
+
94
+ const scale = getBodyScale();
95
+ if (!scale || scale === 1) {
96
+ restore();
97
+ return;
98
+ }
99
+
100
+ if (!saved.current) {
101
+ saved.current = {
102
+ transform: el.style.transform || null,
103
+ transformOrigin: el.style.transformOrigin || null,
104
+ width: el.style.width || null,
105
+ height: el.style.height || null,
106
+ willChange: el.style.willChange || null,
107
+ };
108
+ }
109
+
110
+ const inv = 1 / scale;
111
+
112
+ // safer default: не трогаем width/height, только визуально инвертируем внутри.
113
+ // если нужен "flow" компенсации, включать forceResize: true
114
+ if (forceResize) {
115
+ el.style.width = `${scale * 100}%`;
116
+ el.style.height = `${scale * 100}%`;
117
+ }
118
+ el.style.transform = `scale(${inv})`;
119
+ el.style.transformOrigin = "top left";
120
+ el.style.willChange = "transform";
121
+
122
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "development") {
123
+ // eslint-disable-next-line no-console
124
+ console.debug("[useNeutralizeBodyZoom] applied scale:", { scale, inv, forceResize });
125
+ }
125
126
  };
126
127
 
127
- resize();
128
- detectBodyScale();
129
- window.addEventListener("resize", resize);
130
- window.addEventListener("resize", detectBodyScale);
131
- const mo = new MutationObserver(detectBodyScale);
128
+ const restore = () => {
129
+ if (!saved.current) return;
130
+ const s = saved.current;
131
+ if (s.transform === null) el.style.removeProperty("transform");
132
+ else el.style.transform = s.transform as string;
133
+ if (s.transformOrigin === null) el.style.removeProperty("transform-origin");
134
+ else el.style.transformOrigin = s.transformOrigin as string;
135
+ if (s.width === null) el.style.removeProperty("width");
136
+ else el.style.width = s.width as string;
137
+ if (s.height === null) el.style.removeProperty("height");
138
+ else el.style.height = s.height as string;
139
+ if (s.willChange === null) el.style.removeProperty("will-change");
140
+ else el.style.willChange = s.willChange as string;
141
+ saved.current = null;
142
+ if (process.env.NODE_ENV === "development") {
143
+ // eslint-disable-next-line no-console
144
+ console.debug("[useNeutralizeBodyZoom] restored original styles");
145
+ }
146
+ };
147
+
148
+ let raf = 0;
149
+ const scheduled = () => {
150
+ cancelAnimationFrame(raf);
151
+ raf = requestAnimationFrame(apply);
152
+ };
153
+
154
+ const mo = new MutationObserver((mutations) => {
155
+ for (const m of mutations) {
156
+ if (m.type === "attributes" && (m.attributeName === "style" || m.attributeName === "class")) {
157
+ scheduled();
158
+ break;
159
+ }
160
+ }
161
+ });
132
162
  mo.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
133
163
 
164
+ window.addEventListener("resize", scheduled);
165
+ // initial
166
+ scheduled();
167
+
134
168
  return () => {
135
- window.removeEventListener("resize", resize);
136
- window.removeEventListener("resize", detectBodyScale);
169
+ cancelAnimationFrame(raf);
137
170
  mo.disconnect();
171
+ window.removeEventListener("resize", scheduled);
172
+ restore();
138
173
  };
139
- }, []);
140
-
141
- const getRandomSix = (arr: ImageItem[]) => [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
142
-
143
- const startGame = () => {
144
- if (!theme) return;
145
- const selected = getRandomSix(themes[theme]);
146
- setFiles(selected);
147
- setStarted(true);
148
- setFinished(false);
149
- setCurrentRound(1);
150
- setScore(0);
151
- setResultsTable([]);
152
- setUsedHidden([]);
153
- startRound(selected, [], true);
154
- };
155
-
156
- const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
157
- const roundSet = getRandomSix(images.length ? images : themes[theme!]);
158
- let idx = Math.floor(Math.random() * roundSet.length);
159
- let candidate = roundSet[idx].name;
160
- let attempts = 0;
161
- while (used.includes(candidate) && attempts < 20) {
162
- idx = Math.floor(Math.random() * roundSet.length);
163
- candidate = roundSet[idx].name;
164
- attempts++;
165
- }
166
- setFiles(roundSet);
167
- setHiddenIndex(idx);
168
- setUsedHidden([...used, candidate]);
169
- setAnswered(false);
170
- setInputValue("");
171
- setTimeLeft(20);
172
- setResult(null);
173
- if (isFirst) {
174
- setPhase("ready");
175
- setReadyTime(3);
176
- setMemorizeTime(10);
177
- } else {
178
- setPhase("memorize");
179
- setMemorizeTime(10);
180
- }
181
- };
182
-
183
- useEffect(() => {
184
- if (!started || finished || answered) return;
185
- if (phase === "ready") {
186
- if (readyTime <= 0) setPhase("memorize");
187
- else {
188
- const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
189
- return () => clearTimeout(t);
190
- }
191
- }
192
- if (phase === "memorize") {
193
- if (memorizeTime <= 0) setPhase("guess");
194
- else {
195
- const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
196
- return () => clearTimeout(t);
197
- }
198
- }
199
- if (phase === "guess") {
200
- if (timeLeft <= 0) {
201
- setAnswered(true);
202
- setResult("wrong");
203
- const correct = files[hiddenIndex!].name;
204
- setResultsTable((prev) => [...prev, { round: currentRound, answer: inputValue, correct, result: "wrong" }]);
205
- return;
206
- }
207
- const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
208
- return () => clearTimeout(t);
209
- }
210
- }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
211
-
212
- const checkAnswer = () => {
213
- if (hiddenIndex === null || animating) return;
214
- const correct = files[hiddenIndex].name;
215
- const userAnswer = inputValue.toLowerCase().trim();
216
- let roundResult: "correct" | "almost" | "wrong" = "wrong";
217
- if (userAnswer === correct) {
218
- setScore((s) => s + 1);
219
- setResult("correct");
220
- roundResult = "correct";
221
- } else if (levenshtein(userAnswer, correct) === 1) {
222
- setScore((s) => s + 0.5);
223
- setResult("almost");
224
- roundResult = "almost";
225
- } else {
226
- setResult("wrong");
227
- roundResult = "wrong";
228
- }
229
- setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
230
- setAnimating(true);
231
- setTimeout(() => {
232
- setAnimating(false);
233
- setAnswered(true);
234
- }, 600);
235
- };
236
-
237
- const nextRound = () => (currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true));
238
-
239
- const exitGame = () => {
240
- setStarted(false);
241
- setFinished(false);
242
- setTheme(null);
243
- };
244
-
245
- const MemoizedLogo = useMemo(
246
- () => (
247
- // ensure logo is positioned inside the square container
248
- <div style={{ ...styles.gmLogoFixed, position: "absolute", top: 16, left: 16, zIndex: 30 }}>
249
- <picture>
250
- <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
251
- <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
252
- </picture>
253
- </div>
254
- ),
255
- []
256
- );
257
-
258
- return (
259
- <div
260
- ref={containerRef}
261
- style={{
262
- width: "100%",
263
- height: "100vh",
264
- display: "flex",
265
- justifyContent: "center",
266
- alignItems: "center",
267
- background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
268
- transition: "background 0.3s ease",
269
- overflowX: "clip",
270
- }}
271
- >
272
- <div
273
- style={{
274
- width: isMobile ? "100%" : containerSize ? `${Math.round(containerSize * pageScale)}px` : `${Math.round(1000 * pageScale)}px`,
275
- height: isMobile ? "100%" : containerSize ? `${Math.round(containerSize * pageScale)}px` : `${Math.round(1000 * pageScale)}px`,
276
- display: "flex",
277
- justifyContent: "center",
278
- alignItems: "center",
279
- overflow: "hidden",
280
- borderRadius: isMobile ? 0 : "20px",
281
- background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
282
- boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.1)",
283
- margin: isMobile ? "0 auto" : "unset",
284
- position: "relative",
285
- }}
286
- >
287
- <div
288
- style={{
289
- transform: pageScale && pageScale !== 1 ? `scale(${1 / pageScale})` : "none",
290
- transformOrigin: "top left",
291
- width: "100%",
292
- height: "100%",
293
- display: "flex",
294
- justifyContent: "center",
295
- alignItems: "center",
296
- }}
297
- >
298
- <div id="whats-missing-root">
299
- {!isMobile && MemoizedLogo}
300
-
301
- {/* ====== ИГРОВАЯ ЛОГИКА ====== */}
302
- {!theme && !started && (
303
- <div style={styles.gmCenterScreen}>
304
- <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
305
- <p style={styles.gmBodyM}>Select a theme:</p>
306
- <div style={{ display: "flex", gap: 16 }}>
307
- {["animals", "food", "toys"].map((t) => (
308
- <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
309
- {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
310
- </button>
311
- ))}
312
- </div>
313
- <div style={{ marginTop: 24 }}>
314
- <p style={styles.gmBodyS}>Choose number of rounds:</p>
315
- <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
316
- {[3, 4, 5].map((n) => (
317
- <button
318
- key={n}
319
- style={{ ...styles.gmButton, ...(rounds === n ? styles.gmButtonActive : {}) }}
320
- onClick={() => setRounds(n)}
321
- >
322
- {n}
323
- </button>
324
- ))}
325
- </div>
326
- </div>
327
- </div>
328
- )}
329
-
330
- {theme && !started && (
331
- <div style={styles.gmCenterScreen}>
332
- <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
333
- <p style={styles.gmBodyM}>Rounds: {rounds}</p>
334
- <button style={styles.gmButton} onClick={startGame}>▶ Start game</button>
335
- </div>
336
- )}
337
-
338
- {finished && (
339
- <div style={styles.gmCenterScreen}>
340
- <h1 style={styles.gmHeadline1}>Results</h1>
341
- <h2 style={styles.gmHeadline3}>Your score: {score} / {rounds}</h2>
342
- <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
343
- <table style={styles.gmTable}>
344
- <thead>
345
- <tr><th>Round</th><th>Your Answer</th><th>Correct</th><th>Result</th></tr>
346
- </thead>
347
- <tbody>
348
- {resultsTable.map((r, i) => (
349
- <tr key={i}>
350
- <td style={styles.gmTableCell}>{r.round}</td>
351
- <td style={styles.gmTableCell}>{r.answer || "—"}</td>
352
- <td style={styles.gmTableCell}>{r.correct}</td>
353
- <td style={styles.gmTableCell}>
354
- {r.result === "correct" ? "✔ Correct" : r.result === "almost" ? "◐ Almost (0.5)" : "✘ Wrong"}
355
- </td>
356
- </tr>
357
- ))}
358
- </tbody>
359
- </table>
360
- <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
361
- <button style={styles.gmButton} onClick={startGame}>🔁 Play again</button>
362
- <button style={styles.gmButton} onClick={exitGame}>⬅️ Choose theme</button>
363
- </div>
364
- </div>
365
- )}
366
-
367
- {started && !finished && (
368
- <div style={styles.gmGameLayout}>
369
- <div style={{ minHeight: 160, display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center" }}>
370
- {phase === "ready" && (<><h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1><div style={styles.gmHourglass}>⏳</div></>)}
371
- {phase === "memorize" && (<p style={{ ...styles.gmBodyM, color: "#10b981" }}>MEMORIZE ({memorizeTime})</p>)}
372
- {phase === "guess" && !answered && (<p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>)}
373
- </div>
374
-
375
- {phase !== "ready" && (
376
- <div style={{ ...styles.gmGrid, gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(3, 210px)", gridAutoRows: isMobile ? "150px" : "210px", gap: isMobile ? "12px" : "20px", justifyItems: "center" }}>
377
- {files.map((file, i) => {
378
- const isHidden = hiddenIndex === i && phase === "guess" && !answered;
379
- return (
380
- <div key={i} style={{ ...styles.gmCard, ...(result === "correct" && hiddenIndex === i ? styles.gmCorrect : {}), ...(result === "wrong" && hiddenIndex === i ? styles.gmWrong : {}) }}>
381
- {!isHidden && <img src={file.src} alt={file.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} />}
382
- </div>
383
- );
384
- })}
385
- </div>
386
- )}
387
-
388
- <div style={{ marginTop: 16, height: 80 }}>
389
- {phase === "guess" && !answered && (
390
- <div>
391
- <input type="text" placeholder="Type the missing word" value={inputValue} onChange={(e) => setInputValue(e.target.value)} style={styles.gmInput} />
392
- <button style={{ ...styles.gmButton, marginLeft: 8 }} onClick={checkAnswer} disabled={animating}>{animating ? "..." : "Check"}</button>
393
- </div>
394
- )}
395
- {answered && <button style={styles.gmButton} onClick={nextRound}>Next round</button>}
396
- </div>
397
- </div>
398
- )}
399
- </div>
400
- </div>
401
- </div>
402
- </div>
403
- );
404
- }
174
+ }, [ref, minDesktopWidth, forceResize]);
175
+ }
176
+
177
+
178
+ export default function Game() {
179
+ const containerRef = useRef<HTMLDivElement | null>(null);
180
+ useNeutralizeBodyZoom(containerRef, { minDesktopWidth: 1200, forceResize: false });
181
+
182
+
183
+ useEffect(() => {
184
+ globalReset();
185
+ return () => {
186
+ document.body.style.overflow = "";
187
+ };
188
+ }, []);
189
+
190
+
191
+ const [theme, setTheme] = useState<Theme | null>(null);
192
+ const [files, setFiles] = useState<ImageItem[]>([]);
193
+ const [rounds, setRounds] = useState(4);
194
+ const [started, setStarted] = useState(false);
195
+ const [currentRound, setCurrentRound] = useState(1);
196
+ const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
197
+ const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
198
+ const [readyTime, setReadyTime] = useState(3);
199
+ const [memorizeTime, setMemorizeTime] = useState(10);
200
+ const [timeLeft, setTimeLeft] = useState(20);
201
+ const [score, setScore] = useState(0);
202
+ const [finished, setFinished] = useState(false);
203
+ const [inputValue, setInputValue] = useState("");
204
+ const [answered, setAnswered] = useState(false);
205
+ const [animating, setAnimating] = useState(false);
206
+ const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(null);
207
+ const [resultsTable, setResultsTable] = useState<RoundResult[]>([]);
208
+ const [usedHidden, setUsedHidden] = useState<string[]>([]);
209
+ const [isMobile, setIsMobile] = useState(false);
210
+ const [scale, setScale] = useState(1);
211
+ const [containerSize, setContainerSize] = useState<number | null>(null);
212
+
213
+
214
+ // ✅ адаптив под мобилки, планшеты и десктоп
215
+ useEffect(() => {
216
+ const resize = () => {
217
+ const mobile = window.innerWidth < 768;
218
+ const tablet = window.innerWidth >= 768 && window.innerWidth < 1200;
219
+
220
+
221
+ // treat only phones as "mobile" so tablets use the desktop (square/centered) layout
222
+ setIsMobile(mobile);
223
+
224
+
225
+ if (mobile) {
226
+ // mobile: fluid full-width layout
227
+ setContainerSize(null);
228
+ setScale(1);
229
+ } else {
230
+ // tablets and desktops: fit into a centered square (max 1000)
231
+ const safeFactor = 0.97;
232
+ const maxSquare = 1000;
233
+ const available = Math.min(window.innerWidth, window.innerHeight);
234
+ const sizePx = Math.max(360, Math.round(available * safeFactor)); // don't go too small
235
+ const finalSize = Math.min(sizePx, maxSquare);
236
+ setContainerSize(finalSize);
237
+ setScale(1);
238
+ }
239
+ };
240
+ resize();
241
+ window.addEventListener("resize", resize);
242
+ return () => window.removeEventListener("resize", resize);
243
+ }, []);
244
+
245
+
246
+ const getRandomSix = (arr: ImageItem[]) =>
247
+ [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
248
+
249
+
250
+ const startGame = () => {
251
+ if (!theme) return;
252
+ const selected = getRandomSix(themes[theme]);
253
+ setFiles(selected);
254
+ setStarted(true);
255
+ setFinished(false);
256
+ setCurrentRound(1);
257
+ setScore(0);
258
+ setResultsTable([]);
259
+ setUsedHidden([]);
260
+ startRound(selected, [], true);
261
+ };
262
+
263
+
264
+ const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
265
+ const roundSet = getRandomSix(images.length ? images : themes[theme!]);
266
+ let idx = Math.floor(Math.random() * roundSet.length);
267
+ let candidate = roundSet[idx].name;
268
+ let attempts = 0;
269
+ while (used.includes(candidate) && attempts < 20) {
270
+ idx = Math.floor(Math.random() * roundSet.length);
271
+ candidate = roundSet[idx].name;
272
+ attempts++;
273
+ }
274
+ setFiles(roundSet);
275
+ setHiddenIndex(idx);
276
+ setUsedHidden([...used, candidate]);
277
+ setAnswered(false);
278
+ setInputValue("");
279
+ setTimeLeft(20);
280
+ setResult(null);
281
+ if (isFirst) {
282
+ setPhase("ready");
283
+ setReadyTime(3);
284
+ setMemorizeTime(10);
285
+ } else {
286
+ setPhase("memorize");
287
+ setMemorizeTime(10);
288
+ }
289
+ };
290
+
291
+
292
+ useEffect(() => {
293
+ if (!started || finished || answered) return;
294
+ if (phase === "ready") {
295
+ if (readyTime <= 0) setPhase("memorize");
296
+ else {
297
+ const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
298
+ return () => clearTimeout(t);
299
+ }
300
+ }
301
+ if (phase === "memorize") {
302
+ if (memorizeTime <= 0) setPhase("guess");
303
+ else {
304
+ const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
305
+ return () => clearTimeout(t);
306
+ }
307
+ }
308
+ if (phase === "guess") {
309
+ if (timeLeft <= 0) {
310
+ setAnswered(true);
311
+ setResult("wrong");
312
+ const correct = files[hiddenIndex!].name;
313
+ setResultsTable((prev) => [
314
+ ...prev,
315
+ { round: currentRound, answer: inputValue, correct, result: "wrong" },
316
+ ]);
317
+ return;
318
+ }
319
+ const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
320
+ return () => clearTimeout(t);
321
+ }
322
+ }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
323
+
324
+
325
+ const checkAnswer = () => {
326
+ if (hiddenIndex === null || animating) return;
327
+ const correct = files[hiddenIndex].name;
328
+ const userAnswer = inputValue.toLowerCase().trim();
329
+ let roundResult: "correct" | "almost" | "wrong" = "wrong";
330
+ if (userAnswer === correct) {
331
+ setScore((s) => s + 1);
332
+ setResult("correct");
333
+ roundResult = "correct";
334
+ } else if (levenshtein(userAnswer, correct) === 1) {
335
+ setScore((s) => s + 0.5);
336
+ setResult("almost");
337
+ roundResult = "almost";
338
+ } else {
339
+ setResult("wrong");
340
+ roundResult = "wrong";
341
+ }
342
+ setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
343
+ setAnimating(true);
344
+ setTimeout(() => {
345
+ setAnimating(false);
346
+ setAnswered(true);
347
+ }, 600);
348
+ };
349
+
350
+
351
+ const nextRound = () =>
352
+ currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
353
+
354
+
355
+ const exitGame = () => {
356
+ setStarted(false);
357
+ setFinished(false);
358
+ setTheme(null);
359
+ };
360
+
361
+
362
+ const MemoizedLogo = useMemo(
363
+ () => (
364
+ // ensure logo is positioned inside the square container
365
+ <div style={{ ...styles.gmLogoFixed, position: "absolute", top: 16, left: 16, zIndex: 30 }}>
366
+ <picture>
367
+ <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
368
+ <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
369
+ </picture>
370
+ </div>
371
+ ),
372
+ []
373
+ );
374
+
375
+
376
+ return (
377
+ <div
378
+ ref={containerRef}
379
+ style={{
380
+ width: "100%",
381
+ height: "100vh",
382
+ display: "flex",
383
+ justifyContent: "center",
384
+ alignItems: "center",
385
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
386
+ transition: "background 0.3s ease",
387
+ overflowX: "clip"
388
+ }}
389
+ >
390
+ <div
391
+ style={{
392
+ width: isMobile ? "100%" : containerSize || 1000,
393
+ height: isMobile ? "100%" : containerSize || 1000,
394
+ display: "flex",
395
+ justifyContent: "center",
396
+ alignItems: "center",
397
+ overflow: "hidden",
398
+ borderRadius: isMobile ? 0 : "20px",
399
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
400
+ boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.1)",
401
+ margin: isMobile ? "0 auto" : "unset",
402
+ position: "relative", // needed so absolute logo is inside the square
403
+ }}
404
+ >
405
+ <div
406
+ style={{
407
+ transform: "none",
408
+ width: "100%",
409
+ height: "100%",
410
+ display: "flex",
411
+ justifyContent: "center",
412
+ alignItems: "center",
413
+ }}
414
+ >
415
+ <div id="whats-missing-root">
416
+ {!isMobile && MemoizedLogo}
417
+ {/* ====== ИГРОВАЯ ЛОГИКА ====== */}
418
+ {!theme && !started && (
419
+ <div style={styles.gmCenterScreen}>
420
+ <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
421
+ <p style={styles.gmBodyM}>Select a theme:</p>
422
+ <div style={{ display: "flex", gap: 16 }}>
423
+ {["animals", "food", "toys"].map((t) => (
424
+ <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
425
+ {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
426
+ </button>
427
+ ))}
428
+ </div>
429
+ <div style={{ marginTop: 24 }}>
430
+ <p style={styles.gmBodyS}>Choose number of rounds:</p>
431
+ <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
432
+ {[3, 4, 5].map((n) => (
433
+ <button
434
+ key={n}
435
+ style={{
436
+ ...styles.gmButton,
437
+ ...(rounds === n ? styles.gmButtonActive : {}),
438
+ }}
439
+ onClick={() => setRounds(n)}
440
+ >
441
+ {n}
442
+ </button>
443
+ ))}
444
+ </div>
445
+ </div>
446
+ </div>
447
+ )}
448
+ {theme && !started && (
449
+ <div style={styles.gmCenterScreen}>
450
+ <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
451
+ <p style={styles.gmBodyM}>Rounds: {rounds}</p>
452
+ <button style={styles.gmButton} onClick={startGame}>
453
+ ▶ Start game
454
+ </button>
455
+ </div>
456
+ )}
457
+ {finished && (
458
+ <div style={styles.gmCenterScreen}>
459
+ <h1 style={styles.gmHeadline1}>Results</h1>
460
+ <h2 style={styles.gmHeadline3}>
461
+ Your score: {score} / {rounds}
462
+ </h2>
463
+ <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
464
+ <table style={styles.gmTable}>
465
+ <thead>
466
+ <tr>
467
+ <th>Round</th>
468
+ <th>Your Answer</th>
469
+ <th>Correct</th>
470
+ <th>Result</th>
471
+ </tr>
472
+ </thead>
473
+ <tbody>
474
+ {resultsTable.map((r, i) => (
475
+ <tr key={i}>
476
+ <td style={styles.gmTableCell}>{r.round}</td>
477
+ <td style={styles.gmTableCell}>{r.answer || "—"}</td>
478
+ <td style={styles.gmTableCell}>{r.correct}</td>
479
+ <td style={styles.gmTableCell}>
480
+ {r.result === "correct"
481
+ ? "✔ Correct"
482
+ : r.result === "almost"
483
+ ? "◐ Almost (0.5)"
484
+ : "✘ Wrong"}
485
+ </td>
486
+ </tr>
487
+ ))}
488
+ </tbody>
489
+ </table>
490
+ <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
491
+ <button style={styles.gmButton} onClick={startGame}>
492
+ 🔁 Play again
493
+ </button>
494
+ <button style={styles.gmButton} onClick={exitGame}>
495
+ ⬅️ Choose theme
496
+ </button>
497
+ </div>
498
+ </div>
499
+ )}
500
+ {started && !finished && (
501
+ <div style={styles.gmGameLayout}>
502
+ <div
503
+ style={{
504
+ minHeight: 160,
505
+ display: "flex",
506
+ flexDirection: "column",
507
+ justifyContent: "center",
508
+ alignItems: "center",
509
+ }}
510
+ >
511
+ {phase === "ready" && (
512
+ <>
513
+ <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
514
+ <div style={styles.gmHourglass}>⏳</div>
515
+ </>
516
+ )}
517
+ {phase === "memorize" && (
518
+ <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
519
+ MEMORIZE ({memorizeTime})
520
+ </p>
521
+ )}
522
+ {phase === "guess" && !answered && (
523
+ <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
524
+ )}
525
+ </div>
526
+ {phase !== "ready" && (
527
+ <div
528
+ style={{
529
+ ...styles.gmGrid,
530
+ gridTemplateColumns: isMobile
531
+ ? "repeat(2, 1fr)"
532
+ : "repeat(3, 210px)",
533
+ gridAutoRows: isMobile ? "150px" : "210px",
534
+ gap: isMobile ? "12px" : "20px",
535
+ justifyItems: "center",
536
+ }}
537
+ >
538
+ {files.map((file, i) => {
539
+ const isHidden =
540
+ hiddenIndex === i && phase === "guess" && !answered;
541
+ return (
542
+ <div
543
+ key={i}
544
+ style={{
545
+ ...styles.gmCard,
546
+ ...(result === "correct" && hiddenIndex === i
547
+ ? styles.gmCorrect
548
+ : {}),
549
+ ...(result === "wrong" && hiddenIndex === i
550
+ ? styles.gmWrong
551
+ : {}),
552
+ }}
553
+ >
554
+ {!isHidden && (
555
+ <img
556
+ src={file.src}
557
+ alt={file.name}
558
+ style={{
559
+ width: "100%",
560
+ height: "100%",
561
+ objectFit: "cover",
562
+ }}
563
+ />
564
+ )}
565
+ </div>
566
+ );
567
+ })}
568
+ </div>
569
+ )}
570
+ <div style={{ marginTop: 16, height: 80 }}>
571
+ {phase === "guess" && !answered && (
572
+ <div>
573
+ <input
574
+ type="text"
575
+ placeholder="Type the missing word"
576
+ value={inputValue}
577
+ onChange={(e) => setInputValue(e.target.value)}
578
+ style={styles.gmInput}
579
+ />
580
+ <button
581
+ style={{ ...styles.gmButton, marginLeft: 8 }}
582
+ onClick={checkAnswer}
583
+ disabled={animating}
584
+ >
585
+ {animating ? "..." : "Check"}
586
+ </button>
587
+ </div>
588
+ )}
589
+ {answered && (
590
+ <button type="button" style={styles.gmButton} onClick={nextRound}>
591
+ Next round
592
+ </button>
593
+ )}
594
+ </div>
595
+ </div>
596
+ )}
597
+ </div>
598
+ </div>
599
+ </div>
600
+ </div>
601
+ );
602
+ }