@mblinkov/whats-missing 20.0.19 → 20.0.21

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,622 @@
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
- }, []);
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;
58
52
 
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
- };
53
+ useLayoutEffect(() => {
54
+ if (typeof window === "undefined" || !ref || !ref.current) return;
55
+ const el = ref.current;
99
56
 
100
- const detectBodyScale = () => {
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;
125
85
  };
126
86
 
127
- resize();
128
- detectBodyScale();
129
- window.addEventListener("resize", resize);
130
- window.addEventListener("resize", detectBodyScale);
131
- const mo = new MutationObserver(detectBodyScale);
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
+ }
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
+ });
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
- }, []);
174
+ }, [ref, minDesktopWidth, forceResize]);
175
+ }
140
176
 
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);
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
+ // Prefer visualViewport when available — it's more accurate under page zoom
218
+ const vv = (window as any).visualViewport;
219
+ const vw = vv ? Math.round(vv.width) : window.innerWidth;
220
+ const vh = vv ? Math.round(vv.height) : window.innerHeight;
221
+
222
+ const mobile = vw < 768;
223
+ // tablet flag kept for possible future use
224
+ const tablet = vw >= 768 && vw < 1200;
225
+
226
+ // treat only phones as "mobile" so tablets use the desktop (square/centered) layout
227
+ setIsMobile(mobile);
228
+
229
+ if (mobile) {
230
+ // mobile: fluid full-width layout
231
+ setContainerSize(null);
232
+ setScale(1);
233
+ } else {
234
+ // tablets and desktops: fit into a centered square (max 1000)
235
+ const safeFactor = 0.97;
236
+ const maxSquare = 1000;
237
+ const available = Math.min(vw, vh);
238
+ const sizePx = Math.max(360, Math.round(available * safeFactor)); // don't go too small
239
+ const finalSize = Math.min(sizePx, maxSquare);
240
+ setContainerSize(finalSize);
241
+ setScale(1);
197
242
  }
243
+ };
244
+
245
+ // run once
246
+ resize();
247
+
248
+ // listen to both window and visualViewport (if available) since some browsers update only visualViewport on zoom
249
+ window.addEventListener("resize", resize);
250
+ const vv = (window as any).visualViewport;
251
+ if (vv && typeof vv.addEventListener === "function") {
252
+ vv.addEventListener("resize", resize);
253
+ vv.addEventListener("scroll", resize);
198
254
  }
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;
255
+
256
+ return () => {
257
+ window.removeEventListener("resize", resize);
258
+ if (vv && typeof vv.removeEventListener === "function") {
259
+ vv.removeEventListener("resize", resize);
260
+ vv.removeEventListener("scroll", resize);
206
261
  }
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
- }
262
+ };
263
+ }, []);
264
+
265
+
266
+ const getRandomSix = (arr: ImageItem[]) =>
267
+ [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
268
+
269
+
270
+ const startGame = () => {
271
+ if (!theme) return;
272
+ const selected = getRandomSix(themes[theme]);
273
+ setFiles(selected);
274
+ setStarted(true);
275
+ setFinished(false);
276
+ setCurrentRound(1);
277
+ setScore(0);
278
+ setResultsTable([]);
279
+ setUsedHidden([]);
280
+ startRound(selected, [], true);
281
+ };
282
+
283
+
284
+ const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
285
+ const roundSet = getRandomSix(images.length ? images : themes[theme!]);
286
+ let idx = Math.floor(Math.random() * roundSet.length);
287
+ let candidate = roundSet[idx].name;
288
+ let attempts = 0;
289
+ while (used.includes(candidate) && attempts < 20) {
290
+ idx = Math.floor(Math.random() * roundSet.length);
291
+ candidate = roundSet[idx].name;
292
+ attempts++;
293
+ }
294
+ setFiles(roundSet);
295
+ setHiddenIndex(idx);
296
+ setUsedHidden([...used, candidate]);
297
+ setAnswered(false);
298
+ setInputValue("");
299
+ setTimeLeft(20);
300
+ setResult(null);
301
+ if (isFirst) {
302
+ setPhase("ready");
303
+ setReadyTime(3);
304
+ setMemorizeTime(10);
305
+ } else {
306
+ setPhase("memorize");
307
+ setMemorizeTime(10);
308
+ }
309
+ };
310
+
311
+
312
+ useEffect(() => {
313
+ if (!started || finished || answered) return;
314
+ if (phase === "ready") {
315
+ if (readyTime <= 0) setPhase("memorize");
316
+ else {
317
+ const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
318
+ return () => clearTimeout(t);
319
+ }
320
+ }
321
+ if (phase === "memorize") {
322
+ if (memorizeTime <= 0) setPhase("guess");
323
+ else {
324
+ const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
325
+ return () => clearTimeout(t);
326
+ }
327
+ }
328
+ if (phase === "guess") {
329
+ if (timeLeft <= 0) {
330
+ setAnswered(true);
331
+ setResult("wrong");
332
+ const correct = files[hiddenIndex!].name;
333
+ setResultsTable((prev) => [
334
+ ...prev,
335
+ { round: currentRound, answer: inputValue, correct, result: "wrong" },
336
+ ]);
337
+ return;
338
+ }
339
+ const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
340
+ return () => clearTimeout(t);
341
+ }
342
+ }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
343
+
344
+
345
+ const checkAnswer = () => {
346
+ if (hiddenIndex === null || animating) return;
347
+ const correct = files[hiddenIndex].name;
348
+ const userAnswer = inputValue.toLowerCase().trim();
349
+ let roundResult: "correct" | "almost" | "wrong" = "wrong";
350
+ if (userAnswer === correct) {
351
+ setScore((s) => s + 1);
352
+ setResult("correct");
353
+ roundResult = "correct";
354
+ } else if (levenshtein(userAnswer, correct) === 1) {
355
+ setScore((s) => s + 0.5);
356
+ setResult("almost");
357
+ roundResult = "almost";
358
+ } else {
359
+ setResult("wrong");
360
+ roundResult = "wrong";
361
+ }
362
+ setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
363
+ setAnimating(true);
364
+ setTimeout(() => {
365
+ setAnimating(false);
366
+ setAnswered(true);
367
+ }, 600);
368
+ };
369
+
370
+
371
+ const nextRound = () =>
372
+ currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
373
+
374
+
375
+ const exitGame = () => {
376
+ setStarted(false);
377
+ setFinished(false);
378
+ setTheme(null);
379
+ };
380
+
381
+
382
+ const MemoizedLogo = useMemo(
383
+ () => (
384
+ // ensure logo is positioned inside the square container
385
+ <div style={{ ...styles.gmLogoFixed, position: "absolute", top: 16, left: 16, zIndex: 30 }}>
386
+ <picture>
387
+ <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
388
+ <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
389
+ </picture>
390
+ </div>
391
+ ),
392
+ []
393
+ );
394
+
395
+
396
+ return (
397
+ <div
398
+ ref={containerRef}
399
+ style={{
400
+ width: "100%",
401
+ height: "100vh",
402
+ display: "flex",
403
+ justifyContent: "center",
404
+ alignItems: "center",
405
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
406
+ transition: "background 0.3s ease",
407
+ overflowX: "clip"
408
+ }}
409
+ >
410
+ <div
411
+ style={{
412
+ width: isMobile ? "100%" : containerSize || 1000,
413
+ height: isMobile ? "100%" : containerSize || 1000,
414
+ display: "flex",
415
+ justifyContent: "center",
416
+ alignItems: "center",
417
+ overflow: "hidden",
418
+ borderRadius: isMobile ? 0 : "20px",
419
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
420
+ boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.1)",
421
+ margin: isMobile ? "0 auto" : "unset",
422
+ position: "relative", // needed so absolute logo is inside the square
423
+ }}
424
+ >
425
+ <div
426
+ style={{
427
+ transform: "none",
428
+ width: "100%",
429
+ height: "100%",
430
+ display: "flex",
431
+ justifyContent: "center",
432
+ alignItems: "center",
433
+ }}
434
+ >
435
+ <div id="whats-missing-root">
436
+ {!isMobile && MemoizedLogo}
437
+ {/* ====== ИГРОВАЯ ЛОГИКА ====== */}
438
+ {!theme && !started && (
439
+ <div style={styles.gmCenterScreen}>
440
+ <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
441
+ <p style={styles.gmBodyM}>Select a theme:</p>
442
+ <div style={{ display: "flex", gap: 16 }}>
443
+ {["animals", "food", "toys"].map((t) => (
444
+ <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
445
+ {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
446
+ </button>
447
+ ))}
448
+ </div>
449
+ <div style={{ marginTop: 24 }}>
450
+ <p style={styles.gmBodyS}>Choose number of rounds:</p>
451
+ <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
452
+ {[3, 4, 5].map((n) => (
453
+ <button
454
+ key={n}
455
+ style={{
456
+ ...styles.gmButton,
457
+ ...(rounds === n ? styles.gmButtonActive : {}),
458
+ }}
459
+ onClick={() => setRounds(n)}
460
+ >
461
+ {n}
462
+ </button>
463
+ ))}
464
+ </div>
465
+ </div>
466
+ </div>
467
+ )}
468
+ {theme && !started && (
469
+ <div style={styles.gmCenterScreen}>
470
+ <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
471
+ <p style={styles.gmBodyM}>Rounds: {rounds}</p>
472
+ <button style={styles.gmButton} onClick={startGame}>
473
+ ▶ Start game
474
+ </button>
475
+ </div>
476
+ )}
477
+ {finished && (
478
+ <div style={styles.gmCenterScreen}>
479
+ <h1 style={styles.gmHeadline1}>Results</h1>
480
+ <h2 style={styles.gmHeadline3}>
481
+ Your score: {score} / {rounds}
482
+ </h2>
483
+ <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
484
+ <table style={styles.gmTable}>
485
+ <thead>
486
+ <tr>
487
+ <th>Round</th>
488
+ <th>Your Answer</th>
489
+ <th>Correct</th>
490
+ <th>Result</th>
491
+ </tr>
492
+ </thead>
493
+ <tbody>
494
+ {resultsTable.map((r, i) => (
495
+ <tr key={i}>
496
+ <td style={styles.gmTableCell}>{r.round}</td>
497
+ <td style={styles.gmTableCell}>{r.answer || "—"}</td>
498
+ <td style={styles.gmTableCell}>{r.correct}</td>
499
+ <td style={styles.gmTableCell}>
500
+ {r.result === "correct"
501
+ ? "✔ Correct"
502
+ : r.result === "almost"
503
+ ? "◐ Almost (0.5)"
504
+ : "✘ Wrong"}
505
+ </td>
506
+ </tr>
507
+ ))}
508
+ </tbody>
509
+ </table>
510
+ <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
511
+ <button style={styles.gmButton} onClick={startGame}>
512
+ 🔁 Play again
513
+ </button>
514
+ <button style={styles.gmButton} onClick={exitGame}>
515
+ ⬅️ Choose theme
516
+ </button>
517
+ </div>
518
+ </div>
519
+ )}
520
+ {started && !finished && (
521
+ <div style={styles.gmGameLayout}>
522
+ <div
523
+ style={{
524
+ minHeight: 160,
525
+ display: "flex",
526
+ flexDirection: "column",
527
+ justifyContent: "center",
528
+ alignItems: "center",
529
+ }}
530
+ >
531
+ {phase === "ready" && (
532
+ <>
533
+ <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
534
+ <div style={styles.gmHourglass}>⏳</div>
535
+ </>
536
+ )}
537
+ {phase === "memorize" && (
538
+ <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
539
+ MEMORIZE ({memorizeTime})
540
+ </p>
541
+ )}
542
+ {phase === "guess" && !answered && (
543
+ <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
544
+ )}
545
+ </div>
546
+ {phase !== "ready" && (
547
+ <div
548
+ style={{
549
+ ...styles.gmGrid,
550
+ gridTemplateColumns: isMobile
551
+ ? "repeat(2, 1fr)"
552
+ : "repeat(3, 210px)",
553
+ gridAutoRows: isMobile ? "150px" : "210px",
554
+ gap: isMobile ? "12px" : "20px",
555
+ justifyItems: "center",
556
+ }}
557
+ >
558
+ {files.map((file, i) => {
559
+ const isHidden =
560
+ hiddenIndex === i && phase === "guess" && !answered;
561
+ return (
562
+ <div
563
+ key={i}
564
+ style={{
565
+ ...styles.gmCard,
566
+ ...(result === "correct" && hiddenIndex === i
567
+ ? styles.gmCorrect
568
+ : {}),
569
+ ...(result === "wrong" && hiddenIndex === i
570
+ ? styles.gmWrong
571
+ : {}),
572
+ }}
573
+ >
574
+ {!isHidden && (
575
+ <img
576
+ src={file.src}
577
+ alt={file.name}
578
+ style={{
579
+ width: "100%",
580
+ height: "100%",
581
+ objectFit: "cover",
582
+ }}
583
+ />
584
+ )}
585
+ </div>
586
+ );
587
+ })}
588
+ </div>
589
+ )}
590
+ <div style={{ marginTop: 16, height: 80 }}>
591
+ {phase === "guess" && !answered && (
592
+ <div>
593
+ <input
594
+ type="text"
595
+ placeholder="Type the missing word"
596
+ value={inputValue}
597
+ onChange={(e) => setInputValue(e.target.value)}
598
+ style={styles.gmInput}
599
+ />
600
+ <button
601
+ style={{ ...styles.gmButton, marginLeft: 8 }}
602
+ onClick={checkAnswer}
603
+ disabled={animating}
604
+ >
605
+ {animating ? "..." : "Check"}
606
+ </button>
607
+ </div>
608
+ )}
609
+ {answered && (
610
+ <button type="button" style={styles.gmButton} onClick={nextRound}>
611
+ Next round
612
+ </button>
613
+ )}
614
+ </div>
615
+ </div>
616
+ )}
617
+ </div>
618
+ </div>
619
+ </div>
620
+ </div>
621
+ );
622
+ }