diff --git a/src/components/StaticBoardPreview.tsx b/src/components/StaticBoardPreview.tsx new file mode 100644 index 0000000..72e54a4 --- /dev/null +++ b/src/components/StaticBoardPreview.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from "react"; +import { Application, Container, Sprite, Texture } from "pixi.js"; +import type { ServerGame, ClientGame } from "../../shared/game"; +import { useTheme } from "../themes/useTheme"; +import { themes } from "../themes"; +import { getValue, isServerGame } from "../../shared/game"; +import type { LoadedTexture, LoadedTheme } from "../themes/Theme"; +import { weightedPickRandom } from "../../shared/utils"; +import gen from "random-seed"; + +// Cache for static board previews with size limit +const MAX_CACHE_SIZE = 250; +const previewCache = new Map(); + +// Helper function to manage cache size +const addToCache = (key: string, value: string) => { + // If cache is at max size, remove the oldest entry + if (previewCache.size >= MAX_CACHE_SIZE) { + const firstKey = previewCache.keys().next().value; + if (firstKey) { + previewCache.delete(firstKey); + } + } + previewCache.set(key, value); +}; + +interface StaticBoardPreviewProps { + game: ServerGame | ClientGame; + width: number; + height: number; + className?: string; +} + +const StaticBoardPreview: React.FC = ({ + game, + width, + height, + className, +}) => { + const [imageUrl, setImageUrl] = useState(null); + const theme = useTheme(themes.find((t) => t.id === game.theme)!.theme); + + useEffect(() => { + if (!theme) return; + + // Generate a cache key based on game state and dimensions + const cacheKey = `${game.theme}-${game.uuid}-${width}x${height}`; + + // Check if we have a cached image + const cachedImage = previewCache.get(cacheKey); + if (cachedImage) { + setImageUrl(cachedImage); + return; + } + + const generateStaticImage = async () => { + // Create a temporary Pixi application for rendering + const app = new Application(); + await app.init({ + width, + height, + backgroundAlpha: 0, + preference: "webgl", + }); + + const container = new Container(); + app.stage.addChild(container); + + // Helper function to resolve sprites + const resolveSprite = (lt: LoadedTexture, x: number, y: number) => { + if (Array.isArray(lt)) { + const rng = gen.create(game.uuid + ";" + x + ";" + y); + return weightedPickRandom( + lt, + (i) => i.weight, + (tw) => rng.floatBetween(0, tw), + ).sprite; + } + return lt; + }; + + // Render all tiles + for (let i = 0; i < game.width; i++) { + for (let j = 0; j < game.height; j++) { + const isRevealed = game.isRevealed[i][j]; + const value = isServerGame(game) + ? getValue(game.mines, i, j) + : game.values[i][j]; + const isMine = isServerGame(game) ? game.mines[i][j] : false; + const isFlagged = game.isFlagged[i][j]; + const isQuestionMark = game.isQuestionMark[i][j]; + + // Base tile + const baseTexture = + isRevealed || (isMine && !isFlagged) + ? resolveSprite(theme.revealed, i, j) + : resolveSprite(theme.tile, i, j); + + const baseSprite = new Sprite(baseTexture); + baseSprite.x = i * theme.size; + baseSprite.y = j * theme.size; + container.addChild(baseSprite); + + // Content overlay + let contentTexture: Texture | null = null; + if (isFlagged) { + contentTexture = resolveSprite(theme.flag, i, j); + } else if (isMine) { + contentTexture = resolveSprite(theme.mine, i, j); + } else if (value !== -1 && isRevealed) { + const numberTexture = theme[ + value.toString() as keyof LoadedTheme + ] as Texture; + if (numberTexture) { + contentTexture = numberTexture; + } + } else if (isQuestionMark) { + contentTexture = resolveSprite(theme.questionMark, i, j); + } + + if (contentTexture) { + const contentSprite = new Sprite(contentTexture); + contentSprite.x = i * theme.size + theme.size * 0.5; + contentSprite.y = j * theme.size + theme.size * 0.5; + contentSprite.anchor.set(0.5); + container.addChild(contentSprite); + } + } + } + + // Render to canvas and create data URL + app.renderer.render(container); + const canvas = app.canvas; + const dataUrl = canvas.toDataURL(); + + // Cache the generated image + addToCache(cacheKey, dataUrl); + setImageUrl(dataUrl); + + // Clean up + app.destroy(true); + }; + + generateStaticImage().catch(console.error); + }, [game, theme, width, height]); + + if (!imageUrl) { + return ( +
+ ); + } + + return ( + Board preview + ); +}; + +export default StaticBoardPreview; + diff --git a/src/views/collection/Collection.tsx b/src/views/collection/Collection.tsx index c865c66..47f6c8e 100644 --- a/src/views/collection/Collection.tsx +++ b/src/views/collection/Collection.tsx @@ -1,6 +1,7 @@ import { Ellipsis } from "lucide-react"; import { testBoard } from "../../../shared/testBoard"; import { Board } from "../../components/LazyBoard"; +import StaticBoardPreview from "../../components/StaticBoardPreview"; import { Button } from "../../components/Button"; import { themes } from "../../themes"; import { @@ -11,7 +12,7 @@ import { } from "../../components/DropdownMenu"; import { cn } from "../../lib/utils"; import { useWSMutation, useWSQuery } from "../../hooks"; -import { Suspense } from "react"; +import { Suspense, useState } from "react"; const Collection = () => { const { data: collection, refetch } = useWSQuery( @@ -20,6 +21,7 @@ const Collection = () => { ); const mutateSelected = useWSMutation("user.selectCollectionEntry"); const mutateShuffle = useWSMutation("user.addCollectionEntryToShuffle"); + const [hoveredTheme, setHoveredTheme] = useState(null); return (
@@ -76,19 +78,35 @@ const Collection = () => { )}
- - {}} - restartGame={() => {}} - onRightClick={() => {}} - width={11 * 32} - height={4 * 32} - className={cn( - selected && "outline-primary outline-4 rounded-md", - )} - /> - +
setHoveredTheme(theme.id)} + onMouseLeave={() => setHoveredTheme(null)} + > + {hoveredTheme === theme.id ? ( + + {}} + restartGame={() => {}} + onRightClick={() => {}} + width={11 * 32} + height={4 * 32} + className={cn( + selected && "outline-primary outline-4 rounded-md", + )} + /> + + ) : ( + + )} +
); })}