@mblinkov/whats-missing 20.0.47 → 20.0.49

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.47",
3
+ "version": "20.0.49",
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
@@ -69,6 +69,27 @@ const levenshtein = (a: string, b: string) => {
69
69
  return dp[a.length][b.length];
70
70
  };
71
71
 
72
+ // ✅ Preload utilities
73
+ const preloadImage = (src: string): Promise<HTMLImageElement> => {
74
+ return new Promise((resolve) => {
75
+ // ✅ Проверяем, не загружена ли уже картинка
76
+ const img = new Image();
77
+ img.onload = () => resolve(img);
78
+ img.onerror = () => resolve(img); // Continue even on error, но img все равно создан
79
+ img.src = src;
80
+ });
81
+ };
82
+
83
+ const preloadImages = async (urls: string[], signal?: AbortSignal): Promise<Map<string, HTMLImageElement>> => {
84
+ const imageMap = new Map<string, HTMLImageElement>();
85
+ const promises = urls.map(async (url) => {
86
+ const img = await preloadImage(url);
87
+ imageMap.set(url, img);
88
+ });
89
+ await Promise.allSettled(promises);
90
+ return imageMap;
91
+ };
92
+
72
93
  export default function Game({
73
94
  gameCubeSize,
74
95
  screenHeight,
@@ -92,6 +113,11 @@ export default function Game({
92
113
  if (style) {
93
114
  style.remove();
94
115
  }
116
+
117
+ // ✅ Cancel any ongoing preloads
118
+ if (abortController.current) {
119
+ abortController.current.abort();
120
+ }
95
121
  };
96
122
  }, []);
97
123
 
@@ -102,7 +128,7 @@ export default function Game({
102
128
  const [currentRound, setCurrentRound] = useState(1);
103
129
  const [hiddenIndex, setHiddenIndex] = useState<number | null>(null);
104
130
  const [phase, setPhase] = useState<"ready" | "memorize" | "guess">("ready");
105
- const [readyTime, setReadyTime] = useState(3);
131
+ const [readyTime, setReadyTime] = useState(10);
106
132
  const [memorizeTime, setMemorizeTime] = useState(10);
107
133
  const [timeLeft, setTimeLeft] = useState(20);
108
134
  const [score, setScore] = useState(0);
@@ -126,6 +152,12 @@ export default function Game({
126
152
  const [isIPadProPortrait, setIsIPadProPortrait] = useState(false);
127
153
  const [isIPadProLandscape, setIsIPadProLandscape] = useState(false);
128
154
  const [isHorizontalLayout, setIsHorizontalLayout] = useState(false);
155
+ const [isWideScreen, setIsWideScreen] = useState(false);
156
+
157
+ // ✅ Система предзагрузки изображений
158
+ const preloadedUrls = useRef<Set<string>>(new Set());
159
+ const preloadedImages = useRef<Map<string, HTMLImageElement>>(new Map());
160
+ const abortController = useRef<AbortController | null>(null);
129
161
 
130
162
  // ✅ адаптив под мобилки, планшеты и десктоп
131
163
  useEffect(() => {
@@ -135,6 +167,7 @@ export default function Game({
135
167
  const mobile = width < 768 || (width === 926 && height === 428) || (width === 932 && height === 430); // iPhone 14 Pro Max в landscape тоже считается мобильным
136
168
  const isLandscape = (width > height && mobile) || (width === 926 && height === 428) || (width === 932 && height === 430); // iPhone 14 Pro Max в landscape
137
169
  const isSmallHeight = height < 700; // Nest Hub, маленькие экраны
170
+ const isWideScreen = width / height > 1.8; // ✅ Широкие экраны
138
171
 
139
172
  // Определяем iPhone до 14 Pro Max в landscape режиме
140
173
  // iPhone 14 Pro Max: 926x428 (landscape)
@@ -201,7 +234,9 @@ export default function Game({
201
234
  // ✅ Вычисляем горизонтальный layout ОДИН РАЗ
202
235
  const isHorizontal =
203
236
  (mobile && width > height) ||
237
+ mobile || // ✅ ВСЕ мобильные устройства (включая portrait)
204
238
  height < 700 ||
239
+ isWideScreen || // ✅ Широкие экраны
205
240
  (width === 1366 && height === 766) ||
206
241
  (width === 1366 && height === 768) ||
207
242
  (width === 1280 && height === 720) ||
@@ -231,6 +266,15 @@ export default function Game({
231
266
  // Маленькие экраны: используем переданный gameCubeSize или полную высоту
232
267
  setContainerSize(gameCubeSize && gameCubeSize >= 400 ? gameCubeSize : null);
233
268
  setScale(1);
269
+ } else if (isWideScreen) {
270
+ // ✅ Широкие экраны: используем стандартный размер но уменьшаем масштаб картинок
271
+ const minSize = 400;
272
+ const maxSize = 1200;
273
+ const finalSize = gameCubeSize
274
+ ? Math.max(minSize, Math.min(maxSize, gameCubeSize))
275
+ : Math.min(1000, Math.min(width, height) * 0.9);
276
+ setContainerSize(finalSize);
277
+ setScale(0.85); // Уменьшаем масштаб картинок на 15%
234
278
  } else {
235
279
  // Десктопы: используем переданный gameCubeSize с разумными ограничениями
236
280
  const minSize = 400;
@@ -250,6 +294,33 @@ export default function Game({
250
294
  const getRandomSix = (arr: ImageItem[]) =>
251
295
  [...arr].sort(() => Math.random() - 0.5).slice(0, 6);
252
296
 
297
+ // ✅ Функция предзагрузки темы
298
+ const startThemePreload = (themeName: Theme) => {
299
+ // Cancel previous preload
300
+ if (abortController.current) {
301
+ abortController.current.abort();
302
+ }
303
+
304
+ // Create new controller
305
+ abortController.current = new AbortController();
306
+
307
+ // Get theme images and start preloading
308
+ const themeImages = themes[themeName];
309
+ const urls = themeImages.map(item => item.src);
310
+
311
+ preloadImages(urls, abortController.current.signal).then((imageMap) => {
312
+ // Mark as preloaded
313
+ urls.forEach(url => {
314
+ preloadedUrls.current.add(url);
315
+ if (imageMap.has(url)) {
316
+ preloadedImages.current.set(url, imageMap.get(url)!);
317
+ }
318
+ });
319
+ }).catch(() => {
320
+ // Ignore abort errors
321
+ });
322
+ };
323
+
253
324
  const startGame = () => {
254
325
  if (!theme) return;
255
326
  const selected = getRandomSix(themes[theme]);
@@ -260,6 +331,20 @@ export default function Game({
260
331
  setScore(0);
261
332
  setResultsTable([]);
262
333
  setUsedHidden([]);
334
+
335
+ // ✅ Preload current round images
336
+ const urls = selected.map(item => item.src);
337
+ preloadImages(urls, abortController.current?.signal).then((imageMap) => {
338
+ urls.forEach(url => {
339
+ preloadedUrls.current.add(url);
340
+ if (imageMap.has(url)) {
341
+ preloadedImages.current.set(url, imageMap.get(url)!);
342
+ }
343
+ });
344
+ }).catch(() => {
345
+ // Ignore abort errors
346
+ });
347
+
263
348
  startRound(selected, [], true);
264
349
  };
265
350
 
@@ -280,9 +365,26 @@ export default function Game({
280
365
  setInputValue("");
281
366
  setTimeLeft(20);
282
367
  setResult(null);
368
+
369
+ // ✅ Preload next round images in background
370
+ if (theme && !isFirst) {
371
+ const nextPool = getRandomSix(themes[theme].filter(item => !used.includes(item.name)));
372
+ const nextUrls = nextPool.map(item => item.src);
373
+ preloadImages(nextUrls, abortController.current?.signal).then((imageMap) => {
374
+ nextUrls.forEach(url => {
375
+ preloadedUrls.current.add(url);
376
+ if (imageMap.has(url)) {
377
+ preloadedImages.current.set(url, imageMap.get(url)!);
378
+ }
379
+ });
380
+ }).catch(() => {
381
+ // Ignore abort errors
382
+ });
383
+ }
384
+
283
385
  if (isFirst) {
284
386
  setPhase("ready");
285
- setReadyTime(3);
387
+ setReadyTime(10); // Увеличено до 10 секунд для VPN/медленных соединений
286
388
  setMemorizeTime(10);
287
389
  } else {
288
390
  setPhase("memorize");
@@ -351,6 +453,11 @@ export default function Game({
351
453
  currentRound < rounds ? (setCurrentRound((r) => r + 1), startRound()) : setFinished(true);
352
454
 
353
455
  const exitGame = () => {
456
+ // ✅ Cancel any ongoing preloads
457
+ if (abortController.current) {
458
+ abortController.current.abort();
459
+ }
460
+
354
461
  setStarted(false);
355
462
  setFinished(false);
356
463
  setTheme(null);
@@ -416,6 +523,7 @@ export default function Game({
416
523
  boxShadow: isMobile ? "none" : "0 0 40px rgba(0,0,0,0.1)",
417
524
  margin: isMobile ? "0 auto" : "unset",
418
525
  position: "relative", // needed so absolute logo is inside the square
526
+ transform: `scale(${scale})`, // ✅ Применяем масштаб для широких экранов
419
527
  }}
420
528
  >
421
529
  <div
@@ -442,7 +550,10 @@ export default function Game({
442
550
  padding: (isMobile && window.innerWidth > window.innerHeight) || (isMobile && window.innerWidth <= 375 && window.innerHeight <= 667) || (window.innerWidth === 896 && window.innerHeight === 414) || (window.innerWidth === 844 && window.innerHeight === 390) || (window.innerWidth === 926 && window.innerHeight === 428) || (window.innerWidth === 932 && window.innerHeight === 430) ? "8px 12px" : "12px 24px",
443
551
  fontSize: (isMobile && window.innerWidth > window.innerHeight) || (isMobile && window.innerWidth <= 375 && window.innerHeight <= 667) || (window.innerWidth === 896 && window.innerHeight === 414) || (window.innerWidth === 844 && window.innerHeight === 390) || (window.innerWidth === 926 && window.innerHeight === 428) || (window.innerWidth === 932 && window.innerHeight === 430) ? "12px" : "16px",
444
552
  minWidth: (isMobile && window.innerWidth > window.innerHeight) || (isMobile && window.innerWidth <= 375 && window.innerHeight <= 667) || (window.innerWidth === 896 && window.innerHeight === 414) || (window.innerWidth === 844 && window.innerHeight === 390) || (window.innerWidth === 926 && window.innerHeight === 428) || (window.innerWidth === 932 && window.innerHeight === 430) ? "70px" : "auto"
445
- }} onClick={() => setTheme(t as Theme)}>
553
+ }} onClick={() => {
554
+ setTheme(t as Theme);
555
+ startThemePreload(t as Theme);
556
+ }}>
446
557
  {t === "animals" ? "🐶 Animals" : t === "food" ? "🍎 Food" : "🧸 Toys"}
447
558
  </button>
448
559
  ))}
@@ -736,17 +847,30 @@ export default function Game({
736
847
  : {}),
737
848
  }}
738
849
  >
739
- {!isHidden && (
740
- <img
741
- src={file.src}
742
- alt={file.name}
743
- style={{
744
- width: "100%",
745
- height: "100%",
746
- objectFit: "cover",
747
- }}
748
- />
749
- )}
850
+ {!isHidden && (() => {
851
+ // ✅ Используем уже загруженный объект Image чтобы браузер взял из кеша
852
+ const preloadedImg = preloadedImages.current.get(file.src);
853
+ // Используем src из уже загруженного объекта или оригинальный URL
854
+ const imgSrc = preloadedImg?.complete && preloadedImg.src
855
+ ? preloadedImg.src
856
+ : file.src;
857
+
858
+ return (
859
+ <img
860
+ key={`${file.src}-${preloadedImg?.complete ? 'loaded' : 'pending'}`}
861
+ src={imgSrc}
862
+ alt={file.name}
863
+ fetchPriority="high"
864
+ loading="eager"
865
+ decoding="async"
866
+ style={{
867
+ width: "100%",
868
+ height: "100%",
869
+ objectFit: "cover",
870
+ }}
871
+ />
872
+ );
873
+ })()}
750
874
  </div>
751
875
  );
752
876
  })}