@mblinkov/whats-missing 20.0.18 → 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,463 +1,602 @@
1
- // ...existing code...
2
- import { useState, useEffect, useMemo, useRef } from "react";
1
+ import { useState, useEffect, useMemo, useRef, useLayoutEffect } from "react";
3
2
  import { themes } from "./themes";
4
3
  import { styles } from "./Game.styles";
5
4
  import type { Theme } from "./themes";
6
5
 
6
+
7
7
  type ImageItem = { src: string; name: string };
8
8
  type RoundResult = {
9
- round: number;
10
- answer: string;
11
- correct: string;
12
- result: "correct" | "almost" | "wrong";
9
+ round: number;
10
+ answer: string;
11
+ correct: string;
12
+ result: "correct" | "almost" | "wrong";
13
13
  };
14
14
 
15
+
15
16
  // ✅ базовый reset
16
17
  const globalReset = () => {
17
18
  const style = document.createElement("style");
18
19
  style.textContent = `
19
- #whats-missing-root, #whats-missing-root * {
20
- box-sizing: border-box;
21
- font-family: "Geist", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
22
- }
23
- #whats-missing-root img {
24
- max-width: 100%;
25
- height: auto;
26
- display: block;
27
- user-select: none;
28
- }
29
- html, body {
30
- margin: 0;
31
- padding: 0;
32
- zoom: 1 !important; /* ✅ защита от подзума */
33
- }
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; }
34
23
  `;
35
24
  document.head.appendChild(style);
36
25
  };
37
26
 
38
- // простая функция сравнения
27
+
39
28
  const levenshtein = (a: string, b: string) => {
40
- const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
41
- for (let i = 0; i <= a.length; i++) dp[i][0] = i;
42
- for (let j = 0; j <= b.length; j++) dp[0][j] = j;
43
- for (let i = 1; i <= a.length; i++) {
44
- for (let j = 1; j <= b.length; j++) {
45
- dp[i][j] =
46
- a[i - 1] === b[j - 1]
47
- ? dp[i - 1][j - 1]
48
- : Math.min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]) + 1;
49
- }
50
- }
51
- 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];
52
41
  };
53
42
 
54
- export default function Game() {
55
- const containerRef = useRef<HTMLDivElement>(null);
56
43
 
57
- useEffect(() => {
58
- globalReset();
59
- return () => {
60
- document.body.style.overflow = "";
61
- };
62
- }, []);
63
-
64
- const [theme, setTheme] = useState<Theme | null>(null);
65
- const [files, setFiles] = useState<ImageItem[]>([]);
66
- const [rounds, setRounds] = useState(4);
67
- const [started, setStarted] = useState(false);
68
- const [currentRound, setCurrentRound] = useState(1);
69
- const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
70
- const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
71
- const [readyTime, setReadyTime] = useState(3);
72
- const [memorizeTime, setMemorizeTime] = useState(10);
73
- const [timeLeft, setTimeLeft] = useState(20);
74
- const [score, setScore] = useState(0);
75
- const [finished, setFinished] = useState(false);
76
- const [inputValue, setInputValue] = useState("");
77
- const [answered, setAnswered] = useState(false);
78
- const [animating, setAnimating] = useState(false);
79
- const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(null);
80
- const [resultsTable, setResultsTable] = useState<RoundResult[]>([]);
81
- const [usedHidden, setUsedHidden] = useState<string[]>([]);
82
- const [isMobile, setIsMobile] = useState(false);
83
- const [scale, setScale] = useState(1);
84
- const [containerSize, setContainerSize] = useState<number | null>(null);
85
-
86
- // адаптив под мобилки, планшеты и десктоп
87
- useEffect(() => {
88
- const resize = () => {
89
- const mobile = window.innerWidth < 768;
90
- const tablet = window.innerWidth >= 768 && window.innerWidth < 1200;
91
-
92
- // treat only phones as "mobile" so tablets use the desktop (square/centered) layout
93
- setIsMobile(mobile);
94
-
95
- if (mobile) {
96
- // mobile: fluid full-width layout
97
- setContainerSize(null);
98
- setScale(1);
99
- } else {
100
- // tablets and desktops: fit into a centered square (max 1000)
101
- const safeFactor = 0.97;
102
- const maxSquare = 1000;
103
- const available = Math.min(window.innerWidth, window.innerHeight);
104
- const sizePx = Math.max(360, Math.round(available * safeFactor)); // don't go too small
105
- const finalSize = Math.min(sizePx, maxSquare);
106
- setContainerSize(finalSize);
107
- setScale(1);
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;
52
+
53
+ useLayoutEffect(() => {
54
+ if (typeof window === "undefined" || !ref || !ref.current) return;
55
+ const el = ref.current;
56
+
57
+ const getBodyScale = (): number => {
58
+ try {
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;
73
+ if (tf && tf !== "none") {
74
+ const m = tf.match(/matrix\(([^)]+)\)/);
75
+ if (m) {
76
+ const parts = m[1].split(",").map((p) => parseFloat(p));
77
+ const a = parts[0];
78
+ if (a && !isNaN(a)) return a;
79
+ }
80
+ }
81
+ } catch (e) {
82
+ // ignore
108
83
  }
84
+ return 1;
109
85
  };
110
- resize();
111
- window.addEventListener("resize", resize);
112
- return () => window.removeEventListener("resize", resize);
113
- }, []);
114
-
115
- const getRandomSix = (arr: ImageItem[]) =>
116
- [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
117
-
118
- const startGame = () => {
119
- if (!theme) return;
120
- const selected = getRandomSix(themes[theme]);
121
- setFiles(selected);
122
- setStarted(true);
123
- setFinished(false);
124
- setCurrentRound(1);
125
- setScore(0);
126
- setResultsTable([]);
127
- setUsedHidden([]);
128
- startRound(selected, [], true);
129
- };
130
-
131
- const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
132
- const roundSet = getRandomSix(images.length ? images : themes[theme!]);
133
- let idx = Math.floor(Math.random() * roundSet.length);
134
- let candidate = roundSet[idx].name;
135
- let attempts = 0;
136
- while (used.includes(candidate) && attempts < 20) {
137
- idx = Math.floor(Math.random() * roundSet.length);
138
- candidate = roundSet[idx].name;
139
- attempts++;
140
- }
141
- setFiles(roundSet);
142
- setHiddenIndex(idx);
143
- setUsedHidden([...used, candidate]);
144
- setAnswered(false);
145
- setInputValue("");
146
- setTimeLeft(20);
147
- setResult(null);
148
- if (isFirst) {
149
- setPhase("ready");
150
- setReadyTime(3);
151
- setMemorizeTime(10);
152
- } else {
153
- setPhase("memorize");
154
- setMemorizeTime(10);
155
- }
156
- };
157
-
158
- useEffect(() => {
159
- if (!started || finished || answered) return;
160
- if (phase === "ready") {
161
- if (readyTime <= 0) setPhase("memorize");
162
- else {
163
- const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
164
- return () => clearTimeout(t);
165
- }
166
- }
167
- if (phase === "memorize") {
168
- if (memorizeTime <= 0) setPhase("guess");
169
- else {
170
- const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
171
- return () => clearTimeout(t);
86
+
87
+ const apply = () => {
88
+ // only on desktop view
89
+ if (minDesktopWidth && window.innerWidth < minDesktopWidth) {
90
+ restore();
91
+ return;
172
92
  }
173
- }
174
- if (phase === "guess") {
175
- if (timeLeft <= 0) {
176
- setAnswered(true);
177
- setResult("wrong");
178
- const correct = files[hiddenIndex!].name;
179
- setResultsTable((prev) => [
180
- ...prev,
181
- { round: currentRound, answer: inputValue, correct, result: "wrong" },
182
- ]);
93
+
94
+ const scale = getBodyScale();
95
+ if (!scale || scale === 1) {
96
+ restore();
183
97
  return;
184
98
  }
185
- const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
186
- return () => clearTimeout(t);
187
- }
188
- }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
189
-
190
- const checkAnswer = () => {
191
- if (hiddenIndex === null || animating) return;
192
- const correct = files[hiddenIndex].name;
193
- const userAnswer = inputValue.toLowerCase().trim();
194
- let roundResult: "correct" | "almost" | "wrong" = "wrong";
195
- if (userAnswer === correct) {
196
- setScore((s) => s + 1);
197
- setResult("correct");
198
- roundResult = "correct";
199
- } else if (levenshtein(userAnswer, correct) === 1) {
200
- setScore((s) => s + 0.5);
201
- setResult("almost");
202
- roundResult = "almost";
203
- } else {
204
- setResult("wrong");
205
- roundResult = "wrong";
206
- }
207
- setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
208
- setAnimating(true);
209
- setTimeout(() => {
210
- setAnimating(false);
211
- setAnswered(true);
212
- }, 600);
213
- };
214
-
215
- const nextRound = () =>
216
- currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
217
-
218
- const exitGame = () => {
219
- setStarted(false);
220
- setFinished(false);
221
- setTheme(null);
222
- };
223
-
224
- const MemoizedLogo = useMemo(
225
- () => (
226
- // ensure logo is positioned inside the square container
227
- <div style={{ ...styles.gmLogoFixed, position: "absolute", top: 16, left: 16, zIndex: 30 }}>
228
- <picture>
229
- <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
230
- <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
231
- </picture>
232
- </div>
233
- ),
234
- []
235
- );
236
-
237
- return (
238
- <div
239
- ref={containerRef}
240
- style={{
241
- width: "100%",
242
- height: "100vh",
243
- display: "flex",
244
- justifyContent: "center",
245
- alignItems: "center",
246
- background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
247
- transition: "background 0.3s ease",
248
- overflowX: "clip"
249
- }}
250
- >
251
- <div
252
- style={{
253
- width: isMobile ? "100%" : containerSize || 1000,
254
- height: isMobile ? "100%" : containerSize || 1000,
255
- display: "flex",
256
- justifyContent: "center",
257
- alignItems: "center",
258
- overflow: "hidden",
259
- borderRadius: isMobile ? 0 : "20px",
260
- background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
261
- boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.1)",
262
- margin: isMobile ? "0 auto" : "unset",
263
- position: "relative", // needed so absolute logo is inside the square
264
- }}
265
- >
266
- <div
267
- style={{
268
- transform: "none",
269
- width: "100%",
270
- height: "100%",
271
- display: "flex",
272
- justifyContent: "center",
273
- alignItems: "center",
274
- }}
275
- >
276
- <div id="whats-missing-root">
277
- {!isMobile && MemoizedLogo}
278
- {/* ====== ИГРОВАЯ ЛОГИКА ====== */}
279
- {!theme && !started && (
280
- <div style={styles.gmCenterScreen}>
281
- <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
282
- <p style={styles.gmBodyM}>Select a theme:</p>
283
- <div style={{ display: "flex", gap: 16 }}>
284
- {["animals", "food", "toys"].map((t) => (
285
- <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
286
- {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
287
- </button>
288
- ))}
289
- </div>
290
- <div style={{ marginTop: 24 }}>
291
- <p style={styles.gmBodyS}>Choose number of rounds:</p>
292
- <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
293
- {[3, 4, 5].map((n) => (
294
- <button
295
- key={n}
296
- style={{
297
- ...styles.gmButton,
298
- ...(rounds === n ? styles.gmButtonActive : {}),
299
- }}
300
- onClick={() => setRounds(n)}
301
- >
302
- {n}
303
- </button>
304
- ))}
305
- </div>
306
- </div>
307
- </div>
308
- )}
309
- {theme && !started && (
310
- <div style={styles.gmCenterScreen}>
311
- <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
312
- <p style={styles.gmBodyM}>Rounds: {rounds}</p>
313
- <button style={styles.gmButton} onClick={startGame}>
314
- ▶ Start game
315
- </button>
316
- </div>
317
- )}
318
- {finished && (
319
- <div style={styles.gmCenterScreen}>
320
- <h1 style={styles.gmHeadline1}>Results</h1>
321
- <h2 style={styles.gmHeadline3}>
322
- Your score: {score} / {rounds}
323
- </h2>
324
- <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
325
- <table style={styles.gmTable}>
326
- <thead>
327
- <tr>
328
- <th>Round</th>
329
- <th>Your Answer</th>
330
- <th>Correct</th>
331
- <th>Result</th>
332
- </tr>
333
- </thead>
334
- <tbody>
335
- {resultsTable.map((r, i) => (
336
- <tr key={i}>
337
- <td style={styles.gmTableCell}>{r.round}</td>
338
- <td style={styles.gmTableCell}>{r.answer || "—"}</td>
339
- <td style={styles.gmTableCell}>{r.correct}</td>
340
- <td style={styles.gmTableCell}>
341
- {r.result === "correct"
342
- ? "✔ Correct"
343
- : r.result === "almost"
344
- ? "◐ Almost (0.5)"
345
- : "✘ Wrong"}
346
- </td>
347
- </tr>
348
- ))}
349
- </tbody>
350
- </table>
351
- <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
352
- <button style={styles.gmButton} onClick={startGame}>
353
- 🔁 Play again
354
- </button>
355
- <button style={styles.gmButton} onClick={exitGame}>
356
- ⬅️ Choose theme
357
- </button>
358
- </div>
359
- </div>
360
- )}
361
- {started && !finished && (
362
- <div style={styles.gmGameLayout}>
363
- <div
364
- style={{
365
- minHeight: 160,
366
- display: "flex",
367
- flexDirection: "column",
368
- justifyContent: "center",
369
- alignItems: "center",
370
- }}
371
- >
372
- {phase === "ready" && (
373
- <>
374
- <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
375
- <div style={styles.gmHourglass}>⏳</div>
376
- </>
377
- )}
378
- {phase === "memorize" && (
379
- <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
380
- MEMORIZE ({memorizeTime})
381
- </p>
382
- )}
383
- {phase === "guess" && !answered && (
384
- <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
385
- )}
386
- </div>
387
- {phase !== "ready" && (
388
- <div
389
- style={{
390
- ...styles.gmGrid,
391
- gridTemplateColumns: isMobile
392
- ? "repeat(2, 1fr)"
393
- : "repeat(3, 210px)",
394
- gridAutoRows: isMobile ? "150px" : "210px",
395
- gap: isMobile ? "12px" : "20px",
396
- justifyItems: "center",
397
- }}
398
- >
399
- {files.map((file, i) => {
400
- const isHidden =
401
- hiddenIndex === i && phase === "guess" && !answered;
402
- return (
403
- <div
404
- key={i}
405
- style={{
406
- ...styles.gmCard,
407
- ...(result === "correct" && hiddenIndex === i
408
- ? styles.gmCorrect
409
- : {}),
410
- ...(result === "wrong" && hiddenIndex === i
411
- ? styles.gmWrong
412
- : {}),
413
- }}
414
- >
415
- {!isHidden && (
416
- <img
417
- src={file.src}
418
- alt={file.name}
419
- style={{
420
- width: "100%",
421
- height: "100%",
422
- objectFit: "cover",
423
- }}
424
- />
425
- )}
426
- </div>
427
- );
428
- })}
429
- </div>
430
- )}
431
- <div style={{ marginTop: 16, height: 80 }}>
432
- {phase === "guess" && !answered && (
433
- <div>
434
- <input
435
- type="text"
436
- placeholder="Type the missing word"
437
- value={inputValue}
438
- onChange={(e) => setInputValue(e.target.value)}
439
- style={styles.gmInput}
440
- />
441
- <button
442
- style={{ ...styles.gmButton, marginLeft: 8 }}
443
- onClick={checkAnswer}
444
- disabled={animating}
445
- >
446
- {animating ? "..." : "Check"}
447
- </button>
448
- </div>
449
- )}
450
- {answered && (
451
- <button style={styles.gmButton} onClick={nextRound}>
452
- Next round
453
- </button>
454
- )}
455
- </div>
456
- </div>
457
- )}
458
- </div>
459
- </div>
460
- </div>
461
- </div>
462
- );
463
- }
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
+ }
126
+ };
127
+
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
+ });
162
+ mo.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
163
+
164
+ window.addEventListener("resize", scheduled);
165
+ // initial
166
+ scheduled();
167
+
168
+ return () => {
169
+ cancelAnimationFrame(raf);
170
+ mo.disconnect();
171
+ window.removeEventListener("resize", scheduled);
172
+ restore();
173
+ };
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
+ }