Skip to content

Commit

Permalink
Merge pull request #94 from webdevcody/fix-visualizer-perfomance
Browse files Browse the repository at this point in the history
Improved Visualizer performance
  • Loading branch information
webdevcody authored Oct 18, 2024
2 parents 036a89d + 0b0d137 commit f6a0495
Show file tree
Hide file tree
Showing 10 changed files with 162 additions and 94 deletions.
4 changes: 2 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ export default function GamePage() {
}

return (
<div className="min-h-screen container mx-auto pt-12 pb-24 flex gap-12">
<div className="container mx-auto pt-12 flex gap-12">
<div className="space-y-8 flex-grow">
<h1 className="text-2xl font-bold">Recent Games</h1>
<div className="h-[80vh] overflow-y-auto flex flex-col gap-4">
<div className="h-[calc(100vh_-_185px)] overflow-y-auto flex flex-col gap-4">
{results.map((result) => (
<Result key={result._id} result={result} />
))}
Expand Down
56 changes: 43 additions & 13 deletions app/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import React from "react";
import { CircleAlertIcon } from "lucide-react";
import { CircleAlertIcon, EraserIcon } from "lucide-react";
import { useAction, useMutation, useQuery } from "convex/react";
import { Button } from "@/components/ui/button";
import { CopyMapButton } from "@/components/CopyMapButton";
Expand All @@ -14,12 +14,14 @@ import { api } from "@/convex/_generated/api";
import { errorMessage } from "@/lib/utils";
import { useToast } from "@/components/ui/use-toast";

const STORAGE_MAP_KEY = "playground-map";

export default function PlaygroundPage() {
const isAdmin = useQuery(api.users.isAdmin);
const publishMap = useMutation(api.maps.publishMap);
const testMap = useAction(api.maps.testMap);
const { toast } = useToast();
const [map, setMap] = React.useState<string[][]>([]);
const [map, setMap] = React.useState<string[][]>([[" "]]);
const [model, setModel] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [solution, setSolution] = React.useState<string[][] | null>(null);
Expand Down Expand Up @@ -89,6 +91,7 @@ export default function PlaygroundPage() {
function handleChangeMap(value: string[][]) {
setMap(value);
setError(null);
window.localStorage.setItem(STORAGE_MAP_KEY, JSON.stringify(value));
}

function handleChangeModel(value: string) {
Expand All @@ -103,6 +106,18 @@ export default function PlaygroundPage() {
setVisualizingUserSolution(false);
}

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");
Expand Down Expand Up @@ -131,6 +146,14 @@ export default function PlaygroundPage() {
setVisualizingUserSolution(false);
}

React.useEffect(() => {
const maybeMap = window.localStorage.getItem(STORAGE_MAP_KEY);

if (maybeMap !== null) {
setMap(JSON.parse(maybeMap));
}
}, []);

const visualizing = solution !== null || visualizingUserSolution;

return (
Expand All @@ -143,9 +166,16 @@ export default function PlaygroundPage() {
<div className="flex flex-col gap-0">
<div className="flex gap-2">
<p>
Map ({map.length}x{map[0]?.length ?? 0})
Map ({ZombieSurvival.boardWidth(map)}x
{ZombieSurvival.boardHeight(map)})
</p>
<CopyMapButton map={map} />
<button
className="hover:scale-125 transition"
onClick={handleReset}
>
<EraserIcon size={16} />
</button>
</div>
<p className="text-xs">* Click on a cell to place entity</p>
</div>
Expand Down Expand Up @@ -199,18 +229,18 @@ export default function PlaygroundPage() {
{simulating ? "Simulating..." : "Play With AI"}
</Button>
)}
{(solution !== null || userPlaying) && (
<Button
className="w-full"
disabled={model === "" || simulating}
onClick={handleEdit}
type="button"
>
{simulating ? "Simulating..." : "Edit"}
</Button>
)}
</>
)}
{(solution !== null || userPlaying) && (
<Button
className="w-full"
disabled={model === "" || simulating}
onClick={handleEdit}
type="button"
>
{simulating ? "Simulating..." : "Edit"}
</Button>
)}
{solution === null && !simulating && !userPlaying && (
<Button className="w-full" onClick={handleUserPlay} type="button">
Play Yourself
Expand Down
4 changes: 2 additions & 2 deletions components/MapBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function MapBuilder({
newValue = initialValue === " " ? "Z" : initialValue === "Z" ? "R" : " ";
}

const newMap = [...map.map((row) => [...row])];
const newMap = ZombieSurvival.cloneMap(map);
newMap[row][cell] = newValue;
onChange(newMap);
}
Expand Down Expand Up @@ -83,7 +83,7 @@ export function MapBuilder({
const moreThanOneCell = map[0].length > 1;

const buttonClassName = cn(
"border border-white w-[64px] h-[64px] disabled:opacity-50",
"border border-white w-[64px] h-[64px] disabled:opacity-50 select-none",
);

const controlClassName = cn(
Expand Down
2 changes: 0 additions & 2 deletions components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ export function ThemeToggle() {
// This check is needed because if the user clicks on a button twice the button gets unselected and the newTheme is undefined
if (newTheme) {
setTheme(newTheme);
} else {
console.log("No theme selected, keeping current theme:", theme);
}
}}
className="flex px-1 py-1 rounded-md bg-blue-200 dark:bg-slate-700"
Expand Down
146 changes: 74 additions & 72 deletions components/Visualizer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { EntityType, ZombieSurvival } from "@/simulators/zombie-survival";
import { useEffect, useRef, useState } from "react";
import { getCellImage } from "@/components/Map";

const AUTO_REPLAY_SPEED = 1_500;
Expand All @@ -19,57 +19,60 @@ export function Visualizer({
controls?: boolean;
cellSize?: string;
map: string[][];
onSimulationEnd?: (isWin: boolean) => Promise<void>;
onSimulationEnd?: (isWin: boolean) => unknown;
}) {
const simulator = useRef<ZombieSurvival | null>(null);
const interval = useRef<NodeJS.Timeout | null>(null);
const timeout = useRef<NodeJS.Timeout | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [mapState, setMapState] = useState(map);
const [needsReset, setNeedsReset] = useState(false);
const simulator = React.useRef<ZombieSurvival>(new ZombieSurvival(map));
const interval = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const timeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const ref = React.useRef<HTMLDivElement | null>(null);
const [running, setRunning] = React.useState(false);
const [startedAt, setStartedAt] = React.useState(Date.now());
const [, setRenderedAt] = React.useState(Date.now());

const startSimulation = () => {
setNeedsReset(true);
const clonedMap = JSON.parse(JSON.stringify(map));
simulator.current = new ZombieSurvival(clonedMap);
setMapState(simulator.current!.getState());
setIsRunning(true);
function stepSimulation() {
if (simulator.current === null) {
return;
}

interval.current = setInterval(async () => {
if (simulator.current!.finished()) {
clearInterval(interval.current!);
interval.current = null;
if (!simulator.current.finished()) {
simulator.current.step();
setRenderedAt(Date.now());
return;
}

if (autoReplay) {
timeout.current = setTimeout(() => {
timeout.current = null;
startSimulation();
}, AUTO_REPLAY_SPEED);
clearInterval(interval.current!);
interval.current = null;

return;
}
if (autoReplay) {
timeout.current = setTimeout(() => {
timeout.current = null;
startSimulation();
}, AUTO_REPLAY_SPEED);

setIsRunning(false);
return;
}

if (onSimulationEnd) {
await onSimulationEnd(!simulator.current!.getPlayer().dead());
}
setRunning(false);

return;
}
if (onSimulationEnd) {
onSimulationEnd(!simulator.current.getPlayer().dead());
}
}

simulator.current!.step();
setMapState(simulator.current!.getState());
}, REPLAY_SPEED);
};
function startSimulation() {
simulator.current = new ZombieSurvival(map);
setStartedAt(Date.now());
setRunning(true);
interval.current = setInterval(stepSimulation, REPLAY_SPEED);
}

useEffect(() => {
React.useEffect(() => {
if (autoStart) {
startSimulation();
}
}, [autoStart]);

useEffect(() => {
React.useEffect(() => {
return () => {
if (interval.current) {
clearInterval(interval.current);
Expand All @@ -80,60 +83,59 @@ export function Visualizer({
};
}, []);

const entities = simulator.current.getAllEntities() ?? [];
const cellSizeNum = Number.parseInt(cellSize, 10);

return (
<>
<div className="relative">
<div className="relative" ref={ref}>
<img
src="/map.png"
alt="Background Map"
className="absolute inset-0 w-full h-full object-cover opacity-50"
/>
<div className="relative z-10">
{mapState.map((row, y) => (
<div key={y} className="flex">
{row.map((cell, x) => (
<div
key={x}
style={{
width: `${cellSize}px`,
height: `${cellSize}px`,
fontSize: `${parseInt(cellSize) / 2}px`,
opacity: (() => {
const entity = simulator.current?.getEntityAt({
x,
y,
});
if (
entity?.getType() === EntityType.Zombie &&
entity.getHealth() === 1
) {
return 0.5;
}
return 1;
})(),
}}
className={`border flex items-center justify-center`}
>
{getCellImage(cell)}
</div>
))}
<div
className="relative z-10"
style={{
height: `${ZombieSurvival.boardHeight(map) * cellSizeNum}px`,
width: `${ZombieSurvival.boardWidth(map) * cellSizeNum}px`,
}}
>
{entities.map((entity, idx) => (
<div
className="flex items-center justify-center absolute transition-all"
key={`${startedAt}.${entity.toConfig()}.${idx}`}
style={{
fontSize: `${parseInt(cellSize) / 2}px`,
height: `${cellSize}px`,
left: `${entity.getPosition().x * cellSizeNum}px`,
opacity:
entity.getType() === EntityType.Zombie &&
entity.getHealth() === 1
? 0.5
: entity.getHealth() === 0
? 0
: 1,
top: `${entity.getPosition().y * cellSizeNum}px`,
width: `${cellSize}px`,
}}
>
{getCellImage(entity.toConfig())}
</div>
))}
</div>
</div>
<div>
{controls && (
<div className="flex gap-2 justify-center py-2">
<Button onClick={startSimulation} disabled={isRunning}>
<Button onClick={startSimulation} disabled={running}>
Replay
</Button>
<Button
disabled={isRunning}
disabled={running}
onClick={() => {
simulator.current = new ZombieSurvival(map);
setMapState(simulator.current!.getState());
setIsRunning(false);
setNeedsReset(false);
setRunning(false);
}}
>
Reset
Expand Down
4 changes: 2 additions & 2 deletions convex/maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const publishMap = adminMutationBuilder({
.filter((q) => q.neq("level", undefined))
.collect();

const lastLevel = maps.sort((a, b) => b.level! - a.level!)[120].level!;
const lastLevel = maps.sort((a, b) => b.level! - a.level!)[0].level!;

await ctx.db.insert("maps", {
grid: args.map,
Expand Down Expand Up @@ -284,7 +284,7 @@ export const playMapAction = internalAction({
}

if (process.env.MOCK_MODELS === "true") {
const existingMap = [...map.grid.map((row: string[]) => [...row])];
const existingMap = ZombieSurvival.cloneMap(map.grid);

existingMap[0][0] = "P";
existingMap[0][1] = "B";
Expand Down
14 changes: 14 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,17 @@ export function errorMessage(error: unknown) {
? error
: "An unexpected error occurred";
}

export function isElementInViewport(el: HTMLElement) {
const rect = el.getBoundingClientRect();

const windowHeight =
window.innerHeight || document.documentElement.clientHeight;

const windowWidth = window.innerWidth || document.documentElement.clientWidth;

const isVerticallyInView = rect.top < windowHeight && rect.bottom > 0;
const isHorizontallyInView = rect.left < windowWidth && rect.right > 0;

return isVerticallyInView && isHorizontallyInView;
}
Loading

0 comments on commit f6a0495

Please sign in to comment.