Compare commits
No commits in common. "52ee48bd862af549ae28903e1acca1878469ee7f" and "136612da24c8514f15e32d907eec5061c98bfe29" have entirely different histories.
52ee48bd86
...
136612da24
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
90
package.json
90
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue