@mblinkov/whats-missing 20.0.14 → 20.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mblinkov/whats-missing",
3
- "version": "20.0.14",
3
+ "version": "20.0.16",
4
4
  "main": "dist/whats-missing.umd.js",
5
5
  "module": "dist/whats-missing.es.js",
6
6
  "types": "dist/index.d.ts",
package/src/Game.tsx CHANGED
@@ -11,42 +11,28 @@ type RoundResult = {
11
11
  result: "correct" | "almost" | "wrong";
12
12
  };
13
13
 
14
- // ✅ Финальный reset — исправляет фон, убирает скроллы, изолирует зум
14
+ // ✅ базовый reset
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: hidden;
24
- background: radial-gradient(circle at center, #fff8f8 0%, #fdfdfd 80%, #ffffff 100%);
25
- background-repeat: no-repeat;
26
- background-size: cover;
27
- transform-origin: center;
28
- }
29
-
30
18
  #whats-missing-root, #whats-missing-root * {
31
19
  box-sizing: border-box;
32
20
  font-family: "Geist", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
33
- transform: none !important;
34
21
  }
35
-
36
22
  #whats-missing-root img {
37
23
  max-width: 100%;
38
24
  height: auto;
39
25
  display: block;
40
26
  user-select: none;
41
27
  }
28
+ html, body { margin: 0; padding: 0; }
42
29
  `;
43
30
  document.head.appendChild(style);
44
31
  };
45
32
 
33
+ // простая функция сравнения
46
34
  const levenshtein = (a: string, b: string) => {
47
- const dp = Array.from({ length: a.length + 1 }, () =>
48
- Array(b.length + 1).fill(0)
49
- );
35
+ const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
50
36
  for (let i = 0; i <= a.length; i++) dp[i][0] = i;
51
37
  for (let j = 0; j <= b.length; j++) dp[0][j] = j;
52
38
  for (let i = 1; i <= a.length; i++) {
@@ -85,18 +71,31 @@ export default function Game() {
85
71
  const [inputValue, setInputValue] = useState("");
86
72
  const [answered, setAnswered] = useState(false);
87
73
  const [animating, setAnimating] = useState(false);
88
- const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(
89
- null
90
- );
74
+ const [result, setResult] = useState<"correct" | "almost" | "wrong" | null>(null);
91
75
  const [resultsTable, setResultsTable] = useState<RoundResult[]>([]);
92
76
  const [usedHidden, setUsedHidden] = useState<string[]>([]);
93
77
  const [isMobile, setIsMobile] = useState(false);
78
+ const [scale, setScale] = useState(1);
94
79
 
80
+ // ✅ адаптив под мобилки и десктоп
95
81
  useEffect(() => {
96
- const checkMobile = () => setIsMobile(window.innerWidth < 768);
97
- checkMobile();
98
- window.addEventListener("resize", checkMobile);
99
- return () => window.removeEventListener("resize", checkMobile);
82
+ const resize = () => {
83
+ const mobile = window.innerWidth < 768;
84
+ setIsMobile(mobile);
85
+
86
+ if (!mobile) {
87
+ // виртуальный размер игры (1440x768)
88
+ const baseW = 1440;
89
+ const baseH = 768;
90
+ const ratio = Math.min(window.innerWidth / baseW, window.innerHeight / baseH);
91
+ setScale(ratio);
92
+ } else {
93
+ setScale(1);
94
+ }
95
+ };
96
+ resize();
97
+ window.addEventListener("resize", resize);
98
+ return () => window.removeEventListener("resize", resize);
100
99
  }, []);
101
100
 
102
101
  const getRandomSix = (arr: ImageItem[]) =>
@@ -115,11 +114,7 @@ export default function Game() {
115
114
  startRound(selected, [], true);
116
115
  };
117
116
 
118
- const startRound = (
119
- images = files,
120
- used: string[] = usedHidden,
121
- isFirst = false
122
- ) => {
117
+ const startRound = (images = files, used: string[] = usedHidden, isFirst = false) => {
123
118
  const roundSet = getRandomSix(images.length ? images : themes[theme!]);
124
119
  let idx = Math.floor(Math.random() * roundSet.length);
125
120
  let candidate = roundSet[idx].name;
@@ -169,31 +164,14 @@ export default function Game() {
169
164
  const correct = files[hiddenIndex!].name;
170
165
  setResultsTable((prev) => [
171
166
  ...prev,
172
- {
173
- round: currentRound,
174
- answer: inputValue,
175
- correct,
176
- result: "wrong",
177
- },
167
+ { round: currentRound, answer: inputValue, correct, result: "wrong" },
178
168
  ]);
179
169
  return;
180
170
  }
181
171
  const t = setTimeout(() => setTimeLeft((s) => s - 1), 1000);
182
172
  return () => clearTimeout(t);
183
173
  }
184
- }, [
185
- phase,
186
- readyTime,
187
- memorizeTime,
188
- timeLeft,
189
- started,
190
- finished,
191
- answered,
192
- files,
193
- hiddenIndex,
194
- currentRound,
195
- inputValue,
196
- ]);
174
+ }, [phase, readyTime, memorizeTime, timeLeft, started, finished, answered, files, hiddenIndex, currentRound, inputValue]);
197
175
 
198
176
  const checkAnswer = () => {
199
177
  if (hiddenIndex === null || animating) return;
@@ -212,10 +190,7 @@ export default function Game() {
212
190
  setResult("wrong");
213
191
  roundResult = "wrong";
214
192
  }
215
- setResultsTable((prev) => [
216
- ...prev,
217
- { round: currentRound, answer: userAnswer, correct, result: roundResult },
218
- ]);
193
+ setResultsTable((prev) => [...prev, { round: currentRound, answer: userAnswer, correct, result: roundResult }]);
219
194
  setAnimating(true);
220
195
  setTimeout(() => {
221
196
  setAnimating(false);
@@ -224,9 +199,7 @@ export default function Game() {
224
199
  };
225
200
 
226
201
  const nextRound = () =>
227
- currentRound < rounds
228
- ? (setCurrentRound((r) => r + 1), startRound())
229
- : setFinished(true);
202
+ currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
230
203
 
231
204
  const exitGame = () => {
232
205
  setStarted(false);
@@ -238,279 +211,224 @@ export default function Game() {
238
211
  () => (
239
212
  <div style={styles.gmLogoFixed}>
240
213
  <picture>
241
- <source
242
- srcSet={
243
- window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"
244
- }
245
- type="image/svg+xml"
246
- />
247
- <img
248
- src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"}
249
- alt="SPEAKID Logo"
250
- style={styles.gmLogoImg}
251
- loading="lazy"
252
- />
214
+ <source srcSet={window.origin + "/cloud/speakid/games/whatsmissing/logo.svg"} type="image/svg+xml" />
215
+ <img src={window.origin + "/cloud/speakid/games/whatsmissing/logo.png"} alt="SPEAKID Logo" style={styles.gmLogoImg} loading="lazy" />
253
216
  </picture>
254
217
  </div>
255
218
  ),
256
219
  []
257
220
  );
258
221
 
222
+ // =============================
223
+ // RENDER
224
+ // =============================
259
225
  return (
260
226
  <div
261
227
  ref={containerRef}
262
228
  style={{
229
+ width: "100%",
230
+ height: "100vh",
263
231
  display: "flex",
264
232
  justifyContent: "center",
265
233
  alignItems: "center",
266
- width: "100%",
267
- height: "100%",
234
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
235
+ transition: "background 0.3s ease",
268
236
  }}
269
237
  >
270
238
  <div
271
239
  style={{
272
- width: window.innerWidth > window.innerHeight ? "90vh" : "90vw",
273
- height: window.innerWidth > window.innerHeight ? "90vh" : "90vw",
240
+ width: isMobile ? "100%" : 1440,
241
+ height: isMobile ? "100%" : 768,
274
242
  display: "flex",
275
243
  justifyContent: "center",
276
244
  alignItems: "center",
277
- flexDirection: "column",
278
245
  overflow: "hidden",
279
- borderRadius: "20px",
280
- background:
281
- "radial-gradient(circle at center, #fff8f8 0%, #fdfdfd 80%, #ffffff 100%)",
282
- boxShadow: "0 0 40px rgba(0,0,0,0.08)",
246
+ borderRadius: isMobile ? 0 : "20px",
247
+ background: "linear-gradient(to bottom, #fff8f8 0%, #f9fafb 100%)",
248
+ boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.1)",
249
+ margin: isMobile ? "0 auto" : "unset",
283
250
  }}
284
251
  >
285
- <div id="whats-missing-root">
286
- {!isMobile && MemoizedLogo}
252
+ {/* ✅ добавленный блок для правильного масштабирования и центрирования */}
253
+ <div
254
+ style={{
255
+ transform: isMobile ? "none" : `scale(${scale})`,
256
+ transformOrigin: "center center",
257
+ width: "100%",
258
+ height: "100%",
259
+ display: "flex",
260
+ justifyContent: "center",
261
+ alignItems: "center",
262
+ }}
263
+ >
264
+ <div id="whats-missing-root">
265
+ {!isMobile && MemoizedLogo}
287
266
 
288
- {!theme && !started && (
289
- <div style={styles.gmCenterScreen}>
290
- <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
291
- <p style={styles.gmBodyM}>Select a theme:</p>
292
- <div style={{ display: "flex", gap: 16 }}>
293
- {["animals", "food", "toys"].map((t) => (
294
- <button
295
- key={t}
296
- style={styles.gmButton}
297
- onClick={() => setTheme(t as Theme)}
298
- >
299
- {t === "animals"
300
- ? "🐶 Animals"
301
- : t === "food"
302
- ? "🍎 Food"
303
- : "🧸 Toys"}
304
- </button>
305
- ))}
306
- </div>
307
- <div style={{ marginTop: 24 }}>
308
- <p style={styles.gmBodyS}>Choose number of rounds:</p>
309
- <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
310
- {[3, 4, 5].map((n) => (
311
- <button
312
- key={n}
313
- style={{
314
- ...styles.gmButton,
315
- ...(rounds === n ? styles.gmButtonActive : {}),
316
- }}
317
- onClick={() => setRounds(n)}
318
- >
319
- {n}
267
+ {/* ====== ИГРОВАЯ ЛОГИКА ====== */}
268
+ {!theme && !started && (
269
+ <div style={styles.gmCenterScreen}>
270
+ <h1 style={styles.gmHeadline1}>WHAT'S MISSING?</h1>
271
+ <p style={styles.gmBodyM}>Select a theme:</p>
272
+ <div style={{ display: "flex", gap: 16 }}>
273
+ {["animals", "food", "toys"].map((t) => (
274
+ <button key={t} style={styles.gmButton} onClick={() => setTheme(t as Theme)}>
275
+ {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
320
276
  </button>
321
277
  ))}
322
278
  </div>
279
+ <div style={{ marginTop: 24 }}>
280
+ <p style={styles.gmBodyS}>Choose number of rounds:</p>
281
+ <div style={{ display: "flex", gap: 12, marginTop: 8 }}>
282
+ {[3, 4, 5].map((n) => (
283
+ <button
284
+ key={n}
285
+ style={{
286
+ ...styles.gmButton,
287
+ ...(rounds === n ? styles.gmButtonActive : {}),
288
+ }}
289
+ onClick={() => setRounds(n)}
290
+ >
291
+ {n}
292
+ </button>
293
+ ))}
294
+ </div>
295
+ </div>
323
296
  </div>
324
- </div>
325
- )}
326
-
327
- {theme && !started && (
328
- <div style={styles.gmCenterScreen}>
329
- <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
330
- <p style={styles.gmBodyM}>Rounds: {rounds}</p>
331
- <button style={styles.gmButton} onClick={startGame}>
332
- ▶ Start game
333
- </button>
334
- </div>
335
- )}
297
+ )}
336
298
 
337
- {finished && (
338
- <div style={styles.gmCenterScreen}>
339
- <h1 style={styles.gmHeadline1}>Results</h1>
340
- <h2 style={styles.gmHeadline3}>
341
- Your score: {score} / {rounds}
342
- </h2>
343
- <p
344
- style={{
345
- ...styles.gmBodyM,
346
- color: "#10b981",
347
- marginTop: 12,
348
- }}
349
- >
350
- Great job! 🎉 You did it!
351
- </p>
352
- <table style={styles.gmTable}>
353
- <thead>
354
- <tr>
355
- <th>Round</th>
356
- <th>Your Answer</th>
357
- <th>Correct</th>
358
- <th>Result</th>
359
- </tr>
360
- </thead>
361
- <tbody>
362
- {resultsTable.map((r, i) => (
363
- <tr key={i}>
364
- <td style={styles.gmTableCell}>{r.round}</td>
365
- <td style={styles.gmTableCell}>
366
- {r.answer || "—"}
367
- </td>
368
- <td style={styles.gmTableCell}>{r.correct}</td>
369
- <td style={styles.gmTableCell}>
370
- {r.result === "correct"
371
- ? "✔ Correct"
372
- : r.result === "almost"
373
- ? "◐ Almost (0.5)"
374
- : "✘ Wrong"}
375
- </td>
376
- </tr>
377
- ))}
378
- </tbody>
379
- </table>
380
- <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
299
+ {theme && !started && (
300
+ <div style={styles.gmCenterScreen}>
301
+ <h1 style={styles.gmHeadline1}>Theme selected: {theme}</h1>
302
+ <p style={styles.gmBodyM}>Rounds: {rounds}</p>
381
303
  <button style={styles.gmButton} onClick={startGame}>
382
- 🔁 Play again
383
- </button>
384
- <button style={styles.gmButton} onClick={exitGame}>
385
- ⬅️ Choose theme
304
+ Start game
386
305
  </button>
387
306
  </div>
388
- </div>
389
- )}
307
+ )}
390
308
 
391
- {started && !finished && (
392
- <div style={styles.gmGameLayout}>
393
- <div
394
- style={{
395
- minHeight: 160,
396
- display: "flex",
397
- flexDirection: "column",
398
- justifyContent: "center",
399
- alignItems: "center",
400
- }}
401
- >
402
- {phase === "ready" && (
403
- <>
404
- <h1
405
- style={{
406
- ...styles.gmHeadline1,
407
- color: "#ec4c44",
408
- }}
409
- >
410
- GET READY
411
- </h1>
412
- <div style={styles.gmHourglass}>⏳</div>
413
- </>
414
- )}
415
- {phase === "memorize" && (
416
- <p
417
- style={{
418
- ...styles.gmBodyM,
419
- color: "#10b981",
420
- }}
421
- >
422
- MEMORIZE ({memorizeTime})
423
- </p>
424
- )}
425
- {phase === "guess" && !answered && (
426
- <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>
427
- )}
309
+ {finished && (
310
+ <div style={styles.gmCenterScreen}>
311
+ <h1 style={styles.gmHeadline1}>Results</h1>
312
+ <h2 style={styles.gmHeadline3}>
313
+ Your score: {score} / {rounds}
314
+ </h2>
315
+ <p style={{ ...styles.gmBodyM, color: "#10b981", marginTop: 12 }}>Yahoo! You did it! 🍬✨</p>
316
+ <table style={styles.gmTable}>
317
+ <thead>
318
+ <tr>
319
+ <th>Round</th>
320
+ <th>Your Answer</th>
321
+ <th>Correct</th>
322
+ <th>Result</th>
323
+ </tr>
324
+ </thead>
325
+ <tbody>
326
+ {resultsTable.map((r, i) => (
327
+ <tr key={i}>
328
+ <td style={styles.gmTableCell}>{r.round}</td>
329
+ <td style={styles.gmTableCell}>{r.answer || "—"}</td>
330
+ <td style={styles.gmTableCell}>{r.correct}</td>
331
+ <td style={styles.gmTableCell}>
332
+ {r.result === "correct"
333
+ ? "✔ Correct"
334
+ : r.result === "almost"
335
+ ? "◐ Almost (0.5)"
336
+ : "✘ Wrong"}
337
+ </td>
338
+ </tr>
339
+ ))}
340
+ </tbody>
341
+ </table>
342
+ <div style={{ display: "flex", gap: 12, marginTop: 24 }}>
343
+ <button style={styles.gmButton} onClick={startGame}>
344
+ 🔁 Play again
345
+ </button>
346
+ <button style={styles.gmButton} onClick={exitGame}>
347
+ ⬅️ Choose theme
348
+ </button>
349
+ </div>
428
350
  </div>
351
+ )}
429
352
 
430
- {phase !== "ready" && (
353
+ {started && !finished && (
354
+ <div style={styles.gmGameLayout}>
431
355
  <div
432
356
  style={{
433
- ...styles.gmGrid,
434
- gridTemplateColumns: isMobile
435
- ? "repeat(2, 1fr)"
436
- : "repeat(3, 210px)",
437
- gridAutoRows: isMobile ? "150px" : "210px",
438
- gap: isMobile ? "12px" : "20px",
439
- justifyItems: "center",
357
+ minHeight: 160,
358
+ display: "flex",
359
+ flexDirection: "column",
360
+ justifyContent: "center",
361
+ alignItems: "center",
440
362
  }}
441
363
  >
442
- {files.map((file, i) => {
443
- const isHidden =
444
- hiddenIndex === i &&
445
- phase === "guess" &&
446
- !answered;
447
- return (
448
- <div
449
- key={i}
450
- style={{
451
- ...styles.gmCard,
452
- ...(result === "correct" &&
453
- hiddenIndex === i
454
- ? styles.gmCorrect
455
- : {}),
456
- ...(result === "wrong" &&
457
- hiddenIndex === i
458
- ? styles.gmWrong
459
- : {}),
460
- }}
461
- >
462
- {!isHidden && (
463
- <img
464
- src={file.src}
465
- alt={file.name}
466
- style={{
467
- width: "100%",
468
- height: "100%",
469
- objectFit: "cover",
470
- }}
471
- />
472
- )}
473
- </div>
474
- );
475
- })}
364
+ {phase === "ready" && (
365
+ <>
366
+ <h1 style={{ ...styles.gmHeadline1, color: "#ec4c44" }}>GET READY</h1>
367
+ <div style={styles.gmHourglass}>⏳</div>
368
+ </>
369
+ )}
370
+ {phase === "memorize" && <p style={{ ...styles.gmBodyM, color: "#10b981" }}>MEMORIZE ({memorizeTime})</p>}
371
+ {phase === "guess" && !answered && <p style={styles.gmBodyM}>⏳ Time left: {timeLeft}s</p>}
476
372
  </div>
477
- )}
478
373
 
479
- <div style={{ marginTop: 16, height: 80 }}>
480
- {phase === "guess" && !answered && (
481
- <div>
482
- <input
483
- type="text"
484
- placeholder="Type the missing word"
485
- value={inputValue}
486
- onChange={(e) =>
487
- setInputValue(e.target.value)
488
- }
489
- style={styles.gmInput}
490
- />
491
- <button
492
- style={{
493
- ...styles.gmButton,
494
- marginLeft: 8,
495
- }}
496
- onClick={checkAnswer}
497
- disabled={animating}
498
- >
499
- {animating ? "..." : "Check"}
500
- </button>
501
- </div>
502
- )}
503
- {answered && (
504
- <button
505
- style={styles.gmButton}
506
- onClick={nextRound}
374
+ {phase !== "ready" && (
375
+ <div
376
+ style={{
377
+ ...styles.gmGrid,
378
+ gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(3, 210px)",
379
+ gridAutoRows: isMobile ? "150px" : "210px",
380
+ gap: isMobile ? "12px" : "20px",
381
+ justifyItems: "center",
382
+ }}
507
383
  >
508
- Next round
509
- </button>
384
+ {files.map((file, i) => {
385
+ const isHidden = hiddenIndex === i && phase === "guess" && !answered;
386
+ return (
387
+ <div
388
+ key={i}
389
+ style={{
390
+ ...styles.gmCard,
391
+ ...(result === "correct" && hiddenIndex === i ? styles.gmCorrect : {}),
392
+ ...(result === "wrong" && hiddenIndex === i ? styles.gmWrong : {}),
393
+ }}
394
+ >
395
+ {!isHidden && (
396
+ <img
397
+ src={file.src}
398
+ alt={file.name}
399
+ style={{
400
+ width: "100%",
401
+ height: "100%",
402
+ objectFit: "cover",
403
+ }}
404
+ />
405
+ )}
406
+ </div>
407
+ );
408
+ })}
409
+ </div>
510
410
  )}
411
+
412
+ <div style={{ marginTop: 16, height: 80 }}>
413
+ {phase === "guess" && !answered && (
414
+ <div>
415
+ <input
416
+ type="text"
417
+ placeholder="Type the missing word"
418
+ value={inputValue}
419
+ onChange={(e) => setInputValue(e.target.value)}
420
+ style={styles.gmInput}
421
+ />
422
+ <button style={{ ...styles.gmButton, marginLeft: 8 }} onClick={checkAnswer} disabled={animating}>
423
+ {animating ? "..." : "Check"}
424
+ </button>
425
+ </div>
426
+ )}
427
+ {answered && <button style={styles.gmButton} onClick={nextRound}>Next round</button>}
428
+ </div>
511
429
  </div>
512
- </div>
513
- )}
430
+ )}
431
+ </div>
514
432
  </div>
515
433
  </div>
516
434
  </div>