@mblinkov/whats-missing 20.0.24 → 20.0.25

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
@@ -3,44 +3,41 @@ import { themes } from "./themes";
3
3
  import { styles } from "./Game.styles";
4
4
  import type { Theme } from "./themes";
5
5
 
6
-
7
6
  type ImageItem = { src: string; name: string };
8
7
  type RoundResult = {
9
- round: number;
10
- answer: string;
11
- correct: string;
12
- result: "correct" | "almost" | "wrong";
8
+ round: number;
9
+ answer: string;
10
+ correct: string;
11
+ result: "correct" | "almost" | "wrong";
13
12
  };
14
13
 
15
-
16
14
  // ✅ базовый reset (ограничен корнем игры)
17
15
  const globalReset = () => {
18
16
  const style = document.createElement("style");
19
17
  style.textContent = `
20
- /* scope only inside game root */
21
18
  #whats-missing-root, #whats-missing-root * { box-sizing: border-box; }
22
19
  #whats-missing-root img { max-width:100%; height:auto; display:block; user-select:none; }
23
20
  `;
24
21
  document.head.appendChild(style);
25
22
  };
26
23
 
27
-
24
+ // ✅ расстояние Левенштейна — для проверки “почти правильно”
28
25
  const levenshtein = (a: string, b: string) => {
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];
26
+ const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
27
+ for (let i = 0; i <= a.length; i++) dp[i][0] = i;
28
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
29
+ for (let i = 1; i <= a.length; i++) {
30
+ for (let j = 1; j <= b.length; j++) {
31
+ dp[i][j] =
32
+ a[i - 1] === b[j - 1]
33
+ ? dp[i - 1][j - 1]
34
+ : Math.min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]) + 1;
35
+ }
36
+ }
37
+ return dp[a.length][b.length];
41
38
  };
42
39
 
43
-
40
+ // ✅ фикс визуального подзума (например, на 1366×768)
44
41
  export function useNeutralizeBodyZoom(
45
42
  ref: React.RefObject<HTMLDivElement | null>,
46
43
  opts: { minDesktopWidth?: number; forceResize?: boolean } = { minDesktopWidth: 1200, forceResize: false }
@@ -57,15 +54,11 @@ export function useNeutralizeBodyZoom(
57
54
  try {
58
55
  const body = document.body;
59
56
  const cs = getComputedStyle(body) as CSSStyleDeclaration & { zoom?: string };
60
-
61
- // 1) existing inline/computed zoom if present
62
57
  const zoomInline = parseFloat((body.style && body.style.zoom) || "");
63
58
  if (zoomInline && !isNaN(zoomInline) && zoomInline > 0) return zoomInline;
64
59
  const zoomComputed = parseFloat((cs.zoom as string) || "");
65
60
  if (zoomComputed && !isNaN(zoomComputed) && zoomComputed > 0) return zoomComputed;
66
61
 
67
- // 2) robust probe: insert a 100px element and measure its bounding rect.
68
- // This detects any effective visual scale applied to the page (transform on ancestors, UA scaling, etc).
69
62
  const probe = document.createElement("div");
70
63
  probe.style.position = "absolute";
71
64
  probe.style.left = "0";
@@ -81,25 +74,20 @@ export function useNeutralizeBodyZoom(
81
74
  const measured = rect.width / 100;
82
75
  if (!isNaN(measured) && measured > 0) return measured;
83
76
  }
84
- } catch (e) {
85
- // ignore
86
- }
77
+ } catch {}
87
78
  return 1;
88
79
  };
89
80
 
90
81
  const apply = () => {
91
- // only on desktop view
92
82
  if (minDesktopWidth && window.innerWidth < minDesktopWidth) {
93
83
  restore();
94
84
  return;
95
85
  }
96
-
97
86
  const scale = getBodyScale();
98
87
  if (!scale || scale === 1) {
99
88
  restore();
100
89
  return;
101
90
  }
102
-
103
91
  if (!saved.current) {
104
92
  saved.current = {
105
93
  transform: el.style.transform || null,
@@ -109,44 +97,25 @@ export function useNeutralizeBodyZoom(
109
97
  willChange: el.style.willChange || null,
110
98
  };
111
99
  }
112
-
113
100
  const inv = 1 / scale;
114
-
115
- // safer default: только визуальная компенсация (не трогаем layout), опция forceResize включит width/height
116
101
  if (forceResize) {
117
102
  el.style.width = `${scale * 100}%`;
118
103
  el.style.height = `${scale * 100}%`;
119
104
  }
120
-
121
- // масштабируем относительно центра, чтобы не сдвигать центрированный контент
122
105
  el.style.transformOrigin = "50% 50%";
123
106
  el.style.transform = `scale(${inv})`;
124
107
  el.style.willChange = "transform";
125
-
126
- if (typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
127
- // eslint-disable-next-line no-console
128
- console.debug("[useNeutralizeBodyZoom] applied scale:", { scale, inv, forceResize });
129
- }
130
108
  };
131
109
 
132
110
  const restore = () => {
133
111
  if (!saved.current) return;
134
112
  const s = saved.current;
135
- if (s.transform === null) el.style.removeProperty("transform");
136
- else el.style.transform = s.transform as string;
137
- if (s.transformOrigin === null) el.style.removeProperty("transform-origin");
138
- else el.style.transformOrigin = s.transformOrigin as string;
139
- if (s.width === null) el.style.removeProperty("width");
140
- else el.style.width = s.width as string;
141
- if (s.height === null) el.style.removeProperty("height");
142
- else el.style.height = s.height as string;
143
- if (s.willChange === null) el.style.removeProperty("will-change");
144
- else el.style.willChange = s.willChange as string;
113
+ el.style.transform = s.transform ?? "";
114
+ el.style.transformOrigin = s.transformOrigin ?? "";
115
+ el.style.width = s.width ?? "";
116
+ el.style.height = s.height ?? "";
117
+ el.style.willChange = s.willChange ?? "";
145
118
  saved.current = null;
146
- if (typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
147
- // eslint-disable-next-line no-console
148
- console.debug("[useNeutralizeBodyZoom] restored original styles");
149
- }
150
119
  };
151
120
 
152
121
  let raf = 0;
@@ -154,19 +123,15 @@ export function useNeutralizeBodyZoom(
154
123
  cancelAnimationFrame(raf);
155
124
  raf = requestAnimationFrame(apply);
156
125
  };
157
-
158
- const mo = new MutationObserver((mutations) => {
159
- for (const m of mutations) {
160
- if (m.type === "attributes" && (m.attributeName === "style" || m.attributeName === "class")) {
126
+ const mo = new MutationObserver((m) => {
127
+ for (const x of m)
128
+ if (x.type === "attributes" && (x.attributeName === "style" || x.attributeName === "class")) {
161
129
  scheduled();
162
130
  break;
163
131
  }
164
- }
165
132
  });
166
133
  mo.observe(document.body, { attributes: true, attributeFilter: ["style", "class"] });
167
-
168
134
  window.addEventListener("resize", scheduled);
169
- // initial
170
135
  scheduled();
171
136
 
172
137
  return () => {
@@ -178,45 +143,51 @@ export function useNeutralizeBodyZoom(
178
143
  }, [ref, minDesktopWidth, forceResize]);
179
144
  }
180
145
 
181
-
182
- export default function Game() {
146
+ // =====================================
147
+ // ИГРА
148
+ // =====================================
149
+
150
+ export default function Game({
151
+ gameCubeSize,
152
+ screenHeight,
153
+ screenWidth,
154
+ }: {
155
+ gameCubeSize?: number;
156
+ screenHeight?: number;
157
+ screenWidth?: number;
158
+ }) {
183
159
  const containerRef = useRef<HTMLDivElement | null>(null);
184
- // ref на видимый квадрат (тот, что width/height выставлены в px или %)
185
160
  const squareRef = useRef<HTMLDivElement | null>(null);
186
161
  const [squareSize, setSquareSize] = useState<number | null>(null);
187
- useNeutralizeBodyZoom(containerRef, { minDesktopWidth: 1200, forceResize: false });
188
-
189
-
190
- useEffect(() => {
191
- globalReset();
192
- return () => {
193
- document.body.style.overflow = "";
194
- };
195
- }, []);
196
-
197
-
198
- const [theme, setTheme] = useState<Theme | null>(null);
199
- const [files, setFiles] = useState<ImageItem[]>([]);
200
- const [rounds, setRounds] = useState(4);
201
- const [started, setStarted] = useState(false);
202
- const [currentRound, setCurrentRound] = useState(1);
203
- const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
204
- const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
205
- const [readyTime, setReadyTime] = useState(3);
206
- const [memorizeTime, setMemorizeTime] = useState(10);
207
- const [timeLeft, setTimeLeft] = useState(20);
208
- const [score, setScore] = useState(0);
209
- const [finished, setFinished] = useState(false);
210
- const [inputValue, setInputValue] = useState("");
211
- const [answered, setAnswered] = useState(false);
212
- const [animating, setAnimating] = useState(false);
213
- const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(null);
214
- const [resultsTable, setResultsTable] = useState<RoundResult[]>([]);
215
- const [usedHidden, setUsedHidden] = useState<string[]>([]);
216
- const [isMobile, setIsMobile] = useState(false);
217
- const [containerSize, setContainerSize] = useState<number | null>(null);
218
-
219
- // наблюдаем за реальным render-size квадрата — это даёт надежную базу для расчета карточек
162
+ useNeutralizeBodyZoom(containerRef, { minDesktopWidth: 1200, forceResize: false });
163
+
164
+ useEffect(() => {
165
+ globalReset();
166
+ return () => {
167
+ document.body.style.overflow = "";
168
+ };
169
+ }, []);
170
+
171
+ const [theme, setTheme] = useState<Theme | null>(null);
172
+ const [files, setFiles] = useState<ImageItem[]>([]);
173
+ const [rounds, setRounds] = useState(4);
174
+ const [started, setStarted] = useState(false);
175
+ const [currentRound, setCurrentRound] = useState(1);
176
+ const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
177
+ const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
178
+ const [readyTime, setReadyTime] = useState(3);
179
+ const [memorizeTime, setMemorizeTime] = useState(10);
180
+ const [timeLeft, setTimeLeft] = useState(20);
181
+ const [score, setScore] = useState(0);
182
+ const [finished, setFinished] = useState(false);
183
+ const [inputValue, setInputValue] = useState("");
184
+ const [answered, setAnswered] = useState(false);
185
+ const [animating, setAnimating] = useState(false);
186
+ const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(null);
187
+ const [resultsTable, setResultsTable] = useState<RoundResult[]>([]);
188
+ const [usedHidden, setUsedHidden] = useState<string[]>([]);
189
+ const [isMobile, setIsMobile] = useState(false);
190
+
220
191
  useLayoutEffect(() => {
221
192
  const el = squareRef.current;
222
193
  if (!el || typeof window === "undefined") return;
@@ -229,442 +200,376 @@ export default function Game() {
229
200
  }
230
201
  });
231
202
  ro.observe(el);
232
- // init
233
203
  setSquareSize(Math.round(Math.min(el.clientWidth, el.clientHeight)));
234
- } catch (err) {
235
- // ResizeObserver may not be available — fallback to initial measurement
204
+ } catch {
236
205
  setSquareSize(Math.round(Math.min(el.clientWidth, el.clientHeight)));
237
206
  }
238
- return () => {
239
- if (ro) ro.disconnect();
240
- };
207
+ return () => ro?.disconnect();
241
208
  }, [isMobile]);
242
209
 
243
-
244
- // ✅ адаптив под мобилки, планшеты и десктоп
245
- useEffect(() => {
246
- const resize = () => {
247
- const vv = (window as any).visualViewport;
248
- const vw = vv ? Math.round(vv.width) : window.innerWidth;
249
- const vh = vv ? Math.round(vv.height) : window.innerHeight;
250
-
251
- // treat narrow screens as mobile.
252
- // Also treat portrait tablets (iPad portrait) as mobile:
253
- // - use a slightly larger mobile breakpoint (820px),
254
- // - or if device is touch-capable and height > width (portrait).
255
- const MOBILE_BREAKPOINT = 820;
256
- const isTouchDevice = typeof navigator !== "undefined" && (navigator.maxTouchPoints > 0 || "ontouchstart" in window);
257
- const mobile = vw <= MOBILE_BREAKPOINT || (isTouchDevice && vh > vw);
258
-
259
- setIsMobile(mobile);
260
-
261
- if (mobile) {
262
- setContainerSize(null);
263
- return;
210
+ const getRandomSix = (arr: ImageItem[]) => [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
211
+
212
+ const startGame = () => {
213
+ if (!theme) return;
214
+ const selected = getRandomSix(themes[theme]);
215
+ setFiles(selected);
216
+ setStarted(true);
217
+ setFinished(false);
218
+ setCurrentRound(1);
219
+ setScore(0);
220
+ setResultsTable([]);
221
+ setUsedHidden([]);
222
+ startRound(selected, [], true);
223
+ };
224
+
225
+ const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
226
+ const roundSet = getRandomSix(images.length ? images : themes[theme!]);
227
+ let idx = Math.floor(Math.random() * roundSet.length);
228
+ let candidate = roundSet[idx].name;
229
+ let attempts = 0;
230
+ while (used.includes(candidate) && attempts < 20) {
231
+ idx = Math.floor(Math.random() * roundSet.length);
232
+ candidate = roundSet[idx].name;
233
+ attempts++;
234
+ }
235
+ setFiles(roundSet);
236
+ setHiddenIndex(idx);
237
+ setUsedHidden([...used, candidate]);
238
+ setAnswered(false);
239
+ setInputValue("");
240
+ setTimeLeft(20);
241
+ setResult(null);
242
+ if (isFirst) {
243
+ setPhase("ready");
244
+ setReadyTime(3);
245
+ setMemorizeTime(10);
246
+ } else {
247
+ setPhase("memorize");
248
+ setMemorizeTime(10);
249
+ }
250
+ };
251
+
252
+ useEffect(() => {
253
+ if (!started || finished || answered) return;
254
+ if (phase === "ready") {
255
+ if (readyTime <= 0) setPhase("memorize");
256
+ else {
257
+ const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
258
+ return () => clearTimeout(t);
264
259
  }
265
-
266
- // tablets & desktop: conservative square
267
- const safeFactor = 0.92; // leave margins for browser UI
268
- const maxSquare = 1000;
269
- const gutter = 40;
270
- const availableW = Math.max(360, vw - gutter);
271
- const availableH = Math.max(360, vh - gutter);
272
- const available = Math.min(availableW, availableH);
273
- const sizePx = Math.max(360, Math.round(available * safeFactor));
274
- const finalSize = Math.min(sizePx, maxSquare);
275
- setContainerSize(finalSize);
276
- };
277
-
278
- // run once
279
- resize();
280
-
281
- // listen to both window and visualViewport (if available) since some browsers update only visualViewport on zoom
282
- window.addEventListener("resize", resize);
283
- const vv = (window as any).visualViewport;
284
- if (vv && typeof vv.addEventListener === "function") {
285
- vv.addEventListener("resize", resize);
286
- vv.addEventListener("scroll", resize);
287
260
  }
288
-
289
- return () => {
290
- window.removeEventListener("resize", resize);
291
- if (vv && typeof vv.removeEventListener === "function") {
292
- vv.removeEventListener("resize", resize);
293
- vv.removeEventListener("scroll", resize);
261
+ if (phase === "memorize") {
262
+ if (memorizeTime <= 0) setPhase("guess");
263
+ else {
264
+ const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
265
+ return () => clearTimeout(t);
294
266
  }
295
- };
296
- }, []);
297
-
298
-
299
- const getRandomSix = (arr: ImageItem[]) =>
300
- [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
301
-
302
-
303
- const startGame = () => {
304
- if (!theme) return;
305
- const selected = getRandomSix(themes[theme]);
306
- setFiles(selected);
307
- setStarted(true);
308
- setFinished(false);
309
- setCurrentRound(1);
310
- setScore(0);
311
- setResultsTable([]);
312
- setUsedHidden([]);
313
- startRound(selected, [], true);
314
- };
315
-
316
-
317
- const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
318
- const roundSet = getRandomSix(images.length ? images : themes[theme!]);
319
- let idx = Math.floor(Math.random() * roundSet.length);
320
- let candidate = roundSet[idx].name;
321
- let attempts = 0;
322
- while (used.includes(candidate) && attempts < 20) {
323
- idx = Math.floor(Math.random() * roundSet.length);
324
- candidate = roundSet[idx].name;
325
- attempts++;
326
- }
327
- setFiles(roundSet);
328
- setHiddenIndex(idx);
329
- setUsedHidden([...used, candidate]);
330
- setAnswered(false);
331
- setInputValue("");
332
- setTimeLeft(20);
333
- setResult(null);
334
- if (isFirst) {
335
- setPhase("ready");
336
- setReadyTime(3);
337
- setMemorizeTime(10);
338
- } else {
339
- setPhase("memorize");
340
- setMemorizeTime(10);
341
- }
342
- };
343
-
344
-
345
- useEffect(() => {
346
- if (!started || finished || answered) return;
347
- if (phase === "ready") {
348
- if (readyTime <= 0) setPhase("memorize");
349
- else {
350
- const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
351
- return () => clearTimeout(t);
352
- }
353
- }
354
- if (phase === "memorize") {
355
- if (memorizeTime <= 0) setPhase("guess");
356
- else {
357
- const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
358
- return () => clearTimeout(t);
359
- }
360
- }
361
- if (phase === "guess") {
362
- if (timeLeft <= 0) {
363
- setAnswered(true);
364
- setResult("wrong");
365
- const correct = files[hiddenIndex!].name;
366
- setResultsTable((prev) => [
367
- ...prev,
368
- { round: currentRound, answer: inputValue, correct, result: "wrong" },
369
- ]);
370
- return;
371
- }
372
- const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
373
- return () => clearTimeout(t);
374
- }
375
- }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
376
-
377
-
378
- const checkAnswer = () => {
379
- if (hiddenIndex === null || animating) return;
380
- const correct = files[hiddenIndex].name;
381
- const userAnswer = inputValue.toLowerCase().trim();
382
- let roundResult: "correct" | "almost" | "wrong" = "wrong";
383
- if (userAnswer === correct) {
384
- setScore((s) => s + 1);
385
- setResult("correct");
386
- roundResult = "correct";
387
- } else if (levenshtein(userAnswer, correct) === 1) {
388
- setScore((s) => s + 0.5);
389
- setResult("almost");
390
- roundResult = "almost";
391
- } else {
392
- setResult("wrong");
393
- roundResult = "wrong";
394
- }
395
- setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
396
- setAnimating(true);
397
- setTimeout(() => {
398
- setAnimating(false);
399
- setAnswered(true);
400
- }, 600);
401
- };
402
-
403
-
404
- const nextRound = () =>
405
- currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
406
-
407
-
408
- const exitGame = () => {
409
- setStarted(false);
410
- setFinished(false);
411
- setTheme(null);
412
- };
413
-
414
-
415
- const MemoizedLogo = useMemo(
416
- () => (
417
- // ensure logo is positioned inside the square container
418
- <div style={{ ...styles.gmLogoFixed, position: "absolute", top: 16, left: 16, zIndex: 30 }}>
419
- <picture>
420
- <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
421
- <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
422
- </picture>
423
- </div>
424
- ),
425
- []
426
- );
427
-
428
-
429
- return (
430
- <div
431
- ref={containerRef}
432
- style={{
433
- // center relative to viewport so site layout doesn't push the game off-center
434
- position: "absolute",
435
- inset: 0,
436
- display: "flex",
437
- justifyContent: "center",
438
- alignItems: "center",
439
- background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
440
- transition: "background 0.3s ease",
441
- overflow: "hidden",
442
- zIndex: 1,
443
- pointerEvents: "auto",
444
- }}
445
- >
446
- <div
447
- // visible square that should fit on tablets
448
- ref={squareRef}
449
- style={{
450
- width: isMobile ? "100%" : containerSize ? `${containerSize}px` : "1000px",
451
- height: isMobile ? "100%" : containerSize ? `${containerSize}px` : "1000px",
452
- maxWidth: "calc(100vw - 24px)",
453
- maxHeight: "calc(100vh - 20px)",
454
- display: "flex",
455
- justifyContent: "center",
456
- alignItems: "center",
457
- overflow: "hidden",
458
- borderRadius: isMobile ? 0 : "20px",
459
- background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
460
- boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.08)",
461
- position: "relative",
462
- }}
463
- >
464
- <div
465
- style={{
466
- transform: "none",
467
- transformOrigin: "50% 50%",
468
- width: "100%",
469
- height: "100%",
470
- display: "flex",
471
- justifyContent: "center",
472
- alignItems: "center",
473
- }}
474
- >
475
- <div id="whats-missing-root">
476
- {!isMobile && MemoizedLogo}
477
- {/* ====== ИГРОВАЯ ЛОГИКА ====== */}
478
- {!theme && !started && (
479
- <div style={styles.gmCenterScreen}>
480
- <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
481
- <p style={styles.gmBodyM}>Select a theme:</p>
482
- <div style={{ display: "flex", gap: 16 }}>
483
- {["animals", "food", "toys"].map((t) => (
484
- <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
485
- {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
486
- </button>
487
- ))}
488
- </div>
489
- <div style={{ marginTop: 24 }}>
490
- <p style={styles.gmBodyS}>Choose number of rounds:</p>
491
- <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
492
- {[3, 4, 5].map((n) => (
493
- <button
494
- key={n}
495
- style={{
496
- ...styles.gmButton,
497
- ...(rounds === n ? styles.gmButtonActive : {}),
498
- }}
499
- onClick={() => setRounds(n)}
500
- >
501
- {n}
502
- </button>
503
- ))}
504
- </div>
505
- </div>
506
- </div>
507
- )}
508
- {theme && !started && (
509
- <div style={styles.gmCenterScreen}>
510
- <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
511
- <p style={styles.gmBodyM}>Rounds: {rounds}</p>
512
- <button style={styles.gmButton} onClick={startGame}>
513
- Start game
514
- </button>
515
- </div>
516
- )}
517
- {finished && (
518
- <div style={styles.gmCenterScreen}>
519
- <h1 style={styles.gmHeadline1}>Results</h1>
520
- <h2 style={styles.gmHeadline3}>
521
- Your score: {score} / {rounds}
522
- </h2>
523
- <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
524
- <table style={styles.gmTable}>
525
- <thead>
526
- <tr>
527
- <th>Round</th>
528
- <th>Your Answer</th>
529
- <th>Correct</th>
530
- <th>Result</th>
531
- </tr>
532
- </thead>
533
- <tbody>
534
- {resultsTable.map((r, i) => (
535
- <tr key={i}>
536
- <td style={styles.gmTableCell}>{r.round}</td>
537
- <td style={styles.gmTableCell}>{r.answer || "—"}</td>
538
- <td style={styles.gmTableCell}>{r.correct}</td>
539
- <td style={styles.gmTableCell}>
540
- {r.result === "correct"
541
- ? "✔ Correct"
542
- : r.result === "almost"
543
- ? "◐ Almost (0.5)"
544
- : "✘ Wrong"}
545
- </td>
546
- </tr>
547
- ))}
548
- </tbody>
549
- </table>
550
- <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
551
- <button style={styles.gmButton} onClick={startGame}>
552
- 🔁 Play again
553
- </button>
554
- <button style={styles.gmButton} onClick={exitGame}>
555
- ⬅️ Choose theme
556
- </button>
557
- </div>
558
- </div>
559
- )}
560
- {started && !finished && (
561
- <div style={styles.gmGameLayout}>
562
- <div
563
- style={{
564
- minHeight: 160,
565
- display: "flex",
566
- flexDirection: "column",
567
- justifyContent: "center",
568
- alignItems: "center",
569
- }}
570
- >
571
- {phase === "ready" && (
572
- <>
573
- <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
574
- <div style={styles.gmHourglass}>⏳</div>
575
- </>
576
- )}
577
- {phase === "memorize" && (
578
- <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
579
- MEMORIZE ({memorizeTime})
580
- </p>
581
- )}
582
- {phase === "guess" && !answered && (
583
- <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
584
- )}
585
- </div>
586
- {phase !== "ready" && (() => {
587
- // compute card size from real measured square size (fallback to containerSize)
588
- const gap = isMobile ? 12 : 20;
589
- const innerGutters = 32; // logo / paddings estimation
590
- const effective = squareSize ?? containerSize ?? (isMobile ? 360 : 1000);
591
- const columns = isMobile ? 2 : 3;
592
- const cardSize = Math.max(
593
- 90,
594
- Math.floor((effective - innerGutters - gap * (columns - 1)) / columns)
595
- );
596
-
597
- return (
598
- <div
599
- style={{
600
- ...styles.gmGrid,
601
- gridTemplateColumns: `repeat(${columns}, ${cardSize}px)`,
602
- gridAutoRows: `${cardSize}px`,
603
- gap: `${gap}px`,
604
- justifyContent: "center",
605
- justifyItems: "center",
606
- padding: 8,
607
- boxSizing: "border-box",
608
- }}
609
- >
610
- {files.map((file, i) => {
611
- const isHidden = hiddenIndex === i && phase === "guess" && !answered;
612
- return (
613
- <div
614
- key={i}
615
- style={{
616
- ...styles.gmCard,
617
- width: `${cardSize}px`,
618
- height: `${cardSize}px`,
619
- padding: isMobile ? 8 : 10,
620
- boxSizing: "border-box",
621
- ...(result === "correct" && hiddenIndex === i ? styles.gmCorrect : {}),
622
- ...(result === "wrong" && hiddenIndex === i ? styles.gmWrong : {}),
623
- }}
624
- >
625
- {!isHidden && (
626
- <img
627
- src={file.src}
628
- alt={file.name}
629
- style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
630
- />
631
- )}
632
- </div>
633
- );
634
- })}
635
- </div>
636
- );
637
- })()}
638
- <div style={{ marginTop: 16, height: 80 }}>
639
- {phase === "guess" && !answered && (
640
- <div>
641
- <input
642
- type="text"
643
- placeholder="Type the missing word"
644
- value={inputValue}
645
- onChange={(e) => setInputValue(e.target.value)}
646
- style={styles.gmInput}
647
- />
648
- <button
649
- style={{ ...styles.gmButton, marginLeft: 8 }}
650
- onClick={checkAnswer}
651
- disabled={animating}
652
- >
653
- {animating ? "..." : "Check"}
654
- </button>
655
- </div>
656
- )}
657
- {answered && (
658
- <button type="button" style={styles.gmButton} onClick={nextRound}>
659
- Next round
660
- </button>
661
- )}
662
- </div>
663
- </div>
664
- )}
665
- </div>
666
- </div>
667
- </div>
668
- </div>
669
- );
267
+ }
268
+ if (phase === "guess") {
269
+ if (timeLeft <= 0) {
270
+ setAnswered(true);
271
+ setResult("wrong");
272
+ const correct = files[hiddenIndex!].name;
273
+ setResultsTable((prev) => [...prev, { round: currentRound, answer: inputValue, correct, result: "wrong" }]);
274
+ return;
275
+ }
276
+ const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
277
+ return () => clearTimeout(t);
278
+ }
279
+ }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
280
+
281
+ const checkAnswer = () => {
282
+ if (hiddenIndex === null || animating) return;
283
+ const correct = files[hiddenIndex].name;
284
+ const userAnswer = inputValue.toLowerCase().trim();
285
+ let roundResult: "correct" | "almost" | "wrong" = "wrong";
286
+ if (userAnswer === correct) {
287
+ setScore((s) => s + 1);
288
+ setResult("correct");
289
+ roundResult = "correct";
290
+ } else if (levenshtein(userAnswer, correct) === 1) {
291
+ setScore((s) => s + 0.5);
292
+ setResult("almost");
293
+ roundResult = "almost";
294
+ } else {
295
+ setResult("wrong");
296
+ roundResult = "wrong";
297
+ }
298
+ setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
299
+ setAnimating(true);
300
+ setTimeout(() => {
301
+ setAnimating(false);
302
+ setAnswered(true);
303
+ }, 600);
304
+ };
305
+
306
+ const nextRound = () => (currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true));
307
+
308
+ const exitGame = () => {
309
+ setStarted(false);
310
+ setFinished(false);
311
+ setTheme(null);
312
+ };
313
+
314
+ const MemoizedLogo = useMemo(
315
+ () => (
316
+ <div style={{ ...styles.gmLogoFixed, position: "absolute", top: 16, left: 16, zIndex: 30 }}>
317
+ <picture>
318
+ <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
319
+ <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
320
+ </picture>
321
+ </div>
322
+ ),
323
+ []
324
+ );
325
+
326
+ return (
327
+ <div
328
+ ref={containerRef}
329
+ style={{
330
+ position: "fixed",
331
+ inset: 0,
332
+ display: "flex",
333
+ justifyContent: "center",
334
+ alignItems: "center",
335
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
336
+ transition: "background 0.3s ease",
337
+ overflow: "hidden",
338
+ zIndex: 9999,
339
+ pointerEvents: "auto",
340
+ }}
341
+ >
342
+ <div
343
+ ref={squareRef}
344
+ style={{
345
+ width: gameCubeSize ? `${gameCubeSize}px` : isMobile ? "100%" : "1000px",
346
+ height: gameCubeSize ? `${gameCubeSize}px` : isMobile ? "100%" : "1000px",
347
+ maxWidth: "calc(100vw - 24px)",
348
+ maxHeight: "calc(100vh - 20px)",
349
+ display: "flex",
350
+ justifyContent: "center",
351
+ alignItems: "center",
352
+ overflow: "hidden",
353
+ borderRadius: isMobile ? 0 : "20px",
354
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
355
+ boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.08)",
356
+ position: "relative",
357
+ }}
358
+ >
359
+ <div
360
+ style={{
361
+ transform: "none",
362
+ transformOrigin: "50% 50%",
363
+ width: "100%",
364
+ height: "100%",
365
+ display: "flex",
366
+ justifyContent: "center",
367
+ alignItems: "center",
368
+ }}
369
+ >
370
+ <div id="whats-missing-root">
371
+ {!isMobile && MemoizedLogo}
372
+
373
+ {/* ====== ЛОББИ ====== */}
374
+ {!theme && !started && (
375
+ <div style={styles.gmCenterScreen}>
376
+ <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
377
+ <p style={styles.gmBodyM}>Select a theme:</p>
378
+ <div style={{ display: "flex", gap: 16 }}>
379
+ {["animals", "food", "toys"].map((t) => (
380
+ <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
381
+ {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
382
+ </button>
383
+ ))}
384
+ </div>
385
+ <div style={{ marginTop: 24 }}>
386
+ <p style={styles.gmBodyS}>Choose number of rounds:</p>
387
+ <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
388
+ {[3, 4, 5].map((n) => (
389
+ <button
390
+ key={n}
391
+ style={{
392
+ ...styles.gmButton,
393
+ ...(rounds === n ? styles.gmButtonActive : {}),
394
+ }}
395
+ onClick={() => setRounds(n)}
396
+ >
397
+ {n}
398
+ </button>
399
+ ))}
400
+ </div>
401
+ </div>
402
+ </div>
403
+ )}
404
+
405
+ {/* ====== ПОДТВЕРЖДЕНИЕ ====== */}
406
+ {theme && !started && (
407
+ <div style={styles.gmCenterScreen}>
408
+ <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
409
+ <p style={styles.gmBodyM}>Rounds: {rounds}</p>
410
+ <button style={styles.gmButton} onClick={startGame}>
411
+ Start game
412
+ </button>
413
+ </div>
414
+ )}
415
+
416
+ {/* ====== РЕЗУЛЬТАТЫ ====== */}
417
+ {finished && (
418
+ <div style={styles.gmCenterScreen}>
419
+ <h1 style={styles.gmHeadline1}>Results</h1>
420
+ <h2 style={styles.gmHeadline3}>
421
+ Your score: {score} / {rounds}
422
+ </h2>
423
+ <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
424
+ <table style={styles.gmTable}>
425
+ <thead>
426
+ <tr>
427
+ <th>Round</th>
428
+ <th>Your Answer</th>
429
+ <th>Correct</th>
430
+ <th>Result</th>
431
+ </tr>
432
+ </thead>
433
+ <tbody>
434
+ {resultsTable.map((r, i) => (
435
+ <tr key={i}>
436
+ <td style={styles.gmTableCell}>{r.round}</td>
437
+ <td style={styles.gmTableCell}>{r.answer || "—"}</td>
438
+ <td style={styles.gmTableCell}>{r.correct}</td>
439
+ <td style={styles.gmTableCell}>
440
+ {r.result === "correct"
441
+ ? "✔ Correct"
442
+ : r.result === "almost"
443
+ ? "◐ Almost (0.5)"
444
+ : "✘ Wrong"}
445
+ </td>
446
+ </tr>
447
+ ))}
448
+ </tbody>
449
+ </table>
450
+ <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
451
+ <button style={styles.gmButton} onClick={startGame}>
452
+ 🔁 Play again
453
+ </button>
454
+ <button style={styles.gmButton} onClick={exitGame}>
455
+ ⬅️ Choose theme
456
+ </button>
457
+ </div>
458
+ </div>
459
+ )}
460
+
461
+ {/* ====== ИГРОВОЙ ЭКРАН ====== */}
462
+ {started && !finished && (
463
+ <div style={styles.gmGameLayout}>
464
+ <div
465
+ style={{
466
+ minHeight: 160,
467
+ display: "flex",
468
+ flexDirection: "column",
469
+ justifyContent: "center",
470
+ alignItems: "center",
471
+ }}
472
+ >
473
+ {phase === "ready" && (
474
+ <>
475
+ <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
476
+ <div style={styles.gmHourglass}>⏳</div>
477
+ </>
478
+ )}
479
+ {phase === "memorize" && (
480
+ <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
481
+ MEMORIZE ({memorizeTime})
482
+ </p>
483
+ )}
484
+ {phase === "guess" && !answered && (
485
+ <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
486
+ )}
487
+ </div>
488
+
489
+ {/* Сетка карточек */}
490
+ {phase !== "ready" && (() => {
491
+ const gap = isMobile ? 12 : 20;
492
+ const innerGutters = 32;
493
+ const effective = gameCubeSize ?? squareSize ?? (isMobile ? 360 : 1000);
494
+ const columns = isMobile ? 2 : 3;
495
+ const cardSize = Math.max(
496
+ 90,
497
+ Math.floor((effective - innerGutters - gap * (columns - 1)) / columns)
498
+ );
499
+
500
+ return (
501
+ <div
502
+ style={{
503
+ ...styles.gmGrid,
504
+ gridTemplateColumns: `repeat(${columns}, ${cardSize}px)`,
505
+ gridAutoRows: `${cardSize}px`,
506
+ gap: `${gap}px`,
507
+ justifyContent: "center",
508
+ justifyItems: "center",
509
+ padding: 8,
510
+ boxSizing: "border-box",
511
+ }}
512
+ >
513
+ {files.map((file, i) => {
514
+ const isHidden = hiddenIndex === i && phase === "guess" && !answered;
515
+ return (
516
+ <div
517
+ key={i}
518
+ style={{
519
+ ...styles.gmCard,
520
+ width: `${cardSize}px`,
521
+ height: `${cardSize}px`,
522
+ padding: isMobile ? 8 : 10,
523
+ boxSizing: "border-box",
524
+ ...(result === "correct" && hiddenIndex === i ? styles.gmCorrect : {}),
525
+ ...(result === "wrong" && hiddenIndex === i ? styles.gmWrong : {}),
526
+ }}
527
+ >
528
+ {!isHidden && (
529
+ <img
530
+ src={file.src}
531
+ alt={file.name}
532
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
533
+ />
534
+ )}
535
+ </div>
536
+ );
537
+ })}
538
+ </div>
539
+ );
540
+ })()}
541
+
542
+ {/* Инпут и кнопки */}
543
+ <div style={{ marginTop: 16, height: 80 }}>
544
+ {phase === "guess" && !answered && (
545
+ <div>
546
+ <input
547
+ type="text"
548
+ placeholder="Type the missing word"
549
+ value={inputValue}
550
+ onChange={(e) => setInputValue(e.target.value)}
551
+ style={styles.gmInput}
552
+ />
553
+ <button
554
+ style={{ ...styles.gmButton, marginLeft: 8 }}
555
+ onClick={checkAnswer}
556
+ disabled={animating}
557
+ >
558
+ {animating ? "..." : "Check"}
559
+ </button>
560
+ </div>
561
+ )}
562
+ {answered && (
563
+ <button type="button" style={styles.gmButton} onClick={nextRound}>
564
+ Next round
565
+ </button>
566
+ )}
567
+ </div>
568
+ </div>
569
+ )}
570
+ </div>
571
+ </div>
572
+ </div>
573
+ </div>
574
+ );
670
575
  }