@mblinkov/whats-missing 20.0.9 → 20.0.12

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,4 +1,4 @@
1
- import { useState, useEffect, useMemo } from "react";
1
+ import { useState, useEffect, useMemo, useRef } from "react";
2
2
  import { themes } from "./themes";
3
3
  import { styles } from "./Game.styles";
4
4
  import type { Theme } from "./themes";
@@ -11,55 +11,20 @@ type RoundResult = {
11
11
  result: "correct" | "almost" | "wrong";
12
12
  };
13
13
 
14
- // ✅ Финальный reset без смещений и цветовых различий
14
+ // ✅ Минимальный reset без вмешательства в body
15
15
  const globalReset = () => {
16
16
  const style = document.createElement("style");
17
17
  style.textContent = `
18
- html, body {
19
- margin: 0;
20
- padding: 0;
21
- width: 100%;
22
- height: 100%;
23
- overflow-x: hidden;
24
- background: linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%);
25
- background-repeat: no-repeat;
26
- background-attachment: fixed;
27
- background-size: cover;
28
- }
29
-
30
- #whats-missing-root {
31
- all: unset;
32
- display: block;
33
- position: relative;
34
- left: 0;
35
- top: 0;
36
- overflow: hidden;
37
- width: 100%;
38
- min-height: 100vh;
39
- margin: 0;
40
- padding: 0;
41
- color: #1f2937;
42
- text-align: center;
43
- background: linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%);
44
- background-size: cover;
45
- }
46
-
47
18
  #whats-missing-root, #whats-missing-root * {
48
19
  box-sizing: border-box;
49
20
  font-family: "Geist", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
50
21
  }
51
-
52
22
  #whats-missing-root img {
53
23
  max-width: 100%;
54
24
  height: auto;
55
25
  display: block;
56
26
  user-select: none;
57
27
  }
58
-
59
- @keyframes spin {
60
- from { transform: rotate(0deg); }
61
- to { transform: rotate(360deg); }
62
- }
63
28
  `;
64
29
  document.head.appendChild(style);
65
30
  };
@@ -80,8 +45,13 @@ const levenshtein = (a: string, b: string) => {
80
45
  };
81
46
 
82
47
  export default function Game() {
48
+ const containerRef = useRef<HTMLDivElement>(null);
49
+
83
50
  useEffect(() => {
84
51
  globalReset();
52
+ return () => {
53
+ document.body.style.overflow = "";
54
+ };
85
55
  }, []);
86
56
 
87
57
  const [theme, setTheme] = useState<Theme | null>(null);
@@ -92,7 +62,7 @@ export default function Game() {
92
62
  const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
93
63
  const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
94
64
  const [readyTime, setReadyTime] = useState(3);
95
- const [memorizeTime, setMemorizeTime] = useState(7);
65
+ const [memorizeTime, setMemorizeTime] = useState(10);
96
66
  const [timeLeft, setTimeLeft] = useState(20);
97
67
  const [score, setScore] = useState(0);
98
68
  const [finished, setFinished] = useState(false);
@@ -147,27 +117,25 @@ export default function Game() {
147
117
  if (isFirst) {
148
118
  setPhase("ready");
149
119
  setReadyTime(3);
150
- setMemorizeTime(7);
120
+ setMemorizeTime(10);
151
121
  } else {
152
122
  setPhase("memorize");
153
- setMemorizeTime(7);
123
+ setMemorizeTime(10);
154
124
  }
155
125
  };
156
126
 
157
127
  useEffect(() => {
158
128
  if (!started || finished || answered) return;
159
129
  if (phase === "ready") {
160
- if (readyTime <= 0) {
161
- setPhase("memorize");
162
- } else {
130
+ if (readyTime <= 0) setPhase("memorize");
131
+ else {
163
132
  const t = setTimeout(() => setReadyTime((r) => r - 1), 1000);
164
133
  return () => clearTimeout(t);
165
134
  }
166
135
  }
167
136
  if (phase === "memorize") {
168
- if (memorizeTime <= 0) {
169
- setPhase("guess");
170
- } else {
137
+ if (memorizeTime <= 0) setPhase("guess");
138
+ else {
171
139
  const t = setTimeout(() => setMemorizeTime((m) => m - 1), 1000);
172
140
  return () => clearTimeout(t);
173
141
  }
@@ -186,19 +154,7 @@ export default function Game() {
186
154
  const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
187
155
  return () => clearTimeout(t);
188
156
  }
189
- }, [
190
- phase,
191
- readyTime,
192
- memorizeTime,
193
- timeLeft,
194
- started,
195
- finished,
196
- answered,
197
- files,
198
- hiddenIndex,
199
- currentRound,
200
- inputValue,
201
- ]);
157
+ }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
202
158
 
203
159
  const checkAnswer = () => {
204
160
  if (hiddenIndex === null || animating) return;
@@ -217,10 +173,7 @@ export default function Game() {
217
173
  setResult("wrong");
218
174
  roundResult = "wrong";
219
175
  }
220
- setResultsTable((prev) => [
221
- ...prev,
222
- { round: currentRound, answer: userAnswer, correct, result: roundResult },
223
- ]);
176
+ setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
224
177
  setAnimating(true);
225
178
  setTimeout(() => {
226
179
  setAnimating(false);
@@ -229,9 +182,7 @@ export default function Game() {
229
182
  };
230
183
 
231
184
  const nextRound = () =>
232
- currentRound < rounds
233
- ? (setCurrentRound((r) => r + 1), startRound())
234
- : setFinished(true);
185
+ currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
235
186
 
236
187
  const exitGame = () => {
237
188
  setStarted(false);
@@ -239,226 +190,232 @@ export default function Game() {
239
190
  setTheme(null);
240
191
  };
241
192
 
242
- // ✅ логотип с fallback SVG → PNG
243
193
  const MemoizedLogo = useMemo(
244
194
  () => (
245
195
  <div style={styles.gmLogoFixed}>
246
196
  <picture>
247
- <source
248
- srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"}
249
- type="image/svg+xml"
250
- />
251
- <img
252
- src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"}
253
- alt="SPEAKID Logo"
254
- style={styles.gmLogoImg}
255
- loading="lazy"
256
- />
197
+ <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
198
+ <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
257
199
  </picture>
258
200
  </div>
259
201
  ),
260
202
  []
261
203
  );
262
204
 
205
+ // ✅ Новый контейнер для защиты от zoom
263
206
  return (
264
- <div id="whats-missing-root">
265
- {!isMobile && MemoizedLogo}
207
+ <div
208
+ ref={containerRef}
209
+ style={{
210
+ display: "flex",
211
+ justifyContent: "center",
212
+ alignItems: "center",
213
+ width: "100%",
214
+ height: "100%",
215
+ overflow: "hidden",
216
+ }}
217
+ >
218
+ <div
219
+ style={{
220
+ width: window.innerWidth > window.innerHeight ? "90vh" : "90vw",
221
+ height: window.innerWidth > window.innerHeight ? "90vh" : "90vw",
222
+ display: "flex",
223
+ justifyContent: "center",
224
+ alignItems: "center",
225
+ flexDirection: "column",
226
+ borderRadius: "20px",
227
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
228
+ }}
229
+ >
230
+ <div id="whats-missing-root">
231
+ {!isMobile && MemoizedLogo}
266
232
 
267
- {!theme && !started && (
268
- <div style={styles.gmCenterScreen}>
269
- <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
270
- <p style={styles.gmBodyM}>Select a theme:</p>
271
- <div style={{ display: "flex", gap: 16 }}>
272
- {["animals", "food", "toys"].map((t) => (
273
- <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
274
- {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
275
- </button>
276
- ))}
277
- </div>
278
- <div style={{ marginTop: 24 }}>
279
- <p style={styles.gmBodyS}>Choose number of rounds:</p>
280
- <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
281
- {[3, 4, 5].map((n) => (
282
- <button
283
- key={n}
284
- style={{
285
- ...styles.gmButton,
286
- ...(rounds === n ? styles.gmButtonActive : {}),
287
- }}
288
- onClick={() => setRounds(n)}
289
- >
290
- {n}
291
- </button>
292
- ))}
233
+ {!theme && !started && (
234
+ <div style={styles.gmCenterScreen}>
235
+ <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
236
+ <p style={styles.gmBodyM}>Select a theme:</p>
237
+ <div style={{ display: "flex", gap: 16 }}>
238
+ {["animals", "food", "toys"].map((t) => (
239
+ <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
240
+ {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
241
+ </button>
242
+ ))}
243
+ </div>
244
+ <div style={{ marginTop: 24 }}>
245
+ <p style={styles.gmBodyS}>Choose number of rounds:</p>
246
+ <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
247
+ {[3, 4, 5].map((n) => (
248
+ <button
249
+ key={n}
250
+ style={{
251
+ ...styles.gmButton,
252
+ ...(rounds === n ? styles.gmButtonActive : {}),
253
+ }}
254
+ onClick={() => setRounds(n)}
255
+ >
256
+ {n}
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
293
261
  </div>
294
- </div>
295
- </div>
296
- )}
297
-
298
- {theme && !started && (
299
- <div style={styles.gmCenterScreen}>
300
- <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
301
- <p style={styles.gmBodyM}>Rounds: {rounds}</p>
302
- <button style={styles.gmButton} onClick={startGame}>
303
- ▶ Start game
304
- </button>
305
- </div>
306
- )}
262
+ )}
307
263
 
308
- {finished && (
309
- <div style={styles.gmCenterScreen}>
310
- <h1 style={styles.gmHeadline1}>Results</h1>
311
- <h2 style={styles.gmHeadline3}>
312
- Your score: {score} / {rounds}
313
- </h2>
314
- <table style={styles.gmTable}>
315
- <thead>
316
- <tr>
317
- <th>Round</th>
318
- <th>Your Answer</th>
319
- <th>Correct</th>
320
- <th>Result</th>
321
- </tr>
322
- </thead>
323
- <tbody>
324
- {resultsTable.map((r, i) => (
325
- <tr key={i}>
326
- <td style={styles.gmTableCell}>{r.round}</td>
327
- <td style={styles.gmTableCell}>{r.answer || "—"}</td>
328
- <td style={styles.gmTableCell}>{r.correct}</td>
329
- <td style={styles.gmTableCell}>
330
- {r.result === "correct"
331
- ? "✔ Correct"
332
- : r.result === "almost"
333
- ? "◐ Almost (0.5)"
334
- : "✘ Wrong"}
335
- </td>
336
- </tr>
337
- ))}
338
- </tbody>
339
- </table>
340
- <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
341
- <button style={styles.gmButton} onClick={startGame}>
342
- 🔁 Play again
343
- </button>
344
- <button style={styles.gmButton} onClick={exitGame}>
345
- ⬅️ Choose theme
346
- </button>
347
- </div>
348
- </div>
349
- )}
264
+ {theme && !started && (
265
+ <div style={styles.gmCenterScreen}>
266
+ <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
267
+ <p style={styles.gmBodyM}>Rounds: {rounds}</p>
268
+ <button style={styles.gmButton} onClick={startGame}>
269
+ ▶ Start game
270
+ </button>
271
+ </div>
272
+ )}
350
273
 
351
- {started && !finished && (
352
- <div style={styles.gmGameLayout}>
353
- <div
354
- style={{
355
- minHeight: 160,
356
- display: "flex",
357
- flexDirection: "column",
358
- justifyContent: "center",
359
- alignItems: "center",
360
- }}
361
- >
362
- {phase === "ready" && (
363
- <>
364
- <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
365
- <div style={styles.gmHourglass}>⏳</div>
366
- </>
367
- )}
368
- {phase === "memorize" && (
369
- <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
370
- MEMORIZE ({memorizeTime})
274
+ {finished && (
275
+ <div style={styles.gmCenterScreen}>
276
+ <h1 style={styles.gmHeadline1}>Results</h1>
277
+ <h2 style={styles.gmHeadline3}>
278
+ Your score: {score} / {rounds}
279
+ </h2>
280
+ <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>
281
+ Yahoo! You did it! 🍬✨
371
282
  </p>
372
- )}
373
- {phase === "guess" && !answered && (
374
- <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
375
- )}
376
- </div>
283
+ <table style={styles.gmTable}>
284
+ <thead>
285
+ <tr>
286
+ <th>Round</th>
287
+ <th>Your Answer</th>
288
+ <th>Correct</th>
289
+ <th>Result</th>
290
+ </tr>
291
+ </thead>
292
+ <tbody>
293
+ {resultsTable.map((r, i) => (
294
+ <tr key={i}>
295
+ <td style={styles.gmTableCell}>{r.round}</td>
296
+ <td style={styles.gmTableCell}>{r.answer || "—"}</td>
297
+ <td style={styles.gmTableCell}>{r.correct}</td>
298
+ <td style={styles.gmTableCell}>
299
+ {r.result === "correct"
300
+ ? "✔ Correct"
301
+ : r.result === "almost"
302
+ ? "◐ Almost (0.5)"
303
+ : "✘ Wrong"}
304
+ </td>
305
+ </tr>
306
+ ))}
307
+ </tbody>
308
+ </table>
309
+ <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
310
+ <button style={styles.gmButton} onClick={startGame}>
311
+ 🔁 Play again
312
+ </button>
313
+ <button style={styles.gmButton} onClick={exitGame}>
314
+ ⬅️ Choose theme
315
+ </button>
316
+ </div>
317
+ </div>
318
+ )}
377
319
 
378
- {phase !== "ready" && (
379
- <div
380
- style={{
381
- ...styles.gmGrid,
382
- gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(3, 210px)",
383
- gridAutoRows: isMobile ? "150px" : "210px",
384
- gap: isMobile ? "12px" : "20px",
385
- justifyItems: "center",
386
- ...(window.innerWidth > 2000
387
- ? {
388
- gridTemplateColumns: "repeat(3, 260px)",
389
- gridAutoRows: "260px",
390
- gap: "28px",
391
- }
392
- : {}),
393
- ...(window.innerWidth > 2560
394
- ? {
395
- gridTemplateColumns: "repeat(3, 300px)",
396
- gridAutoRows: "300px",
397
- gap: "32px",
398
- }
399
- : {}),
400
- }}
401
- >
402
- {files.map((file, i) => {
403
- const isHidden =
404
- hiddenIndex === i && phase === "guess" && !answered;
405
- return (
406
- <div
407
- key={i}
408
- style={{
409
- ...styles.gmCard,
410
- ...(result === "correct" && hiddenIndex === i
411
- ? styles.gmCorrect
412
- : {}),
413
- ...(result === "wrong" && hiddenIndex === i
414
- ? styles.gmWrong
415
- : {}),
416
- }}
417
- >
418
- {!isHidden && (
419
- <img
420
- src={file.src}
421
- alt={file.name}
320
+ {started && !finished && (
321
+ <div style={styles.gmGameLayout}>
322
+ <div
323
+ style={{
324
+ minHeight: 160,
325
+ display: "flex",
326
+ flexDirection: "column",
327
+ justifyContent: "center",
328
+ alignItems: "center",
329
+ }}
330
+ >
331
+ {phase === "ready" && (
332
+ <>
333
+ <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
334
+ <div style={styles.gmHourglass}>⏳</div>
335
+ </>
336
+ )}
337
+ {phase === "memorize" && (
338
+ <p style={{ ...styles.gmBodyM, color: "#10b981" }}>
339
+ MEMORIZE ({memorizeTime})
340
+ </p>
341
+ )}
342
+ {phase === "guess" && !answered && (
343
+ <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
344
+ )}
345
+ </div>
346
+
347
+ {phase !== "ready" && (
348
+ <div
349
+ style={{
350
+ ...styles.gmGrid,
351
+ gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(3, 210px)",
352
+ gridAutoRows: isMobile ? "150px" : "210px",
353
+ gap: isMobile ? "12px" : "20px",
354
+ justifyItems: "center",
355
+ }}
356
+ >
357
+ {files.map((file, i) => {
358
+ const isHidden =
359
+ hiddenIndex === i && phase === "guess" && !answered;
360
+ return (
361
+ <div
362
+ key={i}
422
363
  style={{
423
- width: "100%",
424
- height: "100%",
425
- objectFit: "cover",
364
+ ...styles.gmCard,
365
+ ...(result === "correct" && hiddenIndex === i
366
+ ? styles.gmCorrect
367
+ : {}),
368
+ ...(result === "wrong" && hiddenIndex === i
369
+ ? styles.gmWrong
370
+ : {}),
426
371
  }}
427
- />
428
- )}
372
+ >
373
+ {!isHidden && (
374
+ <img
375
+ src={file.src}
376
+ alt={file.name}
377
+ style={{
378
+ width: "100%",
379
+ height: "100%",
380
+ objectFit: "cover",
381
+ }}
382
+ />
383
+ )}
384
+ </div>
385
+ );
386
+ })}
387
+ </div>
388
+ )}
389
+
390
+ <div style={{ marginTop: 16, height: 80 }}>
391
+ {phase === "guess" && !answered && (
392
+ <div>
393
+ <input
394
+ type="text"
395
+ placeholder="Type the missing word"
396
+ value={inputValue}
397
+ onChange={(e) => setInputValue(e.target.value)}
398
+ style={styles.gmInput}
399
+ />
400
+ <button
401
+ style={{ ...styles.gmButton, marginLeft: 8 }}
402
+ onClick={checkAnswer}
403
+ disabled={animating}
404
+ >
405
+ {animating ? "..." : "Check"}
406
+ </button>
429
407
  </div>
430
- );
431
- })}
408
+ )}
409
+ {answered && (
410
+ <button style={styles.gmButton} onClick={nextRound}>
411
+ Next round
412
+ </button>
413
+ )}
414
+ </div>
432
415
  </div>
433
416
  )}
434
-
435
- <div style={{ marginTop: 16, height: 80 }}>
436
- {phase === "guess" && !answered && (
437
- <div>
438
- <input
439
- type="text"
440
- placeholder="Type the missing word"
441
- value={inputValue}
442
- onChange={(e) => setInputValue(e.target.value)}
443
- style={styles.gmInput}
444
- />
445
- <button
446
- style={{ ...styles.gmButton, marginLeft: 8 }}
447
- onClick={checkAnswer}
448
- disabled={animating}
449
- >
450
- {animating ? "..." : "Check"}
451
- </button>
452
- </div>
453
- )}
454
- {answered && (
455
- <button style={styles.gmButton} onClick={nextRound}>
456
- Next round
457
- </button>
458
- )}
459
- </div>
460
417
  </div>
461
- )}
418
+ </div>
462
419
  </div>
463
420
  );
464
421
  }