@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/dist/whats-missing.cjs.js +6 -14
- package/dist/whats-missing.es.js +263 -160
- package/package.json +2 -1
- package/src/Game.styles.ts +7 -3
- package/src/Game.tsx +578 -360
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
57
|
+
const getBodyScale = (): number => {
|
|
101
58
|
try {
|
|
102
|
-
const
|
|
103
|
-
const cs = getComputedStyle(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
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
|
-
|
|
84
|
+
return 1;
|
|
125
85
|
};
|
|
126
86
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|