added cases
This commit is contained in:
parent
5e30cee6ca
commit
157e4768f3
|
|
@ -11,11 +11,14 @@ import crypto from "crypto";
|
||||||
import { resetSessionUser, setSessionUser } from "../router";
|
import { resetSessionUser, setSessionUser } from "../router";
|
||||||
import { userSettings } from "../../shared/user-settings";
|
import { userSettings } from "../../shared/user-settings";
|
||||||
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
import { UnauthorizedError } from "../errors/UnauthorizedError";
|
||||||
import { getGems } from "../repositories/gemsRepository";
|
import { getGems, removeGems } from "../repositories/gemsRepository";
|
||||||
import {
|
import {
|
||||||
getCollection,
|
getCollection,
|
||||||
upsertCollection,
|
upsertCollection,
|
||||||
} from "../repositories/collectionRepository";
|
} from "../repositories/collectionRepository";
|
||||||
|
import { getWeight, lootboxes } from "../../shared/lootboxes";
|
||||||
|
import { weightedPickRandom } from "../../shared/utils";
|
||||||
|
import { emit } from "../events";
|
||||||
|
|
||||||
const secret = process.env.SECRET!;
|
const secret = process.env.SECRET!;
|
||||||
|
|
||||||
|
|
@ -133,4 +136,35 @@ export const userController = createController({
|
||||||
await upsertCollection(db, user, collection);
|
await upsertCollection(db, user, collection);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
openLootbox: createEndpoint(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
async ({ id }, { db, user }) => {
|
||||||
|
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||||
|
const collection = await getCollection(db, user);
|
||||||
|
const lootbox = lootboxes.find((l) => l.id === id);
|
||||||
|
if (!lootbox) {
|
||||||
|
throw new Error("Lootbox not found");
|
||||||
|
}
|
||||||
|
removeGems(db, user, lootbox.price);
|
||||||
|
const result = weightedPickRandom(lootbox.items, (i) =>
|
||||||
|
getWeight(i.rarity),
|
||||||
|
);
|
||||||
|
console.log(result);
|
||||||
|
collection.entries.push({
|
||||||
|
id: result.id,
|
||||||
|
aquired: Date.now(),
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
await upsertCollection(db, user, collection);
|
||||||
|
emit({
|
||||||
|
type: "lootboxPurchased",
|
||||||
|
user,
|
||||||
|
lootbox: lootbox.id,
|
||||||
|
reward: result.id,
|
||||||
|
rarity: result.rarity,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.1",
|
||||||
"@tanstack/react-query": "^5.59.11",
|
"@tanstack/react-query": "^5.59.11",
|
||||||
"@tanstack/react-query-devtools": "^5.59.11",
|
"@tanstack/react-query-devtools": "^5.59.11",
|
||||||
|
"@tsparticles/engine": "^3.5.0",
|
||||||
|
"@tsparticles/preset-sea-anemone": "^3.1.0",
|
||||||
|
"@tsparticles/react": "^3.0.0",
|
||||||
|
"@tsparticles/slim": "^3.5.0",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { Rarity } from "../shared/lootboxes";
|
||||||
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
|
export type EventType = "new" | "finished" | "updateGame" | "updateStage";
|
||||||
|
|
||||||
export type Events =
|
export type Events =
|
||||||
|
|
@ -25,4 +26,11 @@ export type Events =
|
||||||
type: "gemsRewarded";
|
type: "gemsRewarded";
|
||||||
stage: number;
|
stage: number;
|
||||||
gems: number;
|
gems: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "lootboxPurchased";
|
||||||
|
lootbox: string;
|
||||||
|
reward: string;
|
||||||
|
user: string;
|
||||||
|
rarity: Rarity;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { themes } from "../src/themes";
|
import type { themes } from "../src/themes";
|
||||||
|
import lootbox1 from "../src/assets/illustrations/lootbox1.png?w=360&inline";
|
||||||
|
|
||||||
export const rarities = [
|
export const rarities = [
|
||||||
{
|
{
|
||||||
|
|
@ -23,11 +24,18 @@ export const rarities = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type Rarity = (typeof rarities)[number]["id"];
|
export const getWeight = (rarity: Rarity) =>
|
||||||
|
rarities.find((r) => r.id === rarity)?.weight ?? 0;
|
||||||
|
|
||||||
|
export type Rarity = (typeof rarities)[number]["id"];
|
||||||
type ThemeId = (typeof themes)[number]["id"];
|
type ThemeId = (typeof themes)[number]["id"];
|
||||||
|
|
||||||
interface Lootbox {
|
interface Lootbox {
|
||||||
name: string;
|
name: string;
|
||||||
|
id: string;
|
||||||
|
price: number;
|
||||||
|
priceText: string;
|
||||||
|
image: string;
|
||||||
items: {
|
items: {
|
||||||
id: ThemeId;
|
id: ThemeId;
|
||||||
rarity: Rarity;
|
rarity: Rarity;
|
||||||
|
|
@ -36,6 +44,10 @@ interface Lootbox {
|
||||||
|
|
||||||
export const series1: Lootbox = {
|
export const series1: Lootbox = {
|
||||||
name: "Series 1",
|
name: "Series 1",
|
||||||
|
id: "series1",
|
||||||
|
price: 5000,
|
||||||
|
priceText: "5.000",
|
||||||
|
image: lootbox1,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "basic",
|
id: "basic",
|
||||||
|
|
@ -195,3 +207,5 @@ export const series1: Lootbox = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const lootboxes = [series1];
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,6 @@ export const pickRandom = <T>(arr: T[]) => {
|
||||||
return arr[index];
|
return arr[index];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WeightedEntry<T> {
|
|
||||||
weight: number;
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hashStr = (str: string) => {
|
export const hashStr = (str: string) => {
|
||||||
return [...str].reduce(
|
return [...str].reduce(
|
||||||
(hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0,
|
(hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0,
|
||||||
|
|
@ -16,18 +11,19 @@ export const hashStr = (str: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const weightedPickRandom = <T>(
|
export const weightedPickRandom = <T>(
|
||||||
arr: WeightedEntry<T>[],
|
arr: T[],
|
||||||
|
getWeight: (item: T) => number = () => 1,
|
||||||
getRandom: (tw: number) => number = (totalWeight) =>
|
getRandom: (tw: number) => number = (totalWeight) =>
|
||||||
Math.random() * totalWeight,
|
Math.random() * totalWeight,
|
||||||
): T => {
|
): T => {
|
||||||
const totalWeight = arr.reduce((acc, cur) => acc + cur.weight, 0);
|
const totalWeight = arr.reduce((acc, cur) => acc + getWeight(cur), 0);
|
||||||
const random = getRandom(totalWeight);
|
const random = getRandom(totalWeight);
|
||||||
let currentWeight = 0;
|
let currentWeight = 0;
|
||||||
for (const entry of arr) {
|
for (const entry of arr) {
|
||||||
currentWeight += entry.weight;
|
currentWeight += getWeight(entry);
|
||||||
if (random < currentWeight) {
|
if (random < currentWeight) {
|
||||||
return entry.value;
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return arr[arr.length - 1].value;
|
return arr[arr.length - 1];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<Hr />
|
<Hr />
|
||||||
<Feed />
|
<Feed />
|
||||||
<Hr />
|
{/* <Hr /> */}
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
||||||
<GitBranch />
|
<GitBranch />
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,8 @@ export const loginTokenAtom = atomWithStorage<string | undefined>(
|
||||||
export const cursorXAtom = atom(0);
|
export const cursorXAtom = atom(0);
|
||||||
export const cursorYAtom = atom(0);
|
export const cursorYAtom = atom(0);
|
||||||
export const feedItemsAtom = atom<FeedItem[]>([]);
|
export const feedItemsAtom = atom<FeedItem[]>([]);
|
||||||
|
interface LootboxResult {
|
||||||
|
result: string;
|
||||||
|
lootbox: string;
|
||||||
|
}
|
||||||
|
export const lootboxResultAtom = atom<LootboxResult | undefined>();
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,17 @@ const Board: React.FC<BoardProps> = (props) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setZenMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", listener);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
|
|
|
||||||
|
|
@ -28,24 +28,27 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
>(({ className, children, overlayClassName, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay className={overlayClassName}>
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg border-white/20 text-white/90 bg-black",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg border-white/20 text-white/90 bg-black",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
</DialogOverlay>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { feedItemsAtom } from "../../atoms";
|
import { feedItemsAtom, lootboxResultAtom } from "../../atoms";
|
||||||
import FeedItemElement from "./FeedItem";
|
import FeedItemElement from "./FeedItem";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { addMessageListener, removeMessageListener } from "../../wsClient";
|
import { addMessageListener, removeMessageListener } from "../../wsClient";
|
||||||
|
|
@ -9,6 +9,7 @@ import { useWSQuery } from "../../hooks";
|
||||||
|
|
||||||
const Feed: React.FC = () => {
|
const Feed: React.FC = () => {
|
||||||
const [items, setItems] = useAtom(feedItemsAtom);
|
const [items, setItems] = useAtom(feedItemsAtom);
|
||||||
|
const [, setLootboxResult] = useAtom(lootboxResultAtom);
|
||||||
const { data: user } = useWSQuery("user.getSelf", null);
|
const { data: user } = useWSQuery("user.getSelf", null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -19,7 +20,7 @@ const Feed: React.FC = () => {
|
||||||
}, [setItems]);
|
}, [setItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (event: MessageEvent) => {
|
const listener = async (event: MessageEvent) => {
|
||||||
const data = JSON.parse(event.data) as Events;
|
const data = JSON.parse(event.data) as Events;
|
||||||
const newItems = [...items];
|
const newItems = [...items];
|
||||||
if (data.type === "new" && data.user !== user) {
|
if (data.type === "new" && data.user !== user) {
|
||||||
|
|
@ -49,11 +50,29 @@ const Feed: React.FC = () => {
|
||||||
gems: data.gems,
|
gems: data.gems,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (data.type === "lootboxPurchased" && data.user !== user) {
|
||||||
|
newItems.push({
|
||||||
|
type: "lootboxPurchased",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
decay: Date.now() + 20_000,
|
||||||
|
lootbox: data.lootbox,
|
||||||
|
user: data.user,
|
||||||
|
reward: data.reward,
|
||||||
|
rarity: data.rarity,
|
||||||
|
});
|
||||||
|
await new Promise((res) => setTimeout(res, 2_000));
|
||||||
|
}
|
||||||
|
if (data.type === "lootboxPurchased" && data.user === user) {
|
||||||
|
setLootboxResult({
|
||||||
|
lootbox: data.lootbox,
|
||||||
|
result: data.reward,
|
||||||
|
});
|
||||||
|
}
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
};
|
};
|
||||||
addMessageListener(listener);
|
addMessageListener(listener);
|
||||||
return () => removeMessageListener(listener);
|
return () => removeMessageListener(listener);
|
||||||
}, [items, setItems, user]);
|
}, [items, setItems, setLootboxResult, user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full items-start h-[30%] overflow-y-hidden">
|
<div className="flex flex-col gap-4 w-full items-start h-[30%] overflow-y-hidden">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { motion } from "framer-motion";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { formatTimeSpan } from "../../../shared/time";
|
import { formatTimeSpan } from "../../../shared/time";
|
||||||
import GemsIcon from "../GemIcon";
|
import GemsIcon from "../GemIcon";
|
||||||
|
import { Rarity as RarityType } from "../../../shared/lootboxes";
|
||||||
|
import { Rarity } from "../Rarity";
|
||||||
|
import { themes } from "../../themes";
|
||||||
|
|
||||||
interface BaseFeedItem {
|
interface BaseFeedItem {
|
||||||
decay: number;
|
decay: number;
|
||||||
|
|
@ -26,7 +29,19 @@ interface GemsEarnedItem extends BaseFeedItem {
|
||||||
stage: number;
|
stage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedItem = GameStartedItem | GameFinishedItem | GemsEarnedItem;
|
interface LootboxPurchasedItem extends BaseFeedItem {
|
||||||
|
type: "lootboxPurchased";
|
||||||
|
lootbox: string;
|
||||||
|
user: string;
|
||||||
|
reward: string;
|
||||||
|
rarity: RarityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedItem =
|
||||||
|
| GameStartedItem
|
||||||
|
| GameFinishedItem
|
||||||
|
| GemsEarnedItem
|
||||||
|
| LootboxPurchasedItem;
|
||||||
|
|
||||||
const FeedItemWrapper: React.FC<PropsWithChildren> = ({ children }) => {
|
const FeedItemWrapper: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -59,6 +74,15 @@ const FeedItemElement: React.FC<{ item: FeedItem }> = ({ item }) => {
|
||||||
You got {item.gems} <GemsIcon /> for <span>stage {item.stage}</span>
|
You got {item.gems} <GemsIcon /> for <span>stage {item.stage}</span>
|
||||||
</FeedItemWrapper>
|
</FeedItemWrapper>
|
||||||
);
|
);
|
||||||
|
case "lootboxPurchased":
|
||||||
|
return (
|
||||||
|
<FeedItemWrapper>
|
||||||
|
{item.user} got{" "}
|
||||||
|
<Rarity rarity={item.rarity}>
|
||||||
|
{themes.find((i) => i.id == item.reward)?.name}
|
||||||
|
</Rarity>
|
||||||
|
</FeedItemWrapper>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,20 +27,20 @@ const LeaderboardButton = ({
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Leaderboard</DialogTitle>
|
<DialogTitle>Leaderboard</DialogTitle>
|
||||||
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
|
||||||
{leaderboard?.map((_, i) => (
|
|
||||||
<Fragment key={i}>
|
|
||||||
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
|
||||||
<div className="p-4 text-white/90">
|
|
||||||
{leaderboard?.[i]?.user ?? "No User"}
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-white/90">
|
|
||||||
Stage {leaderboard?.[i]?.stage ?? 0}
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-[min-content_2fr_1fr] grid-border-b">
|
||||||
|
{leaderboard?.map((_, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
<div className="p-4 text-white/80 text-right">{i + 1}.</div>
|
||||||
|
<div className="p-4 text-white/90">
|
||||||
|
{leaderboard?.[i]?.user ?? "No User"}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 text-white/90">
|
||||||
|
Stage {leaderboard?.[i]?.stage ?? 0}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { rarities } from "../../shared/lootboxes";
|
||||||
|
|
||||||
|
export const Rarity: React.FC<PropsWithChildren<{ rarity: string }>> = ({
|
||||||
|
rarity,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-bold",
|
||||||
|
rarity == "common" && "text-common",
|
||||||
|
rarity == "uncommon" && "text-uncommon",
|
||||||
|
rarity == "rare" && "text-rare",
|
||||||
|
rarity == "legendary" && "text-legendary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children ?? rarities.find((r) => r.id === rarity)?.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
--color-primary: #D9AFD9;
|
--color-primary: #D9AFD9;
|
||||||
--color-input: color-mix(in srgb, var(--color-white, #fff) 20%, transparent);
|
--color-input: color-mix(in srgb, var(--color-white, #fff) 20%, transparent);
|
||||||
--color-background: black;
|
--color-background: black;
|
||||||
|
--color-common: var(--color-sky-500);
|
||||||
|
--color-uncommon: var(--color-green-400);
|
||||||
|
--color-rare: var(--color-red-500);
|
||||||
|
--color-legendary: var(--color-amber-500);
|
||||||
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
||||||
rgb(21, 198, 251)) 0% 0% / 100% 300%;
|
rgb(21, 198, 251)) 0% 0% / 100% 300%;
|
||||||
--bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%;
|
--bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%;
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,8 @@ const Collection = () => {
|
||||||
const selected = collection?.entries.some(
|
const selected = collection?.entries.some(
|
||||||
(e) => e.id === theme.id && e.selected,
|
(e) => e.id === theme.id && e.selected,
|
||||||
);
|
);
|
||||||
const owned = collection?.entries.some(
|
const owned = collection?.entries.some((e) => e.id === theme.id);
|
||||||
(e) => e.id === theme.id && e.selected,
|
if (!owned) return null;
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div key={theme.id}>
|
<div key={theme.id}>
|
||||||
<div className="flex gap-4 justify-between">
|
<div className="flex gap-4 justify-between">
|
||||||
|
|
@ -40,32 +39,36 @@ const Collection = () => {
|
||||||
<span className="text-white/70 text-sm"> (Owned)</span>
|
<span className="text-white/70 text-sm"> (Owned)</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<DropdownMenu>
|
{owned && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="sm">
|
<DropdownMenuTrigger asChild>
|
||||||
<Ellipsis className="size-5" />
|
<Button variant="ghost" size="sm">
|
||||||
</Button>
|
<Ellipsis className="size-5" />
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent sideOffset={-12}>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem
|
<DropdownMenuContent sideOffset={-12}>
|
||||||
onClick={() => {
|
<DropdownMenuItem
|
||||||
mutateSelected
|
onClick={() => {
|
||||||
.mutateAsync({ id: theme.id })
|
mutateSelected
|
||||||
.then(() => refetch());
|
.mutateAsync({ id: theme.id })
|
||||||
}}
|
.then(() => refetch());
|
||||||
>
|
}}
|
||||||
Select
|
>
|
||||||
</DropdownMenuItem>
|
Select
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() =>
|
<DropdownMenuItem
|
||||||
mutateShuffle.mutateAsync({ id: theme.id })
|
onClick={() =>
|
||||||
}
|
mutateShuffle
|
||||||
>
|
.mutateAsync({ id: theme.id })
|
||||||
{" "}
|
.then(() => refetch())
|
||||||
Add to shuffle
|
}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
{" "}
|
||||||
</DropdownMenu>
|
Add to shuffle
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Board
|
<Board
|
||||||
game={testBoard(theme.id)}
|
game={testBoard(theme.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,197 @@
|
||||||
|
import { Fragment } from "react/jsx-runtime";
|
||||||
|
import { lootboxes } from "../../../shared/lootboxes";
|
||||||
|
import { Button } from "../../components/Button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../components/Dialog";
|
||||||
|
import GemsIcon from "../../components/GemIcon";
|
||||||
|
import { themes } from "../../themes";
|
||||||
|
import { useWSMutation } from "../../hooks";
|
||||||
|
import { Rarity } from "../../components/Rarity";
|
||||||
|
import { lootboxResultAtom } from "../../atoms";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||||
|
import { loadSlim } from "@tsparticles/slim";
|
||||||
|
import { loadSeaAnemonePreset } from "@tsparticles/preset-sea-anemone";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
const Store = () => {
|
const Store = () => {
|
||||||
|
const openLootbox = useWSMutation("user.openLootbox");
|
||||||
|
const [lootboxResult, setLootboxResult] = useAtom(lootboxResultAtom);
|
||||||
|
const currentLootbox = lootboxes.find((l) => l.id === lootboxResult?.lootbox);
|
||||||
|
|
||||||
|
// this should be run only once per application lifetime
|
||||||
|
useEffect(() => {
|
||||||
|
initParticlesEngine(async (engine) => {
|
||||||
|
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
|
||||||
|
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
|
||||||
|
// starting from v2 you can add only the features you need reducing the bundle size
|
||||||
|
//await loadAll(engine);
|
||||||
|
//await loadFull(engine);
|
||||||
|
await loadSlim(engine);
|
||||||
|
await loadSeaAnemonePreset(engine);
|
||||||
|
|
||||||
|
//await loadBasic(engine);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<>
|
||||||
<h2 className="text-white/90 text-xl">Store</h2>
|
<Dialog
|
||||||
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
|
open={!!currentLootbox}
|
||||||
<div className="flex flex-col gap-4 justify-between">
|
onOpenChange={() => setLootboxResult(undefined)}
|
||||||
<h3 className="text-white/90 text-lg">Lootboxes</h3>
|
>
|
||||||
|
<DialogContent className="relative h-96 w-[1000px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="z-10">
|
||||||
|
Opening {currentLootbox?.name}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="absolute flex h-full w-full flex-col items-center justify-center">
|
||||||
|
<Particles
|
||||||
|
className="rounded-md"
|
||||||
|
id="tsparticles"
|
||||||
|
options={{
|
||||||
|
id: "tsparticles",
|
||||||
|
preset: "seaAnemone",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="text-white/90 text-6xl"
|
||||||
|
initial={{ scale: 0, rotate: 0, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
scale: [0, 0.2, 0.5, 0.75, 1.5, 1.2, 1],
|
||||||
|
rotate: 360 * 2,
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
transition={{ type: "spring", duration: 5 }}
|
||||||
|
>
|
||||||
|
<Rarity
|
||||||
|
// @ts-expect-error Don't care
|
||||||
|
rarity={
|
||||||
|
currentLootbox?.items.find(
|
||||||
|
(i) => i.id == lootboxResult?.result,
|
||||||
|
)?.rarity
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{themes.find((t) => t.id === lootboxResult?.result)?.name}
|
||||||
|
</Rarity>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<div className="flex flex-col gap-4 w-full">
|
||||||
|
<h2 className="text-white/90 text-xl">Store</h2>
|
||||||
|
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
|
||||||
|
<div className="flex flex-row gap-y-6 gap-x-8 items-center w-full flex-wrap justify-center mb-10">
|
||||||
|
{lootboxes.map((lootbox) => (
|
||||||
|
<div
|
||||||
|
key={lootbox.id}
|
||||||
|
className="border-white/10 border-1 rounded-md p-4"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 justify-between">
|
||||||
|
<h3 className="text-white/90 text-lg">{lootbox.name}</h3>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="default" size="sm">
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-4xl m-4 relative top-4 translate-y-0"
|
||||||
|
overlayClassName="overflow-y-auto"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{lootbox.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-4 flex-wrap flex-row">
|
||||||
|
<div className="flex flex-col gap-4 basis-md">
|
||||||
|
<img
|
||||||
|
src={lootbox.image}
|
||||||
|
className="max-w-[360px] w-[min(360px,100%)]"
|
||||||
|
/>
|
||||||
|
<div className="text-white/70 text-base">
|
||||||
|
Introducing <i>{lootbox.name}</i>, the first-ever
|
||||||
|
lootbox for Minesweeper, packed with a variety of
|
||||||
|
stylish skins to customize your game like never
|
||||||
|
before! With <i>{lootbox.name}</i>, every click
|
||||||
|
becomes a statement, as you explore new looks for
|
||||||
|
your mines, tiles, and flags. Transform your
|
||||||
|
Minesweeper grid with these visually captivating
|
||||||
|
designs and make each puzzle feel fresh and
|
||||||
|
exciting.
|
||||||
|
</div>
|
||||||
|
<h3 className="text-white/90 text-lg">
|
||||||
|
What's Inside?
|
||||||
|
</h3>
|
||||||
|
<ul className="text-white/70 text-base flex flex-col gap-2">
|
||||||
|
<li>
|
||||||
|
<b>Mine Skins:</b> Swap out the classic mine icons
|
||||||
|
for creative alternatives like skulls, treasure
|
||||||
|
chests, or high-tech drones.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Tile Skins:</b> Replace the plain tiles with
|
||||||
|
vibrant patterns such as tropical beaches,
|
||||||
|
medieval bricks, or sleek metallic plates.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Flag Skins:</b> Mark suspected mines in style
|
||||||
|
with custom flag designs including pirate flags,
|
||||||
|
futuristic beacons, or glowing crystals.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>Backgrounds:</b> Enhance your gameplay
|
||||||
|
experience with unique backgrounds, from serene
|
||||||
|
mountain landscapes to bustling cityscapes or
|
||||||
|
outer space vistas.
|
||||||
|
</li>
|
||||||
|
<div className="text-white/70 text-base">
|
||||||
|
Step up your Minesweeper game and express your
|
||||||
|
personality with <i>{lootbox.name}</i>. Unlock new
|
||||||
|
looks and turn every game into a visual
|
||||||
|
masterpiece!
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center grow">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>Skin</div>
|
||||||
|
<div>Rarity</div>
|
||||||
|
{lootbox.items.map((item) => (
|
||||||
|
<Fragment key={item.id}>
|
||||||
|
<div className="text-white/90">
|
||||||
|
{themes.find((t) => t.id === item.id)?.name}
|
||||||
|
</div>
|
||||||
|
<Rarity rarity={item.rarity} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<img src={lootbox.image} className="w-[360px]" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="mx-auto items-center"
|
||||||
|
onClick={() => openLootbox.mutateAsync({ id: lootbox.id })}
|
||||||
|
>
|
||||||
|
Buy for <b>{lootbox.priceText}</b> <GemsIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,11 @@ const createWSClient = () => {
|
||||||
): Promise<Awaited<ReturnType<Routes[TController][TAction]["handler"]>>> => {
|
): Promise<Awaited<ReturnType<Routes[TController][TAction]["handler"]>>> => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) {
|
if (ws.readyState !== WebSocket.OPEN) {
|
||||||
await new Promise<void>((res) => {
|
await new Promise<void>((res) => {
|
||||||
ws.onopen = () => {
|
const onOpen = () => {
|
||||||
|
ws.removeEventListener("open", onOpen);
|
||||||
res();
|
res();
|
||||||
};
|
};
|
||||||
|
ws.addEventListener("open", onOpen);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,8 @@ import { imagetools } from "vite-imagetools";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 3003,
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss(), imagetools()],
|
plugins: [react(), tailwindcss(), imagetools()],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue