- 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 && (
-
+ <>
+
+
+ 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 && (
-
- )}
-
- {isAdmin && !userPlaying && solution === null && (
- <>
-
-
-
Model (~$0.002)
-
+ >
+ )}
+
+ {error !== null && (
+
{error}
+ )}
+ {visualizingUserSolution &&
}
+ {reasoning !== null && (
+
- >
- )}
-
-
-
-
+ )}
+
+ {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 (
-
+
);
}
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;
}