diff --git a/app/admin/review/page.tsx b/app/admin/review/page.tsx
index 2071686..ad5b201 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 { PlayMapButton } from "@/components/PlayMapButton";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -12,16 +13,17 @@ import {
} from "@/components/ui/card";
import { api } from "@/convex/_generated/api";
-const Page = () => {
+export default function AdminReviewPage() {
const isAdmin = useQuery(api.users.isAdmin);
const maps = useQuery(api.maps.getMaps, { isReviewed: false });
const adminApprovalMutation = useMutation(api.maps.approveMap);
+ const adminRejectMapMutation = useMutation(api.maps.rejectMap);
if (isAdmin == true) {
return (
Review Maps
-
+
{maps?.map((map) => (
@@ -32,7 +34,7 @@ const Page = () => {
-
+
+
+
))}
@@ -50,6 +61,4 @@ const Page = () => {
}
return
Not an admin
;
-};
-
-export default Page;
+}
diff --git a/app/header.tsx b/app/header.tsx
index 9e6720f..9238bab 100644
--- a/app/header.tsx
+++ b/app/header.tsx
@@ -45,7 +45,7 @@ export default function Header() {
return (
-
+
SurviveTheNight
@@ -99,11 +99,18 @@ export default function Header() {
{!isAuthenticated ? (
diff --git a/app/play/page.tsx b/app/play/page.tsx
index 332da1e..bb8f0c5 100644
--- a/app/play/page.tsx
+++ b/app/play/page.tsx
@@ -1,7 +1,13 @@
"use client";
import { useEffect, useState } from "react";
-import { Authenticated, Unauthenticated, useQuery } from "convex/react";
+import {
+ Authenticated,
+ Unauthenticated,
+ useMutation,
+ useQuery,
+} from "convex/react";
+import { TrashIcon } from "lucide-react";
import Link from "next/link";
import { Map as GameMap } from "@/components/Map";
import { Button } from "@/components/ui/button";
@@ -18,9 +24,11 @@ import { api } from "@/convex/_generated/api";
import { cn } from "@/lib/utils";
export default function PlayPage() {
+ const isAdmin = useQuery(api.users.isAdmin);
const maps = useQuery(api.maps.getMaps, {});
const userMapResults = useQuery(api.playerresults.getUserMapStatus);
const mapCountResults = useQuery(api.playerresults.getMapsWins);
+ const adminDeleteMapMutation = useMutation(api.maps.deleteMap);
const [resMap, setResMap] = useState(new Map());
const [countMap, setCountMap] = useState(new Map());
@@ -128,9 +136,24 @@ export default function PlayPage() {
)}
>
-
- Night #{map.level}
-
+
+
+ Night #{map.level}
+
+ {isAdmin && (
+
+ )}
+
diff --git a/app/playground/page.tsx b/app/playground/page.tsx
index 7455877..41ece90 100644
--- a/app/playground/page.tsx
+++ b/app/playground/page.tsx
@@ -2,19 +2,19 @@
import * as React from "react";
import { useAction, useMutation, useQuery } from "convex/react";
-import { CircleAlertIcon, EraserIcon, SendIcon, UploadIcon, ChevronLeft } from "lucide-react";
-import { CopyMapButton } from "@/components/CopyMapButton";
-import { MapBuilder } from "@/components/MapBuilder";
+import { ChevronLeft, UploadIcon } from "lucide-react";
+import { useSearchParams } from "next/navigation";
+import { Map } from "@/components/Map";
import { MapStatus } from "@/components/MapStatus";
import { ModelSelector } from "@/components/ModelSelector";
import { Visualizer } from "@/components/Visualizer";
import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
import { useToast } from "@/components/ui/use-toast";
import { api } from "@/convex/_generated/api";
+import { Id } from "@/convex/_generated/dataModel";
import { errorMessage } from "@/lib/utils";
import { ZombieSurvival } from "@/simulators/zombie-survival";
-import { Card } from "@/components/ui/card";
-import { Map } from "@/components/Map";
const STORAGE_MAP_KEY = "playground-map";
@@ -22,6 +22,13 @@ export default function PlaygroundPage() {
const isAdmin = useQuery(api.users.isAdmin);
const submitMap = useMutation(api.maps.submitMap);
const testMap = useAction(api.maps.testMap);
+ const searchParams = useSearchParams();
+ const mapId = searchParams.get("map") as Id<"maps"> | null;
+ const adminMapMaybe = useQuery(
+ api.maps.adminGetMapById,
+ !isAdmin || mapId === null ? "skip" : { mapId },
+ );
+ const adminMap = adminMapMaybe ?? null;
const { toast } = useToast();
const [map, setMap] = React.useState([
[" ", " ", " ", " ", " "],
@@ -99,7 +106,9 @@ export default function PlaygroundPage() {
function handleChangeMap(value: string[][]) {
setMap(value);
setError(null);
- window.localStorage.setItem(STORAGE_MAP_KEY, JSON.stringify(value));
+ if (adminMap === null) {
+ window.localStorage.setItem(STORAGE_MAP_KEY, JSON.stringify(value));
+ }
}
function handleChangeModel(value: string) {
@@ -112,10 +121,10 @@ export default function PlaygroundPage() {
setReasoning(null);
setUserPlaying(false);
setVisualizingUserSolution(false);
-
+
// Remove players and blocks from the map
- const cleanedMap = map.map(row =>
- row.map(cell => (cell === "P" || cell === "B") ? " " : cell)
+ const cleanedMap = map.map((row) =>
+ row.map((cell) => (cell === "P" || cell === "B" ? " " : cell)),
);
setMap(cleanedMap);
window.localStorage.setItem(STORAGE_MAP_KEY, JSON.stringify(cleanedMap));
@@ -169,6 +178,12 @@ export default function PlaygroundPage() {
}
}, []);
+ React.useEffect(() => {
+ if (adminMap !== null) {
+ setMap(adminMap.grid);
+ }
+ }, [adminMap]);
+
const visualizing = solution !== null || visualizingUserSolution;
return (
@@ -180,16 +195,20 @@ export default function PlaygroundPage() {
{!visualizing && !userPlaying && (
-
- Click on the board to place or remove units. Use the buttons below to switch between unit types.
+
+ 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.
+
+ Place a player (P) and blocks (B) on the board to create your
+ escape route. Click to toggle between empty, player, and block.
)}
-
+
{visualizing && (
{
@@ -223,8 +246,12 @@ export default function PlaygroundPage() {
: [...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;
+ 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 === " ") {
@@ -252,7 +279,7 @@ export default function PlaygroundPage() {
: handleChangeMap(newMap);
}}
/>
- ))
+ )),
)}
diff --git a/app/prompts/page.tsx b/app/prompts/page.tsx
index 91b4dc3..466feeb 100644
--- a/app/prompts/page.tsx
+++ b/app/prompts/page.tsx
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/table";
import { api } from "@/convex/_generated/api";
-const Page = () => {
+export default function PromptsPage() {
const prompts = useQuery(api.prompts.getAllPrompts);
const enablePrompt = useMutation(api.prompts.enablePrompt);
const deletePrompt = useMutation(api.prompts.deletePrompt);
@@ -91,6 +91,4 @@ const Page = () => {
);
-};
-
-export default Page;
+}
diff --git a/components/PlayMapButton.tsx b/components/PlayMapButton.tsx
new file mode 100644
index 0000000..21ec6a2
--- /dev/null
+++ b/components/PlayMapButton.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import * as React from "react";
+import { ExternalLinkIcon } from "lucide-react";
+import Link from "next/link";
+import { Button } from "./ui/button";
+
+export function PlayMapButton({ mapId }: { mapId: string }) {
+ return (
+
+ );
+}
diff --git a/convex/maps.ts b/convex/maps.ts
index 003dc04..9841252 100644
--- a/convex/maps.ts
+++ b/convex/maps.ts
@@ -11,7 +11,11 @@ import {
query,
} from "./_generated/server";
import { Prompt } from "./prompts";
-import { adminMutationBuilder, authenticatedMutation } from "./users";
+import {
+ adminMutationBuilder,
+ adminQueryBuilder,
+ authenticatedMutation,
+} from "./users";
const LEVELS = [
{
@@ -244,6 +248,47 @@ export const approveMap = adminMutationBuilder({
},
});
+export const rejectMap = adminMutationBuilder({
+ args: {
+ mapId: v.id("maps"),
+ },
+ handler: async (ctx, args) => {
+ await ctx.db.delete(args.mapId);
+ },
+});
+
+export const deleteMap = adminMutationBuilder({
+ args: {
+ mapId: v.id("maps"),
+ },
+ handler: async (ctx, args) => {
+ const map = await ctx.db.get(args.mapId);
+
+ if (map === null) {
+ return;
+ }
+
+ await ctx.db.delete(args.mapId);
+
+ if (map.level === undefined) {
+ return;
+ }
+
+ const higherLevelMaps = await ctx.db
+ .query("maps")
+ .withIndex("by_level", (q) => q.gt("level", map.level))
+ .collect();
+
+ await Promise.all(
+ higherLevelMaps.map(async (higherLevelMap) => {
+ return await ctx.db.patch(higherLevelMap._id, {
+ level: higherLevelMap.level! - 1,
+ });
+ }),
+ );
+ },
+});
+
export const getMapByLevel = query({
args: { level: v.number() },
handler: async (ctx, args) => {
@@ -254,6 +299,15 @@ export const getMapByLevel = query({
},
});
+export const adminGetMapById = adminQueryBuilder({
+ args: {
+ mapId: v.id("maps"),
+ },
+ handler: async (ctx, args) => {
+ return await ctx.db.get(args.mapId);
+ },
+});
+
export const playMapAction = internalAction({
args: {
gameId: v.id("games"),
diff --git a/public/entities/rocks.png b/public/entities/rocks.png
deleted file mode 100644
index 1f2e53a..0000000
Binary files a/public/entities/rocks.png and /dev/null differ
diff --git a/renderer/index.ts b/renderer/index.ts
index f32827b..505efd8 100644
--- a/renderer/index.ts
+++ b/renderer/index.ts
@@ -8,6 +8,7 @@ export interface RendererAssets {
player: HTMLImageElement | null;
rock: HTMLImageElement | null;
zombie: HTMLImageElement | null;
+ zombieHit: HTMLImageElement | null;
}
const assets: RendererAssets = {
@@ -18,6 +19,7 @@ const assets: RendererAssets = {
player: null,
rock: null,
zombie: null,
+ zombieHit: null,
};
async function loadAssets() {
@@ -27,12 +29,13 @@ async function loadAssets() {
assets.loading = true;
- const [bg, box, player, rock, zombie] = await Promise.all([
+ const [bg, box, player, rock, zombie, zombieHit] = await Promise.all([
loadImage("/map.png"),
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"),
]);
assets.loaded = true;
@@ -41,6 +44,7 @@ async function loadAssets() {
assets.player = player;
assets.rock = rock;
assets.zombie = zombie;
+ assets.zombieHit = zombieHit;
}
async function loadImage(src: string): Promise
{
@@ -63,16 +67,49 @@ function getEntityImage(entity: Entity): HTMLImageElement | null {
return assets.rock;
}
case EntityType.Zombie: {
- return assets.zombie;
+ if (entity.getHealth() === 1) {
+ return assets.zombieHit;
+ } else {
+ return assets.zombie;
+ }
}
}
}
function getEntityOffset(entity: Entity): { x: number; y: number } {
- return {
- x: entity.getType() === EntityType.Zombie ? 16 : 0,
- y: 0,
- };
+ 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 {
@@ -118,8 +155,6 @@ export class Renderer {
for (const entity of entities) {
this.drawEntity(entity);
}
-
- this.ctx.globalAlpha = 1.0;
}
private drawBg() {
@@ -158,6 +193,7 @@ 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
@@ -166,10 +202,16 @@ export class Renderer {
this.ctx.drawImage(
entityImage,
- entityPosition.x * this.cellSize + entityOffset.x,
- entityPosition.y * this.cellSize + entityOffset.y,
- this.cellSize,
- this.cellSize,
+ 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;
}
}