From 4884d4b0e7b508b73dafe59a854b1eea7d11c769 Mon Sep 17 00:00:00 2001 From: Aaron Delasy Date: Sat, 19 Oct 2024 01:59:37 +0300 Subject: [PATCH] Implement canvas visualizer --- app/provider.tsx | 7 +- components/Visualizer.tsx | 214 ++++++++++++++++++++---------- components/VisualizerProvider.tsx | 85 ++++++++++++ 3 files changed, 234 insertions(+), 72 deletions(-) create mode 100644 components/VisualizerProvider.tsx diff --git a/app/provider.tsx b/app/provider.tsx index 3d19e55..1631ea2 100644 --- a/app/provider.tsx +++ b/app/provider.tsx @@ -5,6 +5,7 @@ import { ConvexReactClient } from "convex/react"; import { ThemeProvider } from "next-themes"; import PlausibleProvider from "next-plausible"; import { Toaster } from "@/components/ui/toaster"; +import VisualizerProvider from "@/components/VisualizerProvider"; const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); @@ -17,8 +18,10 @@ export function Providers({ children }: { children: React.ReactNode }) { > - {children} - + + {children} + + diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 1c5bfe0..b53eb7f 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -1,11 +1,38 @@ import React from "react"; import { Button } from "@/components/ui/button"; -import { EntityType, ZombieSurvival } from "@/simulators/zombie-survival"; -import { getCellImage } from "@/components/Map"; +import { + type Entity, + EntityType, + ZombieSurvival, +} from "@/simulators/zombie-survival"; +import { + type VisualizerContextImages, + useVisualizer, +} from "./VisualizerProvider"; const AUTO_REPLAY_SPEED = 1_500; const REPLAY_SPEED = 600; +function getEntityImage( + entity: Entity, + images: VisualizerContextImages, +): HTMLImageElement { + switch (entity.getType()) { + case EntityType.Box: { + return images.box; + } + case EntityType.Player: { + return images.player; + } + case EntityType.Rock: { + return images.rock; + } + case EntityType.Zombie: { + return images.zombie; + } + } +} + export function Visualizer({ autoReplay = false, autoStart = false, @@ -21,23 +48,59 @@ export function Visualizer({ map: string[][]; onSimulationEnd?: (isWin: boolean) => unknown; }) { + const visualizer = useVisualizer(); const simulator = React.useRef(new ZombieSurvival(map)); const interval = React.useRef | null>(null); const timeout = React.useRef | null>(null); - const ref = React.useRef(null); - const paused = React.useRef(false); - const [running, setRunning] = React.useState(false); - const [startedAt, setStartedAt] = React.useState(Date.now()); - const [, setRenderedAt] = React.useState(Date.now()); + const canvas = React.useRef(null); + const running = React.useRef(false); + const cellSizeNum = Number.parseInt(cellSize, 10); + const h = ZombieSurvival.boardHeight(map) * cellSizeNum; + const w = ZombieSurvival.boardWidth(map) * cellSizeNum; + + React.useEffect(() => { + if (canvas.current !== null && visualizer.ready) { + setupCanvas(canvas.current); + } + }, [visualizer.ready]); + + function setupCanvas(canvas: HTMLCanvasElement) { + canvas.setAttribute("height", `${h * window.devicePixelRatio}`); + canvas.setAttribute("width", `${w * window.devicePixelRatio}`); + canvas.style.height = `${h}px`; + canvas.style.width = `${w}px`; + + const ctx = canvas.getContext("2d"); + + if (ctx !== null) { + setupContext(ctx); + } + } + + function setupContext(ctx: CanvasRenderingContext2D) { + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + } + + React.useEffect(() => { + if (autoStart) { + startSimulation(); + } + }, [autoStart]); + + function startSimulation() { + simulator.current = new ZombieSurvival(map); + running.current = true; + interval.current = setInterval(stepSimulation, REPLAY_SPEED); + } function stepSimulation() { - if (simulator.current === null && !paused.current) { + if (simulator.current === null && running) { return; } if (!simulator.current.finished()) { simulator.current.step(); - setRenderedAt(Date.now()); + render(); return; } @@ -53,44 +116,94 @@ export function Visualizer({ return; } - setRunning(false); + running.current = false; if (onSimulationEnd) { onSimulationEnd(!simulator.current.getPlayer().dead()); } } - function startSimulation() { - simulator.current = new ZombieSurvival(map); - setStartedAt(Date.now()); - setRunning(true); - interval.current = setInterval(stepSimulation, REPLAY_SPEED); + function render() { + if (canvas.current !== null) { + const ctx = canvas.current.getContext("2d"); + + if (ctx !== null) { + renderCtx(ctx); + } + } } - function handleObserving([entry]: IntersectionObserverEntry[]) { - paused.current = !entry.isIntersecting; + function renderCtx(ctx: CanvasRenderingContext2D) { + renderCtxBg(ctx); + + const entities = simulator.current.getAllAliveEntities(); + const images = visualizer.getImages(); + + for (const entity of entities) { + const entityImage = getEntityImage(entity, images); + const entityPosition = entity.getPosition(); + + ctx.globalAlpha = + entity.getType() === EntityType.Zombie && entity.getHealth() === 1 + ? 0.5 + : 1; + + ctx.drawImage( + entityImage, + entityPosition.x * cellSizeNum, + entityPosition.y * cellSizeNum, + cellSizeNum, + cellSizeNum, + ); + } + + ctx.globalAlpha = 1.0; + } + + function renderCtxBg(ctx: CanvasRenderingContext2D) { + ctx.clearRect(0, 0, w, h); + + const canvasRatio = w / h; + const images = visualizer.getImages(); + const bgRatio = images.bg.width / images.bg.height; + + let drawWidth, drawHeight, offsetX, offsetY; + + if (bgRatio > canvasRatio) { + drawWidth = h * bgRatio; + drawHeight = h; + offsetX = (w - drawWidth) / 2; + offsetY = 0; + } else { + drawWidth = w; + drawHeight = w / bgRatio; + offsetX = 0; + offsetY = (h - drawHeight) / 2; + } + + ctx.globalAlpha = 0.5; + ctx.drawImage(images.bg, offsetX, offsetY, drawWidth, drawHeight); + ctx.globalAlpha = 1.0; } React.useEffect(() => { - if (ref.current === null) { + if (canvas.current === null) { return; } const observer = new IntersectionObserver(handleObserving); - observer.observe(ref.current); + observer.observe(canvas.current); return () => { - if (ref.current) { - observer.unobserve(ref.current); + if (canvas.current) { + observer.unobserve(canvas.current); } }; - }, [ref]); + }, [canvas]); - React.useEffect(() => { - if (autoStart) { - startSimulation(); - } - }, [autoStart]); + function handleObserving([entry]: IntersectionObserverEntry[]) { + running.current = !entry.isIntersecting; + } React.useEffect(() => { return () => { @@ -103,59 +216,20 @@ export function Visualizer({ }; }, []); - const entities = simulator.current.getAllEntities() ?? []; - const cellSizeNum = Number.parseInt(cellSize, 10); - return ( <> -
- Background Map -
- {entities.map((entity, idx) => ( -
- {getCellImage(entity.toConfig())} -
- ))} -
-
+
{controls && (
-