diff --git a/backend/controller/userController.ts b/backend/controller/userController.ts index 6bee4bd..470fc89 100644 --- a/backend/controller/userController.ts +++ b/backend/controller/userController.ts @@ -11,11 +11,14 @@ import crypto from "crypto"; import { resetSessionUser, setSessionUser } from "../router"; import { userSettings } from "../../shared/user-settings"; import { UnauthorizedError } from "../errors/UnauthorizedError"; -import { getGems } from "../repositories/gemsRepository"; +import { getGems, removeGems } from "../repositories/gemsRepository"; import { getCollection, upsertCollection, } from "../repositories/collectionRepository"; +import { getWeight, lootboxes } from "../../shared/lootboxes"; +import { weightedPickRandom } from "../../shared/utils"; +import { emit } from "../events"; const secret = process.env.SECRET!; @@ -133,4 +136,35 @@ export const userController = createController({ 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, + }); + }, + ), }); diff --git a/bun.lockb b/bun.lockb index 352fdc6..d0091f2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 65d4d20..fd22354 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "@radix-ui/react-switch": "^1.1.1", "@tanstack/react-query": "^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", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/shared/events.ts b/shared/events.ts index fe7dda5..68361ad 100644 --- a/shared/events.ts +++ b/shared/events.ts @@ -1,3 +1,4 @@ +import type { Rarity } from "../shared/lootboxes"; export type EventType = "new" | "finished" | "updateGame" | "updateStage"; export type Events = @@ -25,4 +26,11 @@ export type Events = type: "gemsRewarded"; stage: number; gems: number; + } + | { + type: "lootboxPurchased"; + lootbox: string; + reward: string; + user: string; + rarity: Rarity; }; diff --git a/shared/lootboxes.ts b/shared/lootboxes.ts index cddfe4a..f24278f 100644 --- a/shared/lootboxes.ts +++ b/shared/lootboxes.ts @@ -1,4 +1,5 @@ import type { themes } from "../src/themes"; +import lootbox1 from "../src/assets/illustrations/lootbox1.png?w=360&inline"; export const rarities = [ { @@ -23,11 +24,18 @@ export const rarities = [ }, ] 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"]; interface Lootbox { name: string; + id: string; + price: number; + priceText: string; + image: string; items: { id: ThemeId; rarity: Rarity; @@ -36,6 +44,10 @@ interface Lootbox { export const series1: Lootbox = { name: "Series 1", + id: "series1", + price: 5000, + priceText: "5.000", + image: lootbox1, items: [ { id: "basic", @@ -195,3 +207,5 @@ export const series1: Lootbox = { }, ], }; + +export const lootboxes = [series1]; diff --git a/shared/utils.ts b/shared/utils.ts index 794778b..ac48d73 100644 --- a/shared/utils.ts +++ b/shared/utils.ts @@ -3,11 +3,6 @@ export const pickRandom = (arr: T[]) => { return arr[index]; }; -interface WeightedEntry { - weight: number; - value: T; -} - export const hashStr = (str: string) => { return [...str].reduce( (hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0, @@ -16,18 +11,19 @@ export const hashStr = (str: string) => { }; export const weightedPickRandom = ( - arr: WeightedEntry[], + arr: T[], + getWeight: (item: T) => number = () => 1, getRandom: (tw: number) => number = (totalWeight) => Math.random() * totalWeight, ): 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); let currentWeight = 0; for (const entry of arr) { - currentWeight += entry.weight; + currentWeight += getWeight(entry); if (random < currentWeight) { - return entry.value; + return entry; } } - return arr[arr.length - 1].value; + return arr[arr.length - 1]; }; diff --git a/src/Shell.tsx b/src/Shell.tsx index f7e10a2..dead12d 100644 --- a/src/Shell.tsx +++ b/src/Shell.tsx @@ -90,7 +90,7 @@ const Shell: React.FC = ({ children }) => {
-
+ {/*
*/}
diff --git a/src/atoms.ts b/src/atoms.ts index fcc2526..6331c7f 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -10,3 +10,8 @@ export const loginTokenAtom = atomWithStorage( export const cursorXAtom = atom(0); export const cursorYAtom = atom(0); export const feedItemsAtom = atom([]); +interface LootboxResult { + result: string; + lootbox: string; +} +export const lootboxResultAtom = atom(); diff --git a/src/components/Board.tsx b/src/components/Board.tsx index 6b9274f..37dcd96 100644 --- a/src/components/Board.tsx +++ b/src/components/Board.tsx @@ -136,6 +136,17 @@ const Board: React.FC = (props) => { }); } }, [ref]); + useEffect(() => { + const listener = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setZenMode(false); + } + }; + document.addEventListener("keydown", listener); + return () => { + document.removeEventListener("keydown", listener); + }; + }, []); return (
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 8aa2f6c..97f6b15 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -28,24 +28,27 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + overlayClassName?: string; + } +>(({ className, children, overlayClassName, ...props }, ref) => ( - - - {children} - - - Close - - + + + {children} + + + Close + + + )); DialogContent.displayName = DialogPrimitive.Content.displayName; diff --git a/src/components/Feed/Feed.tsx b/src/components/Feed/Feed.tsx index f151c08..5837c01 100644 --- a/src/components/Feed/Feed.tsx +++ b/src/components/Feed/Feed.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from "framer-motion"; import { useAtom } from "jotai"; -import { feedItemsAtom } from "../../atoms"; +import { feedItemsAtom, lootboxResultAtom } from "../../atoms"; import FeedItemElement from "./FeedItem"; import { useEffect } from "react"; import { addMessageListener, removeMessageListener } from "../../wsClient"; @@ -9,6 +9,7 @@ import { useWSQuery } from "../../hooks"; const Feed: React.FC = () => { const [items, setItems] = useAtom(feedItemsAtom); + const [, setLootboxResult] = useAtom(lootboxResultAtom); const { data: user } = useWSQuery("user.getSelf", null); useEffect(() => { @@ -19,7 +20,7 @@ const Feed: React.FC = () => { }, [setItems]); useEffect(() => { - const listener = (event: MessageEvent) => { + const listener = async (event: MessageEvent) => { const data = JSON.parse(event.data) as Events; const newItems = [...items]; if (data.type === "new" && data.user !== user) { @@ -49,11 +50,29 @@ const Feed: React.FC = () => { 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); }; addMessageListener(listener); return () => removeMessageListener(listener); - }, [items, setItems, user]); + }, [items, setItems, setLootboxResult, user]); return (
diff --git a/src/components/Feed/FeedItem.tsx b/src/components/Feed/FeedItem.tsx index a97a996..aa72434 100644 --- a/src/components/Feed/FeedItem.tsx +++ b/src/components/Feed/FeedItem.tsx @@ -2,6 +2,9 @@ import { motion } from "framer-motion"; import { PropsWithChildren } from "react"; import { formatTimeSpan } from "../../../shared/time"; import GemsIcon from "../GemIcon"; +import { Rarity as RarityType } from "../../../shared/lootboxes"; +import { Rarity } from "../Rarity"; +import { themes } from "../../themes"; interface BaseFeedItem { decay: number; @@ -26,7 +29,19 @@ interface GemsEarnedItem extends BaseFeedItem { 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 = ({ children }) => { return ( @@ -59,6 +74,15 @@ const FeedItemElement: React.FC<{ item: FeedItem }> = ({ item }) => { You got {item.gems} for stage {item.stage} ); + case "lootboxPurchased": + return ( + + {item.user} got{" "} + + {themes.find((i) => i.id == item.reward)?.name} + + + ); } }; diff --git a/src/components/LeaderboardButton.tsx b/src/components/LeaderboardButton.tsx index 65995c0..2bfb4b9 100644 --- a/src/components/LeaderboardButton.tsx +++ b/src/components/LeaderboardButton.tsx @@ -27,20 +27,20 @@ const LeaderboardButton = ({ Leaderboard -
- {leaderboard?.map((_, i) => ( - -
{i + 1}.
-
- {leaderboard?.[i]?.user ?? "No User"} -
-
- Stage {leaderboard?.[i]?.stage ?? 0} -
-
- ))} -
+
+ {leaderboard?.map((_, i) => ( + +
{i + 1}.
+
+ {leaderboard?.[i]?.user ?? "No User"} +
+
+ Stage {leaderboard?.[i]?.stage ?? 0} +
+
+ ))} +
); diff --git a/src/components/Rarity.tsx b/src/components/Rarity.tsx new file mode 100644 index 0000000..4e19d4b --- /dev/null +++ b/src/components/Rarity.tsx @@ -0,0 +1,22 @@ +import { PropsWithChildren } from "react"; +import { cn } from "../lib/utils"; +import { rarities } from "../../shared/lootboxes"; + +export const Rarity: React.FC> = ({ + rarity, + children, +}) => { + return ( + + {children ?? rarities.find((r) => r.id === rarity)?.name} + + ); +}; diff --git a/src/index.css b/src/index.css index 8717290..31b1d71 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,10 @@ --color-primary: #D9AFD9; --color-input: color-mix(in srgb, var(--color-white, #fff) 20%, transparent); --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), rgb(21, 198, 251)) 0% 0% / 100% 300%; --bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%; diff --git a/src/views/collection/Collection.tsx b/src/views/collection/Collection.tsx index 2c175e8..af305aa 100644 --- a/src/views/collection/Collection.tsx +++ b/src/views/collection/Collection.tsx @@ -28,9 +28,8 @@ const Collection = () => { const selected = collection?.entries.some( (e) => e.id === theme.id && e.selected, ); - const owned = collection?.entries.some( - (e) => e.id === theme.id && e.selected, - ); + const owned = collection?.entries.some((e) => e.id === theme.id); + if (!owned) return null; return (
@@ -40,32 +39,36 @@ const Collection = () => { (Owned) )} - - - - - - { - mutateSelected - .mutateAsync({ id: theme.id }) - .then(() => refetch()); - }} - > - Select - - - mutateShuffle.mutateAsync({ id: theme.id }) - } - > - {" "} - Add to shuffle - - - + {owned && ( + + + + + + { + mutateSelected + .mutateAsync({ id: theme.id }) + .then(() => refetch()); + }} + > + Select + + + mutateShuffle + .mutateAsync({ id: theme.id }) + .then(() => refetch()) + } + > + {" "} + Add to shuffle + + + + )}
{ + 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 ( -
-

Store

-
-
-

Lootboxes

+ <> + setLootboxResult(undefined)} + > + + + + Opening {currentLootbox?.name} + + +
+ + + i.id == lootboxResult?.result, + )?.rarity + } + > + {themes.find((t) => t.id === lootboxResult?.result)?.name} + + +
+
+
+
+

Store

+
+
+ {lootboxes.map((lootbox) => ( +
+
+

{lootbox.name}

+ + + + + + + {lootbox.name} + +
+
+ +
+ Introducing {lootbox.name}, the first-ever + lootbox for Minesweeper, packed with a variety of + stylish skins to customize your game like never + before! With {lootbox.name}, 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. +
+

+ What's Inside? +

+
    +
  • + Mine Skins: Swap out the classic mine icons + for creative alternatives like skulls, treasure + chests, or high-tech drones. +
  • +
  • + Tile Skins: Replace the plain tiles with + vibrant patterns such as tropical beaches, + medieval bricks, or sleek metallic plates. +
  • +
  • + Flag Skins: Mark suspected mines in style + with custom flag designs including pirate flags, + futuristic beacons, or glowing crystals. +
  • +
  • + Backgrounds: Enhance your gameplay + experience with unique backgrounds, from serene + mountain landscapes to bustling cityscapes or + outer space vistas. +
  • +
    + Step up your Minesweeper game and express your + personality with {lootbox.name}. Unlock new + looks and turn every game into a visual + masterpiece! +
    +
+
+
+
+
Skin
+
Rarity
+ {lootbox.items.map((item) => ( + +
+ {themes.find((t) => t.id === item.id)?.name} +
+ +
+ ))} +
+
+
+
+
+
+ + +
+ ))} +
-
+ ); }; diff --git a/src/wsClient.ts b/src/wsClient.ts index bde07e7..edd4f36 100644 --- a/src/wsClient.ts +++ b/src/wsClient.ts @@ -56,9 +56,11 @@ const createWSClient = () => { ): Promise>> => { if (ws.readyState !== WebSocket.OPEN) { await new Promise((res) => { - ws.onopen = () => { + const onOpen = () => { + ws.removeEventListener("open", onOpen); res(); }; + ws.addEventListener("open", onOpen); }); } const requestId = crypto.randomUUID(); diff --git a/vite.config.ts b/vite.config.ts index 9163aca..9ffaf19 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,5 +5,8 @@ import { imagetools } from "vite-imagetools"; // https://vitejs.dev/config/ export default defineConfig({ + server: { + port: 3003, + }, plugins: [react(), tailwindcss(), imagetools()], });