Skip to content

Commit

Permalink
feat: add tutorial dialog (#32)
Browse files Browse the repository at this point in the history
* feat: add tutorial dialog

* refactor: init shadcn and update tutorial content

* refactor: pr udates

* refactor: 2nd pr updates

* oops
  • Loading branch information
drewradcliff authored Aug 26, 2024
1 parent c48ca39 commit 828fa48
Show file tree
Hide file tree
Showing 14 changed files with 848 additions and 128 deletions.
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"tailwindCSS.experimental.classRegex": [
"tw`([^`]*)`",
"tw\\(\"([^\"]*)\"\\)",
"tw\\('([^']*)'\\)"
"tw\\('([^']*)'\\)",
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.preferTypeOnlyAutoImports": true
Expand Down
3 changes: 3 additions & 0 deletions app/components/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { Skull } from "lucide-react";
import { Fade } from "@/components/fade";
import { Plot } from "@/components/plot";
import { TutorialDialog } from "@/components/tutorial-dialog";
import { useSocketEvent } from "@/hooks/use-socket-event";

const GRID_SIZE = 2;
Expand All @@ -13,6 +14,7 @@ const PX_PER_REM = 16;

export function Field() {
const [sessionState] = useSocketEvent("sessionState");
const [isNewSession] = useSocketEvent("newSession");
const [plots] = useSocketEvent("update");
const size = plots ? Math.sqrt(plots.length) : 0;

Expand Down Expand Up @@ -97,6 +99,7 @@ export function Field() {
}),
)}
</div>
{isNewSession && <TutorialDialog />}
</div>
);
}
98 changes: 98 additions & 0 deletions app/components/tutorial-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useState } from "react";
import {
BombIcon,
FlagIcon,
GridIcon,
MousePointerClick,
UsersIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";

const steps = [
{
title: "Massive Multiplayer Mines",
description:
"Work together in real time to clear the grid and avoid the mines!",
icon: <UsersIcon className="w-12 h-12 mb-4 text-primary" />,
},
{
title: "Understand the Grid",
description:
"Minesweeper is played on a grid of squares. Some squares contain mines, while others are safe.",
icon: <GridIcon className="w-12 h-12 mb-4 text-primary" />,
},
{
title: "Left-Click to Reveal",
description:
"Left-click on a square to reveal what's underneath. If it's a mine, you lose!",
icon: <MousePointerClick className="w-12 h-12 mb-4 text-primary" />,
},
{
title: "Right-Click to Flag",
description:
"Right-click to place a flag on a square to keep track of potential mine locations.",
icon: <FlagIcon className="w-12 h-12 mb-4 text-primary" />,
},
{
title: "Hardcore",
description:
"Click a mine, and you're out until all squares are revealed and a new game starts with an even larger grid.",
icon: <BombIcon className="w-12 h-12 mb-4 text-primary" />,
},
];

export function TutorialDialog() {
const [step, setStep] = useState(1);
const [open, setOpen] = useState(true);
const totalSteps = steps.length;
const currentStep = steps[step - 1]!;

const handleNext = () => {
if (step < totalSteps) {
setStep(step + 1);
} else {
setOpen(false);
}
};

return (
<Dialog open={open} onOpenChange={setOpen} defaultOpen>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>How to Play</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center text-center py-4">
{currentStep.icon}
<h3 className="text-lg font-semibold mb-2">{currentStep.title}</h3>
<p className="text-sm text-gray-600">{currentStep.description}</p>
</div>
<DialogFooter>
<div className="text-sm text-gray-600 text-center">
Step {step} of {totalSteps}
</div>
<div className="space-x-2 flex">
<Button
className="flex-1"
variant="outline"
onClick={
step === 1 ? () => setOpen(false) : () => setStep(step - 1)
}
>
{step === 1 ? "Skip" : "Back"}
</Button>
<Button className="flex-1" onClick={handleNext}>
{step < totalSteps ? "Next" : "Start Playing"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
57 changes: 57 additions & 0 deletions app/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils";

const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-slate-300",
{
variants: {
variant: {
default:
"bg-slate-900 text-slate-50 shadow hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
destructive:
"bg-red-500 text-slate-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline:
"border border-slate-200 bg-white shadow-sm hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
secondary:
"bg-slate-100 text-slate-900 shadow-sm hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost:
"hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";

export { Button, buttonVariants };
115 changes: 115 additions & 0 deletions app/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/utils";

const Dialog = DialogPrimitive.Root;

const DialogTrigger = DialogPrimitive.Trigger;

const DialogPortal = DialogPrimitive.Portal;

const DialogClose = DialogPrimitive.Close;

const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-200 bg-white 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 dark:border-slate-800 dark:bg-slate-950",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[state=open]:bg-slate-800 dark:data-[state=open]:text-slate-400">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";

const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-4", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";

const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;

const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-500 dark:text-slate-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
4 changes: 2 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export const metadata: Metadata = {

export default function RootLayout(props: Readonly<React.PropsWithChildren>) {
return (
<html lang="en">
<body className={GeistSans.className}>
<html lang="en" className={GeistSans.variable}>
<body className="min-h-screen bg-background font-sans antialiased">
<main className="flex flex-col items-center h-[100dvh]">
<SocketProvider>
<Header />
Expand Down
1 change: 1 addition & 0 deletions app/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ async function main() {
// send initial data to newly connected client
socket.emit("session", socket.data.sessionId);
socket.emit("sessionState", socket.data.sessionState);
socket.emit("newSession", socket.data.isNewSession);
socket.emit("update", field.plots);
socket.emit("exposedPercent", field.exposedPercent);

Expand Down
2 changes: 2 additions & 0 deletions app/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ export type ServerToClientEvents = {
exposedPercent(count: number): void;
session(sessionId: string): void;
sessionState(state: SessionState): void;
newSession(isNewSession: boolean): void;
};

type InterServerEvents = {};

export type SocketData = {
sessionId: string;
sessionState: SessionState;
isNewSession: boolean;
};

export type SocketClient = Socket<ServerToClientEvents, ClientToServerEvents>;
Expand Down
13 changes: 11 additions & 2 deletions app/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

/** @see {@link https://github.com/tailwindlabs/prettier-plugin-tailwindcss#sorting-classes-in-template-literals} */
export const tw = (
export function tw(
strings: readonly string[] | ArrayLike<string>,
...values: any[]
) => String.raw({ raw: strings }, ...values);
) {
return String.raw({ raw: strings }, ...values);
}
4 changes: 2 additions & 2 deletions app/utils/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ export async function getSession(sessionId: string): Promise<SocketData> {
const redis = getClient();
const sessionState = await redis.hget(sessionKey, sessionId);
if (sessionState === "alive" || sessionState === "dead") {
return { sessionId, sessionState };
return { sessionId, sessionState, isNewSession: false };
}
const newSessionId = crypto.randomBytes(8).toString("hex");
await setSession(newSessionId, "alive");
return { sessionId: newSessionId, sessionState: "alive" };
return { sessionId: newSessionId, sessionState: "alive", isNewSession: true };
}

export async function setSession(id: string, state: SessionState) {
Expand Down
17 changes: 17 additions & 0 deletions components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utils"
}
}
Loading

0 comments on commit 828fa48

Please sign in to comment.