@mblinkov/whats-missing 20.0.23 → 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/dist/whats-missing.cjs.js +3 -4
- package/dist/whats-missing.es.js +191 -212
- package/package.json +1 -1
- package/src/Game.tsx +433 -528
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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.88; // 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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
}
|