Compare commits

..

No commits in common. "52ee48bd862af549ae28903e1acca1878469ee7f" and "136612da24c8514f15e32d907eec5061c98bfe29" have entirely different histories.

12 changed files with 177 additions and 375 deletions

View File

@ -18,12 +18,13 @@ export type Endpoint<TInputSchema extends ZodType, TResponse> = {
) => Promise<TResponse>; ) => Promise<TResponse>;
}; };
export const createEndpoint = <TInputSchema extends ZodType, TResponse>( export const createEndpoint = <
TInputSchema extends ZodType,
TResponse,
TInput = z.infer<TInputSchema>,
>(
validate: TInputSchema, validate: TInputSchema,
handler: ( handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
input: z.infer<TInputSchema>,
context: RequestContext,
) => Promise<TResponse>,
): Endpoint<TInputSchema, TResponse> => { ): Endpoint<TInputSchema, TResponse> => {
return { validate, handler }; return { validate, handler };
}; };

BIN
bun.lockb

Binary file not shown.

View File

@ -28,7 +28,6 @@ export default [
"react/react-in-jsx-scope": "off", "react/react-in-jsx-scope": "off",
"react/prop-types": "off", "react/prop-types": "off",
"react/display-name": "off", "react/display-name": "off",
"react/no-unknown-property": "off",
}, },
}, },
{ {

View File

@ -15,60 +15,60 @@
"nukedb": "rm sqlite.db && bun run backend/migrate.ts" "nukedb": "rm sqlite.db && bun run backend/migrate.ts"
}, },
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.2", "@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/canvas-display": "^7.4.3", "@pixi/canvas-display": "^7.4.2",
"@pixi/canvas-renderer": "^7.4.3", "@pixi/canvas-renderer": "^7.4.2",
"@pixi/react": "^8.0.3", "@pixi/react": "^7.1.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.87.4", "@tanstack/react-query": "^5.59.11",
"@tanstack/react-query-devtools": "^5.87.4", "@tanstack/react-query-devtools": "^5.59.11",
"@tsparticles/engine": "^3.9.1", "@tsparticles/engine": "^3.5.0",
"@tsparticles/preset-sea-anemone": "^3.2.0", "@tsparticles/preset-sea-anemone": "^3.1.0",
"@tsparticles/react": "^3.0.0", "@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.9.1", "@tsparticles/slim": "^3.5.0",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.18", "dayjs": "^1.11.13",
"drizzle-orm": "0.44.5", "drizzle-orm": "0.33.0",
"jotai": "^2.14.0", "jotai": "^2.10.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.452.0",
"motion": "^12.23.12", "motion": "^12.18.1",
"pixi-viewport": "^6.0.3", "pixi-viewport": "^5.0.3",
"pixi.js": "^8.13.2", "pixi.js": "^7.0.0",
"pixi.js-legacy": "^7.4.3", "pixi.js-legacy": "^7.4.2",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"random-seed": "^0.3.0", "random-seed": "^0.3.0",
"react": "^19.1.1", "react": "^18.3.1",
"react-confetti-boom": "^2.0.1", "react-confetti-boom": "^1.0.0",
"react-dom": "^19.1.1", "react-dom": "^18.3.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^2.5.3",
"wouter": "^3.7.1", "wouter": "^3.3.5",
"zod": "^4.1.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.3.2", "@eslint/compat": "^1.2.0",
"@eslint/js": "^9.35.0", "@eslint/js": "^9.12.0",
"@tailwindcss/vite": "next", "@tailwindcss/vite": "next",
"@types/bun": "latest", "@types/bun": "latest",
"@types/random-seed": "^0.3.5", "@types/random-seed": "^0.3.5",
"@types/react": "^19.1.13", "@types/react": "^18.3.11",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^4.0.1", "@vitejs/plugin-react-swc": "^3.7.1",
"drizzle-kit": "0.31.4", "drizzle-kit": "0.24.2",
"eslint": "^9.35.0", "eslint": "^9.12.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-hooks": "5.0.0",
"globals": "^16.4.0", "globals": "^15.11.0",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.0.0-alpha.26",
"typescript": "^5.9.2", "typescript": "^5.6.3",
"typescript-eslint": "^8.44.0", "typescript-eslint": "^8.8.1",
"vite": "^7.1.5", "vite": "^5.4.8",
"vite-bundle-analyzer": "^1.2.3", "vite-bundle-analyzer": "^0.22.3",
"vite-imagetools": "^8.0.0" "vite-imagetools": "^7.0.4"
} }
} }

View File

@ -12,10 +12,9 @@ import {
type Theme, type Theme,
} from "../themes/Theme"; } from "../themes/Theme";
import { useTheme } from "../themes/useTheme"; import { useTheme } from "../themes/useTheme";
import { extend, useTick, Application } from "@pixi/react"; import { Container, Sprite, Stage, useTick } from "@pixi/react";
import Viewport from "./pixi/PixiViewport"; import Viewport from "./pixi/PixiViewport";
import type { Viewport as PixiViewport } from "pixi-viewport"; import type { Viewport as PixiViewport } from "pixi-viewport";
import { Container, FederatedPointerEvent, Sprite, Texture } from "pixi.js";
import { import {
type ClientGame, type ClientGame,
getValue, getValue,
@ -23,6 +22,7 @@ import {
type ServerGame, type ServerGame,
} from "../../shared/game"; } from "../../shared/game";
import { useWSQuery } from "../hooks"; import { useWSQuery } from "../hooks";
import { Texture } from "pixi.js";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { cursorXAtom, cursorYAtom } from "../atoms"; import { cursorXAtom, cursorYAtom } from "../atoms";
import Coords from "./Coords"; import Coords from "./Coords";
@ -46,11 +46,6 @@ import { weightedPickRandom } from "../../shared/utils";
import gen from "random-seed"; import gen from "random-seed";
import type { UserSettings } from "../../shared/user-settings"; import type { UserSettings } from "../../shared/user-settings";
extend({
Container,
Sprite,
});
interface BoardProps { interface BoardProps {
className?: string; className?: string;
game: ServerGame | ClientGame; game: ServerGame | ClientGame;
@ -162,26 +157,6 @@ const Board: React.FC<BoardProps> = (props) => {
}; };
}, []); }, []);
const clamp = useMemo(
() =>
theme &&
(props.width || props.height
? { left: 0, right: boardWidth, top: 0, bottom: boardHeight }
: {
left: -theme.size,
right: boardWidth + theme.size,
top: -theme.size,
bottom: boardHeight + theme.size,
}),
[boardHeight, boardWidth, props.height, props.width, theme],
);
const clampZoom = useMemo(
() => ({
minScale: 1,
}),
[],
);
return ( return (
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div <div
@ -224,13 +199,11 @@ const Board: React.FC<BoardProps> = (props) => {
)} )}
</div> </div>
{theme && ( {theme && (
<Application <Stage
hello options={{ hello: true, forceCanvas: !!props.width }}
forceFallbackAdapter={!!props.width}
width={width} width={width}
height={height} height={height}
className="select-none" className="select-none"
preference="webgl"
> >
<Viewport <Viewport
viewportRef={viewportRef} viewportRef={viewportRef}
@ -238,8 +211,19 @@ const Board: React.FC<BoardProps> = (props) => {
worldHeight={boardHeight} worldHeight={boardHeight}
width={width} width={width}
height={height} height={height}
clamp={clamp} clamp={
clampZoom={clampZoom} props.width || props.height
? { left: 0, right: boardWidth, top: 0, bottom: boardHeight }
: {
left: -theme.size,
right: boardWidth + theme.size,
top: -theme.size,
bottom: boardHeight + theme.size,
}
}
clampZoom={{
minScale: 1,
}}
onViewportChange={onViewportChange} onViewportChange={onViewportChange}
> >
{Array.from({ length: game.width }).map((_, i) => { {Array.from({ length: game.width }).map((_, i) => {
@ -270,7 +254,7 @@ const Board: React.FC<BoardProps> = (props) => {
}); });
})} })}
</Viewport> </Viewport>
</Application> </Stage>
)} )}
</div> </div>
{!props.width && !props.height && <Coords />} {!props.width && !props.height && <Coords />}
@ -327,14 +311,13 @@ const Tile = ({
const isQuestionMark = game.isQuestionMark[i][j]; const isQuestionMark = game.isQuestionMark[i][j];
const base = const base =
isRevealed || (isMine && !isFlagged) ? ( isRevealed || (isMine && !isFlagged) ? (
<pixiSprite key="b" texture={resolveSprite(theme.revealed)} /> <Sprite key="b" texture={resolveSprite(theme.revealed)} />
) : ( ) : (
<pixiSprite key="b" texture={resolveSprite(theme.tile)} /> <Sprite key="b" texture={resolveSprite(theme.tile)} />
); );
const extra = isLastPos ? ( const extra = isLastPos ? (
<pixiSprite key="e" texture={resolveSprite(theme.lastPos)} /> <Sprite key="e" texture={resolveSprite(theme.lastPos)} />
) : null; ) : null;
const touchStart = useRef<number>(0); const touchStart = useRef<number>(0);
const isMove = useRef<boolean>(false); const isMove = useRef<boolean>(false);
const startX = useRef<number>(0); const startX = useRef<number>(0);
@ -351,16 +334,13 @@ const Tile = ({
setDoTick(true); setDoTick(true);
} }
}, [isMine, isRevealed, userSettings?.showRevealAnimation, value]); }, [isMine, isRevealed, userSettings?.showRevealAnimation, value]);
useTick({ useTick((delta) => {
callback: (delta) => { frame.current += delta * 0.1;
frame.current += delta.count * 0.1; if (frame.current > 3) {
if (frame.current > 3) { setDoTick(false);
setDoTick(false); }
} setScale(Math.max(1, -2 * Math.pow(frame.current - 0.5, 2) + 1.2));
setScale(Math.max(1, -2 * Math.pow(frame.current - 0.5, 2) + 1.2)); }, doTick);
},
isEnabled: doTick,
});
const baseProps = useMemo( const baseProps = useMemo(
() => ({ () => ({
scale, scale,
@ -373,18 +353,18 @@ const Tile = ({
let content: ReactNode = null; let content: ReactNode = null;
if (isFlagged) { if (isFlagged) {
content = ( content = (
<pixiSprite key="c" texture={resolveSprite(theme.flag)} {...baseProps} /> <Sprite key="c" texture={resolveSprite(theme.flag)} {...baseProps} />
); );
} else if (isMine) { } else if (isMine) {
content = ( content = (
<pixiSprite key="c" texture={resolveSprite(theme.mine)} {...baseProps} /> <Sprite key="c" texture={resolveSprite(theme.mine)} {...baseProps} />
); );
} else if (value !== -1 && isRevealed) { } else if (value !== -1 && isRevealed) {
const img = theme[value.toString() as keyof Theme] as Texture; const img = theme[value.toString() as keyof Theme] as Texture;
content = img ? <pixiSprite key="c" texture={img} {...baseProps} /> : null; content = img ? <Sprite key="c" texture={img} {...baseProps} /> : null;
} else if (isQuestionMark) { } else if (isQuestionMark) {
content = ( content = (
<pixiSprite <Sprite
key="c" key="c"
texture={resolveSprite(theme.questionMark)} texture={resolveSprite(theme.questionMark)}
{...baseProps} {...baseProps}
@ -395,16 +375,16 @@ const Tile = ({
const [, setCursorY] = useAtom(cursorYAtom); const [, setCursorY] = useAtom(cursorYAtom);
return ( return (
<pixiContainer <Container
eventMode="static" eventMode="static"
interactive interactive
x={i * theme.size} x={i * theme.size}
y={j * theme.size} y={j * theme.size}
key={`${i},${j}`} key={`${i},${j}`}
onRightUp={() => { onrightup={() => {
onRightClick(i, j); onRightClick(i, j);
}} }}
onPointerUp={(e: FederatedPointerEvent) => { onpointerup={(e) => {
if (e.button !== 0) return; if (e.button !== 0) return;
if (isMove.current) return; if (isMove.current) return;
if (Date.now() - touchStart.current > 300) { if (Date.now() - touchStart.current > 300) {
@ -413,17 +393,17 @@ const Tile = ({
onLeftClick(i, j); onLeftClick(i, j);
} }
}} }}
onPointerDown={(e: FederatedPointerEvent) => { onpointerdown={(e) => {
isMove.current = false; isMove.current = false;
touchStart.current = Date.now(); touchStart.current = Date.now();
startX.current = e.global.x; startX.current = e.global.x;
startY.current = e.global.y; startY.current = e.global.y;
}} }}
onPointerEnter={() => { onpointerenter={() => {
setCursorX(i); setCursorX(i);
setCursorY(j); setCursorY(j);
}} }}
onPointerMove={(e: FederatedPointerEvent) => { onpointermove={(e) => {
if ( if (
Math.abs(startX.current - e.global.x) > 10 || Math.abs(startX.current - e.global.x) > 10 ||
Math.abs(startY.current - e.global.y) > 10 Math.abs(startY.current - e.global.y) > 10
@ -435,7 +415,7 @@ const Tile = ({
{base} {base}
{content} {content}
{extra} {extra}
</pixiContainer> </Container>
); );
}; };

View File

@ -1,167 +0,0 @@
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<string, string>();
// 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<StaticBoardPreviewProps> = ({
game,
width,
height,
className,
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(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 (
<div
className={className}
style={{ width, height, backgroundColor: "#1a1a1a" }}
/>
);
}
return (
<img
src={imageUrl}
alt="Board preview"
className={className}
style={{ width, height, imageRendering: "pixelated" }}
/>
);
};
export default StaticBoardPreview;

View File

@ -1,18 +1,14 @@
import React, { useLayoutEffect } from "react"; import React from "react";
import { type IClampZoomOptions, Viewport } from "pixi-viewport"; import type { Application } from "pixi.js";
import { extend, useApplication } from "@pixi/react"; import {
type IClampZoomOptions,
Viewport as PixiViewport,
} from "pixi-viewport";
import { PixiComponent, useApp } from "@pixi/react";
import { BaseTexture, SCALE_MODES } from "pixi.js";
BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST;
extend({ Viewport }); export interface ViewportProps {
import { type PixiReactElementProps } from "@pixi/react";
declare module "@pixi/react" {
interface PixiElements {
pixiViewport: PixiReactElementProps<typeof Viewport>;
}
}
interface ViewportProps {
width: number; width: number;
height: number; height: number;
worldWidth: number; worldWidth: number;
@ -25,18 +21,26 @@ interface ViewportProps {
bottom: number; bottom: number;
}; };
clampZoom?: IClampZoomOptions; clampZoom?: IClampZoomOptions;
onViewportChange?: (viewport: Viewport) => void; onViewportChange?: (viewport: PixiViewport) => void;
viewportRef?: React.RefObject<Viewport | null>; viewportRef?: React.RefObject<PixiViewport>;
} }
const PixiViewport = (props: ViewportProps) => { export interface PixiComponentViewportProps extends ViewportProps {
const { app } = useApplication(); app: Application;
const ref = React.useRef<Viewport | null>(null); }
const { clamp, clampZoom, onViewportChange, viewportRef } = props;
useLayoutEffect(() => { const PixiComponentViewport = PixiComponent("Viewport", {
void app.renderer; create: (props: PixiComponentViewportProps) => {
const viewport = ref.current; const viewport = new PixiViewport({
if (!viewport) return () => {}; screenWidth: props.width,
screenHeight: props.height,
worldWidth: props.worldWidth,
worldHeight: props.worldHeight,
ticker: props.app.ticker,
events: props.app.renderer.events,
disableOnContextMenu: true,
allowPreserveDragOutside: true,
});
viewport viewport
.drag({ .drag({
ignoreKeyToPressOnTouch: true, ignoreKeyToPressOnTouch: true,
@ -44,43 +48,53 @@ const PixiViewport = (props: ViewportProps) => {
}) })
.pinch() .pinch()
.wheel(); .wheel();
if (clamp) { if (props.clamp) {
viewport.clamp(clamp); viewport.clamp(props.clamp);
} }
if (clampZoom) { if (props.clampZoom) {
viewport.clampZoom(clampZoom); viewport.clampZoom(props.clampZoom);
} }
viewport.on("moved", () => { viewport.on("moved", () => {
onViewportChange?.(viewport); props.onViewportChange?.(viewport);
}); });
viewport.on("zoomed-end", () => { viewport.on("zoomed-end", () => {
onViewportChange?.(viewport); props.onViewportChange?.(viewport);
}); });
if (viewportRef) { if (props.viewportRef) {
viewportRef.current = viewport; // @ts-expect-error We dont care since this is internal api
props.viewportRef.current = viewport;
} }
return () => {
viewport.off("moved"); return viewport;
viewport.off("zoomed-end"); },
}; applyProps: (
}, [clamp, clampZoom, onViewportChange, viewportRef, app.renderer]); viewport: PixiViewport,
if (!app.renderer) return null; oldProps: ViewportProps,
return ( newProps: ViewportProps,
<pixiViewport ) => {
ref={ref} if (
screenWidth={props.width} oldProps.width !== newProps.width ||
screenHeight={props.height} oldProps.height !== newProps.height ||
worldWidth={props.worldWidth} oldProps.worldWidth !== newProps.worldWidth ||
worldHeight={props.worldHeight} oldProps.worldHeight !== newProps.worldHeight
ticker={app.ticker} ) {
events={app.renderer.events} viewport.resize(
disableOnContextMenu newProps.width,
allowPreserveDragOutside newProps.height,
> newProps.worldWidth,
{props.children} newProps.worldHeight,
</pixiViewport> );
); }
if (oldProps.clamp !== newProps.clamp) {
viewport.clamp(newProps.clamp);
}
},
});
const Viewport = (props: ViewportProps) => {
const app = useApp();
return <PixiComponentViewport app={app} {...props} />;
}; };
export default PixiViewport; export default Viewport;

View File

@ -23,7 +23,6 @@ export const useWSQuery = <
// @ts-expect-error We dont care since this is internal api // @ts-expect-error We dont care since this is internal api
Awaited<ReturnType<Routes[TController][TAction]["handler"]>> Awaited<ReturnType<Routes[TController][TAction]["handler"]>>
> => { > => {
// @ts-expect-error We dont care since this is internal api
return useQuery({ return useQuery({
queryKey: [action, payload], queryKey: [action, payload],
queryFn: async () => { queryFn: async () => {

View File

@ -1,4 +1,4 @@
import { Assets, Texture } from "pixi.js"; import { Assets } from "pixi.js";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { Theme, LoadedTheme, WeightedLazySprites } from "./Theme"; import type { Theme, LoadedTheme, WeightedLazySprites } from "./Theme";
@ -12,20 +12,14 @@ export const useTheme = (theme: Theme) => {
Object.entries(theme).map(async ([key, value]) => { Object.entries(theme).map(async ([key, value]) => {
let loaded = value; let loaded = value;
if (typeof value === "function") { if (typeof value === "function") {
const texture = await Assets.load<Texture>((await value()).default); loaded = await Assets.load((await value()).default);
texture.source.scaleMode = "nearest";
loaded = texture;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
loaded = await Promise.all( loaded = await Promise.all(
loaded.map(async (sprite: WeightedLazySprites) => { loaded.map(async (sprite: WeightedLazySprites) => {
const texture = await Assets.load<Texture>(
(await sprite.sprite()).default,
);
texture.source.scaleMode = "nearest";
return { return {
weight: sprite.weight, weight: sprite.weight,
sprite: texture, sprite: await Assets.load((await sprite.sprite()).default),
}; };
}), }),
); );

View File

@ -1,7 +1,6 @@
import { Ellipsis } from "lucide-react"; import { Ellipsis } from "lucide-react";
import { testBoard } from "../../../shared/testBoard"; import { testBoard } from "../../../shared/testBoard";
import { Board } from "../../components/LazyBoard"; import { Board } from "../../components/LazyBoard";
import StaticBoardPreview from "../../components/StaticBoardPreview";
import { Button } from "../../components/Button"; import { Button } from "../../components/Button";
import { themes } from "../../themes"; import { themes } from "../../themes";
import { import {
@ -12,7 +11,7 @@ import {
} from "../../components/DropdownMenu"; } from "../../components/DropdownMenu";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useWSMutation, useWSQuery } from "../../hooks"; import { useWSMutation, useWSQuery } from "../../hooks";
import { Suspense, useState } from "react"; import { Suspense } from "react";
const Collection = () => { const Collection = () => {
const { data: collection, refetch } = useWSQuery( const { data: collection, refetch } = useWSQuery(
@ -21,7 +20,6 @@ const Collection = () => {
); );
const mutateSelected = useWSMutation("user.selectCollectionEntry"); const mutateSelected = useWSMutation("user.selectCollectionEntry");
const mutateShuffle = useWSMutation("user.addCollectionEntryToShuffle"); const mutateShuffle = useWSMutation("user.addCollectionEntryToShuffle");
const [hoveredTheme, setHoveredTheme] = useState<string | null>(null);
return ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
@ -78,35 +76,19 @@ const Collection = () => {
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
<div <Suspense>
onMouseEnter={() => setHoveredTheme(theme.id)} <Board
onMouseLeave={() => setHoveredTheme(null)} game={testBoard(theme.id)}
> onLeftClick={() => {}}
{hoveredTheme === theme.id ? ( restartGame={() => {}}
<Suspense> onRightClick={() => {}}
<Board width={11 * 32}
game={testBoard(theme.id)} height={4 * 32}
onLeftClick={() => {}} className={cn(
restartGame={() => {}} selected && "outline-primary outline-4 rounded-md",
onRightClick={() => {}} )}
width={11 * 32} />
height={4 * 32} </Suspense>
className={cn(
selected && "outline-primary outline-4 rounded-md",
)}
/>
</Suspense>
) : (
<StaticBoardPreview
game={testBoard(theme.id)}
width={11 * 32}
height={4 * 32}
className={cn(
selected && "outline-primary outline-4 rounded-md",
)}
/>
)}
</div>
</div> </div>
); );
})} })}

View File

@ -20,7 +20,7 @@ const TouchTooltip = ({
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false); const [isTouchDevice, setIsTouchDevice] = useState(false);
const timeoutRef = useRef<Timer | null>(null); const timeoutRef = useRef<Timer>();
useEffect(() => { useEffect(() => {
// Detect if device supports touch // Detect if device supports touch

View File

@ -146,7 +146,7 @@ const createWSClient = () => {
>( >(
action: `${TController}.${TAction}`, action: `${TController}.${TAction}`,
// @ts-expect-error We dont care since this is internal api // @ts-expect-error We dont care since this is internal api
payload: z.input<Routes[TController][TAction]["validate"]>, payload: Routes[TController][TAction]["validate"]["_input"],
// @ts-expect-error We dont care since this is internal api // @ts-expect-error We dont care since this is internal api
): Promise<Awaited<ReturnType<Routes[TController][TAction]["handler"]>>> => { ): Promise<Awaited<ReturnType<Routes[TController][TAction]["handler"]>>> => {
if (ws.readyState !== WebSocket.OPEN) { if (ws.readyState !== WebSocket.OPEN) {