diff --git a/app/admin/review/page.tsx b/app/admin/review/page.tsx index 5228caa..5b28852 100644 --- a/app/admin/review/page.tsx +++ b/app/admin/review/page.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery } from "convex/react"; import { Map } from "@/components/Map"; +import { Page, PageTitle } from "@/components/Page"; import { PlayMapButton } from "@/components/PlayMapButton"; import { Button } from "@/components/ui/button"; import { @@ -21,8 +22,8 @@ export default function AdminReviewPage() { if (isAdmin == true) { return ( -
-

Review Maps

+ + Review Maps
{maps?.map((map) => ( @@ -56,7 +57,7 @@ export default function AdminReviewPage() { ))}
-
+ ); } diff --git a/app/auth/page.tsx b/app/auth/page.tsx index a541068..5651a4d 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -1,35 +1,34 @@ "use client"; import React from "react"; -import { Button } from "@/components/ui/button"; import { useAuthActions } from "@convex-dev/auth/react"; -import { GitHubLogoIcon } from "@radix-ui/react-icons"; import { SiGoogle } from "@icons-pack/react-simple-icons"; +import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import { Page, PageTitle } from "@/components/Page"; +import { Button } from "@/components/ui/button"; const AuthPage = () => { const { signIn } = useAuthActions(); return ( -
-

- Select How To Sign In -

+ + Select How To Sign In -
+
-
+
); }; diff --git a/app/games/[gameId]/page.tsx b/app/games/[gameId]/page.tsx index 269b97a..8b1875c 100644 --- a/app/games/[gameId]/page.tsx +++ b/app/games/[gameId]/page.tsx @@ -2,6 +2,7 @@ import { useQuery } from "convex/react"; import { Result } from "./result"; +import { Page } from "@/components/Page"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; @@ -14,7 +15,7 @@ export default function GamePage({ params }: { params: { gameId: string } }) { }); return ( -
+

Game {params.gameId}

Model: {game?.modelId}

@@ -28,6 +29,6 @@ export default function GamePage({ params }: { params: { gameId: string } }) { results.map((result) => ) )}
-
+ ); } diff --git a/app/games/[gameId]/result.tsx b/app/games/[gameId]/result.tsx index db7478e..07b35dd 100644 --- a/app/games/[gameId]/result.tsx +++ b/app/games/[gameId]/result.tsx @@ -34,7 +34,7 @@ export const Result = ({ result }: { result: Doc<"results"> }) => { {result.error} ) : ( - + )}
diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx index 02daa17..d82176f 100644 --- a/app/leaderboard/page.tsx +++ b/app/leaderboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery } from "convex/react"; +import { Page, PageTitle } from "@/components/Page"; import { Table, TableBody, @@ -65,8 +66,8 @@ const LeaderBoard = () => { ); return ( -
-

Leaderboard

+ + Leaderboard @@ -140,7 +141,7 @@ const LeaderBoard = () => { -
+ ); }; diff --git a/app/play/[level]/[attempt]/page.tsx b/app/play/[level]/[attempt]/page.tsx index 9a82040..f829ec6 100644 --- a/app/play/[level]/[attempt]/page.tsx +++ b/app/play/[level]/[attempt]/page.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { ChevronLeftIcon } from "@radix-ui/react-icons"; import { useQuery } from "convex/react"; import Link from "next/link"; +import { Page, PageTitle } from "@/components/Page"; import { Visualizer } from "@/components/Visualizer"; import { Button } from "@/components/ui/button"; import { api } from "@/convex/_generated/api"; @@ -23,7 +24,7 @@ export default function PlayLevelAttemptPage({ if (attempt === undefined) { return ( -
+
-

Night #{level}

+ Night #{level}

Attempt #{attemptNum}

Loading... -
+ ); } else if (attempt === null) { return ( @@ -45,7 +46,7 @@ export default function PlayLevelAttemptPage({ } return ( -
+
-

Night #{level}

+ Night #{level}

Attempt #{attemptNum}

@@ -66,6 +67,6 @@ export default function PlayLevelAttemptPage({ {attempt.didWin ? "You Survived!" : "You Died!"}
-
+ ); } diff --git a/app/play/[level]/page.tsx b/app/play/[level]/page.tsx index c2a0375..da758af 100644 --- a/app/play/[level]/page.tsx +++ b/app/play/[level]/page.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import TestMode from "./test-mode"; import { Map } from "@/components/Map"; +import { Page, PageTitle } from "@/components/Page"; import { Visualizer } from "@/components/Visualizer"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -49,7 +50,7 @@ export default function PlayLevelPage({ if (!map) { return ( -
+
-

Night #{level}

+ Night #{level}

Loading...

-
+ ); } @@ -156,7 +157,7 @@ export default function PlayLevelPage({ }; return ( -
+
-

Night #{level}

+ Night #{level} {mode === "play" ? ( <> @@ -206,7 +207,7 @@ export default function PlayLevelPage({ <> @@ -311,6 +312,6 @@ export default function PlayLevelPage({ ) : ( )} -
+ ); } diff --git a/app/play/[level]/test-mode.tsx b/app/play/[level]/test-mode.tsx index d86ac59..332c770 100644 --- a/app/play/[level]/test-mode.tsx +++ b/app/play/[level]/test-mode.tsx @@ -105,7 +105,7 @@ export default function TestMode({ level, map }: TestModeProps) { {aiError &&
{aiError}
} {gameResult && (
- +
-

Choose a Night

+ + Choose a Night ))}
-
+ ); } return ( -
-

Choose a Night

+ + Choose a Night ))}
-
+ ); } diff --git a/app/playground/page.tsx b/app/playground/page.tsx index 483c1ec..0d9cf3b 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -9,16 +9,26 @@ import { ChevronUp, UploadIcon, } from "lucide-react"; +import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Map } from "@/components/Map"; import { MapStatus } from "@/components/MapStatus"; import { ModelSelector } from "@/components/ModelSelector"; +import { Page, PageTitle } from "@/components/Page"; import { Visualizer } from "@/components/Visualizer"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useToast } from "@/components/ui/use-toast"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; +import { SIGN_IN_ERROR_MESSAGE } from "@/convex/users"; import { errorMessage } from "@/lib/utils"; import { ZombieSurvival } from "@/simulators/zombie-survival"; @@ -53,6 +63,7 @@ export default function PlaygroundPage() { const [userPlaying, setUserPlaying] = useState(false); const [userSolution, setUserSolution] = useState([]); const [visualizingUserSolution, setVisualizingUserSolution] = useState(false); + const [openSignInModal, setOpenSignInModal] = useState(false); async function handlePublish() { if (!ZombieSurvival.mapHasZombies(map)) { @@ -73,10 +84,14 @@ export default function PlaygroundPage() { description: "Map submitted successfully!", }); } catch (error) { - toast({ - description: errorMessage(error), - variant: "destructive", - }); + if (errorMessage(error).includes(SIGN_IN_ERROR_MESSAGE)) { + setOpenSignInModal(true); + } else { + toast({ + description: errorMessage(error), + variant: "destructive", + }); + } } finally { setPublishing(false); } @@ -137,18 +152,6 @@ export default function PlaygroundPage() { window.localStorage.setItem(STORAGE_MAP_KEY, JSON.stringify(cleanedMap)); } - function handleReset() { - handleChangeMap([]); - - setSolution(null); - setReasoning(null); - setPublishing(false); - setSimulating(false); - setUserPlaying(false); - setUserSolution([]); - setVisualizingUserSolution(false); - } - function handleUserPlay() { if (!ZombieSurvival.mapHasZombies(map)) { alert("Add player to the map first"); @@ -217,6 +220,17 @@ export default function PlaygroundPage() { } } + // function handleReset() { + // handleChangeMap([]); + // setSolution(null); + // setReasoning(null); + // setPublishing(false); + // setSimulating(false); + // setUserPlaying(false); + // setUserSolution([]); + // setVisualizingUserSolution(false); + // } + useEffect(() => { const maybeMap = window.localStorage.getItem(STORAGE_MAP_KEY); const maybeModel = window.localStorage.getItem(STORAGE_MODEL_KEY); @@ -238,248 +252,275 @@ export default function PlaygroundPage() { const visualizing = solution !== null || visualizingUserSolution; return ( -
-

Playground

- -
- {/* Left side: Grid */} -
- - {!visualizing && !userPlaying && ( -

- Click on the board to place or remove units. Use the buttons - below to switch between unit types. -

- )} - {!visualizing && userPlaying && ( -

- Place a player (P) and blocks (B) on the board to create your - escape route. Click to toggle between empty, player, and block. -

- )} -
- {visualizing && ( - + <> + setOpenSignInModal(open)} + > + + + For this action you need to be signed in! + + In order to submit a map you need to be signed in. + +
+ + + +
+
+
+
+ + Playground + +
+ {/* Left side: Grid */} +
+ + {!visualizing && !userPlaying && ( +

+ Click on the board to place or remove units. Use the buttons + below to switch between unit types. +

)} - {!visualizing && ( -
- -
- {(userPlaying ? userSolution : map).map((row, y) => - row.map((cell, x) => ( -
{ - const newMap = userPlaying - ? [...userSolution] - : [...map]; - if (userPlaying) { - // Count existing players and blocks - const playerCount = newMap - .flat() - .filter((c) => c === "P").length; - const blockCount = newMap - .flat() - .filter((c) => c === "B").length; - - // Toggle logic for play mode - if (cell === " ") { - if (playerCount === 0) { - newMap[y][x] = "P"; - } else if (blockCount < 2) { - newMap[y][x] = "B"; + {!visualizing && userPlaying && ( +

+ Place a player (P) and blocks (B) on the board to create your + escape route. Click to toggle between empty, player, and + block. +

+ )} +
+ {visualizing && ( + + )} + {!visualizing && ( +
+ +
+ {(userPlaying ? userSolution : map).map((row, y) => + row.map((cell, x) => ( +
{ + const newMap = userPlaying + ? [...userSolution] + : [...map]; + if (userPlaying) { + // Count existing players and blocks + const playerCount = newMap + .flat() + .filter((c) => c === "P").length; + const blockCount = newMap + .flat() + .filter((c) => c === "B").length; + + // Toggle logic for play mode + if (cell === " ") { + if (playerCount === 0) { + newMap[y][x] = "P"; + } else if (blockCount < 2) { + newMap[y][x] = "B"; + } + } else if (cell === "P") { + newMap[y][x] = " "; + } else if (cell === "B") { + newMap[y][x] = " "; } - } else if (cell === "P") { - newMap[y][x] = " "; - } else if (cell === "B") { - newMap[y][x] = " "; + userPlaying + ? setUserSolution(newMap) + : handleChangeMap(newMap); + } else { + // Toggle between empty, zombie, and rock for edit mode + if (cell === " ") newMap[y][x] = "Z"; + else if (cell === "Z") newMap[y][x] = "R"; + else newMap[y][x] = " "; } userPlaying ? setUserSolution(newMap) : handleChangeMap(newMap); - } else { - // Toggle between empty, zombie, and rock for edit mode - if (cell === " ") newMap[y][x] = "Z"; - else if (cell === "Z") newMap[y][x] = "R"; - else newMap[y][x] = " "; - } - userPlaying - ? setUserSolution(newMap) - : handleChangeMap(newMap); - }} - /> - )), + }} + /> + )), + )} +
+ + {/* Grid expansion controls */} + {!userPlaying && !visualizing && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ )}
+ )} +
+ +
- {/* Grid expansion controls */} - {!userPlaying && !visualizing && ( - <> -
- - -
-
- - -
-
- - -
-
- - -
- + {/* Right side: Action Panel */} + +
+ {userPlaying || solution !== null ? ( + <> + + {userPlaying && ( + )} -
- )} -
- -
- - {/* Right side: Action Panel */} - -
- {userPlaying || solution !== null ? ( - <> - - {userPlaying && ( + + ) : ( + <> - )} - - ) : ( - <> - - - - )} - - {error !== null &&

{error}

} - {visualizingUserSolution && } - {reasoning !== null && ( -
- -

{reasoning}

-
- )} - - {isAdmin && !userPlaying && solution === null && ( - <> -
-
-

Model (~$0.002)

- + + )} + + {error !== null && ( +

{error}

+ )} + {visualizingUserSolution && } + {reasoning !== null && ( +
+ +

{reasoning}

- - )} -
- -
-
+ )} + + {isAdmin && !userPlaying && solution === null && ( + <> +
+
+

Model (~$0.002)

+ + +
+ + )} +
+ +
+
+ ); } diff --git a/app/prompts/[promptId]/page.tsx b/app/prompts/[promptId]/page.tsx index af1df90..b7c8301 100644 --- a/app/prompts/[promptId]/page.tsx +++ b/app/prompts/[promptId]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery } from "convex/react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; +import { Page } from "@/components/Page"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/convex/_generated/api"; @@ -55,7 +56,7 @@ export default function EditPromptPage({ } return ( -
+

Edit your prompt

@@ -80,6 +81,6 @@ export default function EditPromptPage({ />
-
+ ); } diff --git a/app/prompts/page.tsx b/app/prompts/page.tsx index 25b193e..89a4d37 100644 --- a/app/prompts/page.tsx +++ b/app/prompts/page.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery } from "convex/react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; +import { Page } from "@/components/Page"; import { Button } from "@/components/ui/button"; import { Table, @@ -37,7 +38,7 @@ export default function PromptsPage() { } return ( -
+

Review Prompts

-
+ ); } diff --git a/app/rules/page.tsx b/app/rules/page.tsx index 505040d..21c1a51 100644 --- a/app/rules/page.tsx +++ b/app/rules/page.tsx @@ -1,7 +1,9 @@ +import { Page, PageTitle } from "@/components/Page"; + export default function RulesPage() { return ( -
-

Rules

+ + Rules

Placing rules

    @@ -48,6 +50,6 @@ export default function RulesPage() {
-
+ ); } diff --git a/app/test/page.tsx b/app/test/page.tsx index 54e9db5..b170126 100644 --- a/app/test/page.tsx +++ b/app/test/page.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useMutation, useQuery } from "convex/react"; import { useRouter } from "next/navigation"; import { ModelSelector } from "@/components/ModelSelector"; +import { Page, PageTitle } from "@/components/Page"; import { Button } from "@/components/ui/button"; import { api } from "@/convex/_generated/api"; @@ -22,8 +23,8 @@ export default function TestPage() { }; return ( -
-

Zombie Map Simulator

+ + Zombie Map Simulator
@@ -50,6 +51,6 @@ export default function TestPage() { Test All Models
-
+ ); } diff --git a/components/Page.tsx b/components/Page.tsx new file mode 100644 index 0000000..d9dc8c9 --- /dev/null +++ b/components/Page.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +export const PageTitle = (props: { children: React.ReactNode }) => { + return ( +

{props.children}

+ ); +}; + +export const Page = (props: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
+ {props.children} +
+ ); +}; diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 8af250a..daebf20 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -50,7 +50,7 @@ export function Visualizer({ function startSimulation() { simulator.current = new ZombieSurvival(map); - renderer.current?.render(simulator.current.getAllAliveEntities()); + renderer.current?.render(simulator.current); setRunning(true); interval.current = setInterval(() => { @@ -60,7 +60,7 @@ export function Visualizer({ if (!simulator.current.finished()) { simulator.current.step(); - renderer.current?.render(simulator.current.getAllAliveEntities()); + renderer.current?.render(simulator.current); return; } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..95b0d38 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/convex/users.ts b/convex/users.ts index 30dbeb7..396a284 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -4,8 +4,8 @@ import { customMutation, customQuery, } from "convex-helpers/server/customFunctions"; -import { mutation, query } from "./_generated/server"; import { ConvexError } from "convex/values"; +import { mutation, query } from "./_generated/server"; export const viewer = query({ args: {}, @@ -101,6 +101,8 @@ export const adminMutationBuilder = customMutation( }), ); +export const SIGN_IN_ERROR_MESSAGE = + "You must be signed in to perform this action"; export const authenticatedMutation = customMutation( mutation, @@ -108,11 +110,11 @@ export const authenticatedMutation = customMutation( const userId = await getAuthUserId(ctx); if (userId === null) { - throw new ConvexError("You must be signed in to perform this action"); + throw new ConvexError(SIGN_IN_ERROR_MESSAGE); } return { userId, }; }), -); +); diff --git a/models/claude-3-5-sonnet.ts b/models/claude-3-5-sonnet.ts index 196423e..c848f13 100644 --- a/models/claude-3-5-sonnet.ts +++ b/models/claude-3-5-sonnet.ts @@ -1,4 +1,4 @@ -import { type ModelHandler, getValidLocations } from "."; +import { type ModelHandler } from "."; import { Anthropic } from "@anthropic-ai/sdk"; import { z } from "zod"; @@ -8,7 +8,11 @@ const responseSchema = z.object({ reasoning: z.string(), }); -export const claude35sonnet: ModelHandler = async (prompt, map, config) => { +export const claude35sonnet: ModelHandler = async ( + systemPrompt, + userPrompt, + config, +) => { const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, }); @@ -18,15 +22,11 @@ export const claude35sonnet: ModelHandler = async (prompt, map, config) => { max_tokens: config.maxTokens, temperature: config.temperature, top_p: config.topP, - system: prompt, + system: systemPrompt, messages: [ { role: "user", - content: ` -Grid: ${JSON.stringify(map)} - -Valid Locations: ${JSON.stringify(getValidLocations(map))} -`, + content: userPrompt, }, ], }); diff --git a/models/gemini-1.5-pro.ts b/models/gemini-1.5-pro.ts index cba3561..81a21cf 100644 --- a/models/gemini-1.5-pro.ts +++ b/models/gemini-1.5-pro.ts @@ -1,4 +1,4 @@ -import { type ModelHandler, getValidLocations } from "."; +import { type ModelHandler } from "."; import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai"; interface GeminiResponse { @@ -47,12 +47,16 @@ const responseSchema = { required: ["map", "reasoning", "playerCoordinates", "boxCoordinates"], }; -export const gemini15pro: ModelHandler = async (prompt, map, config) => { +export const gemini15pro: ModelHandler = async ( + systemPrompt, + userPrompt, + config, +) => { const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); const model = genAI.getGenerativeModel({ model: "gemini-1.5-pro", - systemInstruction: prompt, + systemInstruction: systemPrompt, generationConfig: { responseMimeType: "application/json", responseSchema, @@ -62,11 +66,7 @@ export const gemini15pro: ModelHandler = async (prompt, map, config) => { }, }); - const result = await model.generateContent(` -Grid: ${JSON.stringify(map)} - -Valid Locations: ${JSON.stringify(getValidLocations(map))} -`); + const result = await model.generateContent(userPrompt); const parsedResponse = JSON.parse(result.response.text()) as GeminiResponse; return { diff --git a/models/gpt-4o.ts b/models/gpt-4o.ts index b9d2a2b..c018648 100644 --- a/models/gpt-4o.ts +++ b/models/gpt-4o.ts @@ -1,4 +1,4 @@ -import { type ModelHandler, getValidLocations } from "."; +import { type ModelHandler } from "."; import OpenAI from "openai"; import { zodResponseFormat } from "openai/helpers/zod"; import { z } from "zod"; @@ -9,7 +9,7 @@ const responseSchema = z.object({ boxCoordinates: z.array(z.array(z.number())), }); -export const gpt4o: ModelHandler = async (prompt, map, config) => { +export const gpt4o: ModelHandler = async (systemPrompt, userPrompt, config) => { const openai = new OpenAI(); const completion = await openai.beta.chat.completions.parse({ @@ -20,15 +20,11 @@ export const gpt4o: ModelHandler = async (prompt, map, config) => { messages: [ { role: "system", - content: prompt, + content: systemPrompt, }, { role: "user", - content: ` -Grid: ${JSON.stringify(map)} - -Valid Locations: ${JSON.stringify(getValidLocations(map))} -`, + content: userPrompt, }, ], response_format: zodResponseFormat(responseSchema, "game_map"), diff --git a/models/index.ts b/models/index.ts index 2d25e5a..549f068 100644 --- a/models/index.ts +++ b/models/index.ts @@ -12,20 +12,9 @@ export interface ModelHandlerConfig { topP: number; } -export function getValidLocations(map: string[][]) { - return map.flatMap((row, y) => - row.reduce((acc, cell, x) => { - if (cell === " ") { - acc.push([y, x]); - } - return acc; - }, [] as number[][]), - ); -} - export type ModelHandler = ( - prompt: string, - map: string[][], + systemPrompt: string, + userPrompt: string, config: ModelHandlerConfig, ) => Promise<{ boxCoordinates: number[][]; @@ -54,29 +43,33 @@ export async function runModel( reasoning: string; error?: string; }> { + const userPrompt = + `Grid: ${JSON.stringify(map)}\n\n` + + `Valid Locations: ${JSON.stringify(ZombieSurvival.validLocations(map))}`; + let result; let reasoning: string | null = null; try { switch (modelId) { case "gemini-1.5-pro": { - result = await gemini15pro(prompt, map, CONFIG); + result = await gemini15pro(prompt, userPrompt, CONFIG); break; } case "gpt-4o": { - result = await gpt4o(prompt, map, CONFIG); + result = await gpt4o(prompt, userPrompt, CONFIG); break; } case "claude-3.5-sonnet": { - result = await claude35sonnet(prompt, map, CONFIG); + result = await claude35sonnet(prompt, userPrompt, CONFIG); break; } case "perplexity-llama-3.1": { - result = await perplexityLlama31(prompt, map, CONFIG); + result = await perplexityLlama31(prompt, userPrompt, CONFIG); break; } case "mistral-large-2": { - result = await mistralLarge2(prompt, map, CONFIG); + result = await mistralLarge2(prompt, userPrompt, CONFIG); break; } default: { diff --git a/models/mistral-large-2.ts b/models/mistral-large-2.ts index 7a67f7b..64bb21b 100644 --- a/models/mistral-large-2.ts +++ b/models/mistral-large-2.ts @@ -1,4 +1,4 @@ -import { type ModelHandler, getValidLocations } from "."; +import { type ModelHandler } from "."; import { isJSON } from "../lib/utils"; import { Mistral } from "@mistralai/mistralai"; import { z } from "zod"; @@ -9,7 +9,11 @@ const responseSchema = z.object({ boxCoordinates: z.array(z.array(z.number())), }); -export const mistralLarge2: ModelHandler = async (prompt, map, config) => { +export const mistralLarge2: ModelHandler = async ( + systemPrompt, + userPrompt, + config, +) => { const client = new Mistral(); const completion = await client.chat.complete({ @@ -20,15 +24,11 @@ export const mistralLarge2: ModelHandler = async (prompt, map, config) => { messages: [ { role: "system", - content: prompt, + content: systemPrompt, }, { role: "user", - content: ` -Grid: ${JSON.stringify(map)} - -Valid Locations: ${JSON.stringify(getValidLocations(map))} -`, + content: userPrompt, }, ], responseFormat: { diff --git a/models/perplexity-llama-3.1.ts b/models/perplexity-llama-3.1.ts index 1ea4016..98cd9d1 100644 --- a/models/perplexity-llama-3.1.ts +++ b/models/perplexity-llama-3.1.ts @@ -1,6 +1,6 @@ import { isJSON } from "../lib/utils"; import { z } from "zod"; -import { ModelHandler, getValidLocations } from "./index"; +import { ModelHandler } from "./index"; const completionSchema = z.object({ id: z.string(), @@ -36,7 +36,11 @@ const responseSchema = z.object({ reasoning: z.string(), }); -export const perplexityLlama31: ModelHandler = async (prompt, map, config) => { +export const perplexityLlama31: ModelHandler = async ( + systemPrompt, + userPrompt, + config, +) => { const completion = await fetch("https://api.perplexity.ai/chat/completions", { method: "POST", headers: { @@ -46,14 +50,10 @@ export const perplexityLlama31: ModelHandler = async (prompt, map, config) => { body: JSON.stringify({ model: "llama-3.1-sonar-large-128k-online", messages: [ - { role: "system", content: prompt }, + { role: "system", content: systemPrompt }, { role: "user", - content: ` -Grid: ${JSON.stringify(map)} - -Valid Locations: ${JSON.stringify(getValidLocations(map))} -`, + content: userPrompt, }, ], max_tokens: config.maxTokens, diff --git a/package.json b/package.json index 50b2a2a..58301a0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@google/generative-ai": "^0.21.0", "@icons-pack/react-simple-icons": "^10.1.0", "@mistralai/mistralai": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-select": "^2.1.2", @@ -34,7 +35,7 @@ "convex-helpers": "^0.1.61", "date-fns": "^4.1.0", "lucide-react": "^0.453.0", - "next": "14.2.5", + "next": "^14.2.16", "next-plausible": "^3.12.2", "next-themes": "^0.3.0", "openai": "^4.67.3", diff --git a/public/entities/box.svg b/public/entities/box.svg new file mode 100644 index 0000000..1237569 --- /dev/null +++ b/public/entities/box.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/entities/player-attacking.svg b/public/entities/player-attacking.svg new file mode 100644 index 0000000..a088201 --- /dev/null +++ b/public/entities/player-attacking.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/entities/player-idle.svg b/public/entities/player-idle.svg new file mode 100644 index 0000000..a857534 --- /dev/null +++ b/public/entities/player-idle.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/entities/rock.svg b/public/entities/rock.svg new file mode 100644 index 0000000..fa489df --- /dev/null +++ b/public/entities/rock.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/entities/zombie-idle.svg b/public/entities/zombie-idle.svg new file mode 100644 index 0000000..b757918 --- /dev/null +++ b/public/entities/zombie-idle.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/entities/zombie-walking.svg b/public/entities/zombie-walking.svg new file mode 100644 index 0000000..820bce1 --- /dev/null +++ b/public/entities/zombie-walking.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/renderer/index.ts b/renderer/index.ts index 93961b0..b25e4cc 100644 --- a/renderer/index.ts +++ b/renderer/index.ts @@ -1,4 +1,9 @@ -import { type Entity, EntityType } from "@/simulators/zombie-survival"; +import { + type Entity, + EntityType, + ZombieSurvival, +} from "@/simulators/zombie-survival"; +import { Change } from "@/simulators/zombie-survival/Change"; export interface RendererAssets { loading: boolean; @@ -8,7 +13,7 @@ export interface RendererAssets { player: HTMLImageElement | null; rock: HTMLImageElement | null; zombie: HTMLImageElement | null; - zombieHit: HTMLImageElement | null; + zombieWalking: HTMLImageElement | null; } const assets: RendererAssets = { @@ -19,7 +24,7 @@ const assets: RendererAssets = { player: null, rock: null, zombie: null, - zombieHit: null, + zombieWalking: null, }; async function loadAssets() { @@ -31,11 +36,11 @@ async function loadAssets() { const [bg, box, player, rock, zombie, zombieHit] = await Promise.all([ loadImage("/map.webp"), - loadImage("/entities/block.svg"), - loadImage("/entities/player_alive_1.svg"), - loadImage("/entities/rocks.svg"), - loadImage("/entities/zombie_alive_1.svg"), - loadImage("/entities/zombie_alive_2.svg"), + loadImage("/entities/box.svg"), + loadImage("/entities/player-attacking.svg"), + loadImage("/entities/rock.svg"), + loadImage("/entities/zombie-idle.svg"), + loadImage("/entities/zombie-walking.svg"), ]); assets.loaded = true; @@ -44,7 +49,7 @@ async function loadAssets() { assets.player = player; assets.rock = rock; assets.zombie = zombie; - assets.zombieHit = zombieHit; + assets.zombieWalking = zombieHit; } async function loadImage(src: string): Promise { @@ -67,8 +72,8 @@ function getEntityImage(entity: Entity): HTMLImageElement | null { return assets.rock; } case EntityType.Zombie: { - if (entity.getHealth() === 1) { - return assets.zombieHit; + if (entity.getChanges().includes(Change.Walking)) { + return assets.zombieWalking; } else { return assets.zombie; } @@ -76,49 +81,15 @@ function getEntityImage(entity: Entity): HTMLImageElement | null { } } -function getEntityOffset(entity: Entity): { x: number; y: number } { - switch (entity.getType()) { - case EntityType.Zombie: { - if (entity.getHealth() === 1) { - return { x: -2, y: 0 }; - } else { - return { x: 14, y: 0 }; - } - } - default: { - return { x: 0, y: 0 }; - } - } -} - -function getEntityRatio(entity: Entity): { width: number; height: number } { - switch (entity.getType()) { - case EntityType.Box: { - return { width: 0.87, height: 1 }; // 41x47 - } - case EntityType.Player: { - return { width: 1, height: 1 }; // 64x64 - } - case EntityType.Rock: { - return { width: 1, height: 0.76 }; // 67x51 - } - case EntityType.Zombie: { - if (entity.getHealth() === 1) { - return { width: 0.61, height: 1 }; // 40x65 - } else { - return { width: 1, height: 1 }; // 64x64 - } - } - } -} - export class Renderer { private readonly assets: RendererAssets; private readonly cellSize: number; private readonly h: number; private readonly w: number; + private canvas2: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; + private ctx2: CanvasRenderingContext2D; public constructor( boardHeight: number, @@ -131,24 +102,37 @@ export class Renderer { this.h = boardHeight * cellSize; this.w = boardWidth * cellSize; + this.canvas2 = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const ctx2 = this.canvas2.getContext("2d"); - if (ctx === null) { + if (ctx === null || ctx2 === null) { throw new Error("Unable to get 2d context"); } this.ctx = ctx; + this.ctx2 = ctx2; - canvas.setAttribute("height", `${this.h * window.devicePixelRatio}`); - canvas.setAttribute("width", `${this.w * window.devicePixelRatio}`); + canvas.height = this.h * window.devicePixelRatio; + canvas.width = this.w * window.devicePixelRatio; canvas.style.height = `${this.h}px`; canvas.style.width = `${this.w}px`; + this.canvas2.width = this.cellSize * window.devicePixelRatio; + this.canvas2.height = this.cellSize * window.devicePixelRatio; + this.canvas2.style.height = `${this.cellSize}px`; + this.canvas2.style.width = `${this.cellSize}px`; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + ctx2.scale(window.devicePixelRatio, window.devicePixelRatio); + void loadAssets(); } - public render(entities: Entity[]) { + public render(simulator: ZombieSurvival) { + const entities = simulator.getAllEntities(); + this.ctx.clearRect(0, 0, this.w, this.h); this.drawBg(); @@ -185,6 +169,10 @@ export class Renderer { } private drawEntity(entity: Entity) { + if (entity.dead()) { + return; + } + const entityImage = getEntityImage(entity); if (entityImage === null) { @@ -192,26 +180,24 @@ export class Renderer { } const entityPosition = entity.getPosition(); - const entityOffset = getEntityOffset(entity); - const entityScale = getEntityRatio(entity); - - this.ctx.globalAlpha = - entity.getType() === EntityType.Zombie && entity.getHealth() === 1 - ? 0.5 - : 1; - - this.ctx.drawImage( - entityImage, - entityPosition.x * this.cellSize + - ((1 - entityScale.width) / 2) * this.cellSize + - entityOffset.x, - entityPosition.y * this.cellSize + - ((1 - entityScale.height) / 2) * this.cellSize + - entityOffset.y, - this.cellSize * entityScale.width, - this.cellSize * entityScale.height, - ); - - this.ctx.globalAlpha = 1; + const x = entityPosition.x * this.cellSize; + const y = entityPosition.y * this.cellSize; + + if (entity.getChanges().includes(Change.Hit)) { + this.ctx2.clearRect(0, 0, this.cellSize, this.cellSize); + + this.ctx2.filter = "hue-rotate(300deg)"; + this.ctx2.drawImage(entityImage, 0, 0, this.cellSize, this.cellSize); + this.ctx2.filter = "none"; + + this.ctx2.globalCompositeOperation = "destination-in"; + this.ctx2.fillRect(0, 0, this.cellSize, this.cellSize); + this.ctx2.globalCompositeOperation = "source-over"; + + this.ctx.drawImage(this.canvas2, x, y, this.cellSize, this.cellSize); + return; + } + + this.ctx.drawImage(entityImage, x, y, this.cellSize, this.cellSize); } } diff --git a/simulators/zombie-survival/Change.ts b/simulators/zombie-survival/Change.ts new file mode 100644 index 0000000..755c9d7 --- /dev/null +++ b/simulators/zombie-survival/Change.ts @@ -0,0 +1,5 @@ +export enum Change { + Hit, + Killed, + Walking, +} diff --git a/simulators/zombie-survival/Position.ts b/simulators/zombie-survival/Position.ts index 09614ba..74d7653 100644 --- a/simulators/zombie-survival/Position.ts +++ b/simulators/zombie-survival/Position.ts @@ -2,3 +2,7 @@ export interface Position { x: number; y: number; } + +export function samePosition(p1: Position, p2: Position): boolean { + return p1.x === p2.x && p1.y === p2.y; +} diff --git a/simulators/zombie-survival/ZombieSurvival.ts b/simulators/zombie-survival/ZombieSurvival.ts index cb9329a..263e5cd 100644 --- a/simulators/zombie-survival/ZombieSurvival.ts +++ b/simulators/zombie-survival/ZombieSurvival.ts @@ -1,4 +1,5 @@ -import { Position } from "./Position"; +import { Change } from "./Change"; +import { Position, samePosition } from "./Position"; import { Box } from "./entities/Box"; import { Entity } from "./entities/Entity"; import { Player } from "./entities/Player"; @@ -62,6 +63,17 @@ export class ZombieSurvival { return map.length === 0 || map[0].length === 0; } + public static validLocations(map: string[][]): number[][] { + return map.flatMap((row, y) => + row.reduce((acc, cell, x) => { + if (cell === " ") { + acc.push([y, x]); + } + return acc; + }, [] as number[][]), + ); + } + public constructor(config: string[][]) { if (ZombieSurvival.mapIsEmpty(config)) { throw new Error("Config is empty"); @@ -182,14 +194,30 @@ export class ZombieSurvival { } public step() { + const initialHealth = this.zombies.map((zombie) => zombie.getHealth()); + + this.player.clearChanges(); this.player.shoot(); - for (const zombie of this.zombies) { + for (let i = 0; i < this.zombies.length; i++) { + const zombie = this.zombies[i]; + if (this.player.dead()) { break; } + const initialPosition = zombie.getPosition(); + + zombie.clearChanges(); zombie.walk(); + + if (initialHealth[i] !== zombie.getHealth()) { + zombie.addChange(Change.Hit); + } + + if (!samePosition(initialPosition, zombie.getPosition())) { + zombie.addChange(Change.Walking); + } } } } diff --git a/simulators/zombie-survival/entities/Entity.ts b/simulators/zombie-survival/entities/Entity.ts index 92d911f..7ebee97 100644 --- a/simulators/zombie-survival/entities/Entity.ts +++ b/simulators/zombie-survival/entities/Entity.ts @@ -1,3 +1,4 @@ +import { type Change } from "../Change"; import { Position } from "../Position"; export enum EntityType { @@ -9,6 +10,7 @@ export enum EntityType { export class Entity { protected destructible: boolean; + protected changes: Change[]; protected health: number; protected position: Position; protected type: EntityType; @@ -20,15 +22,28 @@ export class Entity { position: Position, ) { this.destructible = destructible; + this.changes = []; this.health = health; this.position = position; this.type = type; } + public addChange(change: Change): void { + this.changes.push(change); + } + + public clearChanges(): void { + this.changes = []; + } + public dead(): boolean { return this.health === 0; } + public getChanges(): Change[] { + return this.changes; + } + public getPosition(): Position { return this.position; }