diff --git a/app/provider.tsx b/app/provider.tsx index 1631ea2..3d19e55 100644 --- a/app/provider.tsx +++ b/app/provider.tsx @@ -5,7 +5,6 @@ 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!); @@ -18,10 +17,8 @@ export function Providers({ children }: { children: React.ReactNode }) { > - - {children} - - + {children} + diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 0e239d1..0058b20 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Renderer } from "@/renderer"; import { ZombieSurvival } from "@/simulators/zombie-survival"; -import { useVisualizer } from "@/components/VisualizerProvider"; const AUTO_REPLAY_SPEED = 1_500; const REPLAY_SPEED = 600; @@ -24,7 +23,6 @@ export function Visualizer({ onReset?: () => unknown; onSimulationEnd?: (isWin: boolean) => unknown; }) { - const visualizer = useVisualizer(); const simulator = React.useRef(new ZombieSurvival(map)); const renderer = React.useRef(null); const interval = React.useRef | null>(null); @@ -34,20 +32,15 @@ export function Visualizer({ const [running, setRunning] = React.useState(false); React.useEffect(() => { - if ( - visualizer.ready && - canvas.current !== null && - renderer.current === null - ) { + if (canvas.current !== null) { renderer.current = new Renderer( - visualizer.getAssets(), ZombieSurvival.boardHeight(map), ZombieSurvival.boardWidth(map), canvas.current, Number.parseInt(cellSize, 10), ); } - }, [canvas, visualizer.ready]); + }, [canvas]); React.useEffect(() => { if (autoStart) { diff --git a/components/VisualizerProvider.tsx b/components/VisualizerProvider.tsx deleted file mode 100644 index 23b8562..0000000 --- a/components/VisualizerProvider.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import React from "react"; -import { type RendererAssets } from "@/renderer"; - -export type VisualizerContextValue = - | { - ready: false; - } - | { - getAssets: () => RendererAssets; - ready: true; - }; - -const VisualizerContext = React.createContext( - null, -); - -async function loadImage(src: string): Promise { - return await new Promise((resolve) => { - const img = new Image(); - img.addEventListener("load", () => resolve(img)); - img.src = src; - }); -} - -export default function VisualizerProvider({ - children, -}: { - children: React.ReactNode; -}): React.ReactNode { - const [ready, setReady] = React.useState(false); - const assets = React.useRef(null); - - React.useEffect(() => { - Promise.all([ - loadImage("/map.png"), - loadImage("/entities/block.svg"), - loadImage("/entities/player_alive_1.svg"), - loadImage("/entities/rocks.png"), - loadImage("/entities/zombie_alive_1.svg"), - ]).then((result) => { - setReady(true); - - assets.current = { - bg: result[0], - box: result[1], - player: result[2], - rock: result[3], - zombie: result[4], - }; - }); - }, []); - - function getAssets(): RendererAssets { - if (!ready || assets.current === null) { - throw new Error( - "Tried accessing visualizer images before they are loaded", - ); - } - - return assets.current; - } - - return ( - - {children} - - ); -} - -export function useVisualizer() { - const context = React.useContext(VisualizerContext); - - if (context === null) { - throw new Error( - "useVisualizer should be used within the VisualizerProvider", - ); - } - - return context; -} diff --git a/renderer/index.ts b/renderer/index.ts index b44a48f..f32827b 100644 --- a/renderer/index.ts +++ b/renderer/index.ts @@ -1,11 +1,78 @@ import { type Entity, EntityType } from "@/simulators/zombie-survival"; export interface RendererAssets { - bg: HTMLImageElement; - box: HTMLImageElement; - player: HTMLImageElement; - rock: HTMLImageElement; - zombie: HTMLImageElement; + loading: boolean; + loaded: boolean; + bg: HTMLImageElement | null; + box: HTMLImageElement | null; + player: HTMLImageElement | null; + rock: HTMLImageElement | null; + zombie: HTMLImageElement | null; +} + +const assets: RendererAssets = { + loading: false, + loaded: false, + bg: null, + box: null, + player: null, + rock: null, + zombie: null, +}; + +async function loadAssets() { + if (assets.loading || assets.loaded) { + return; + } + + assets.loading = true; + + const [bg, box, player, rock, zombie] = 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"), + ]); + + assets.loaded = true; + assets.bg = bg; + assets.box = box; + assets.player = player; + assets.rock = rock; + assets.zombie = zombie; +} + +async function loadImage(src: string): Promise { + return await new Promise((resolve) => { + const img = new Image(); + img.addEventListener("load", () => resolve(img)); + img.src = src; + }); +} + +function getEntityImage(entity: Entity): HTMLImageElement | null { + switch (entity.getType()) { + case EntityType.Box: { + return assets.box; + } + case EntityType.Player: { + return assets.player; + } + case EntityType.Rock: { + return assets.rock; + } + case EntityType.Zombie: { + return assets.zombie; + } + } +} + +function getEntityOffset(entity: Entity): { x: number; y: number } { + return { + x: entity.getType() === EntityType.Zombie ? 16 : 0, + y: 0, + }; } export class Renderer { @@ -17,7 +84,6 @@ export class Renderer { private ctx: CanvasRenderingContext2D; public constructor( - assets: RendererAssets, boardHeight: number, boardWidth: number, canvas: HTMLCanvasElement, @@ -42,6 +108,7 @@ export class Renderer { canvas.style.width = `${this.w}px`; ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + void loadAssets(); } public render(entities: Entity[]) { @@ -56,8 +123,12 @@ export class Renderer { } private drawBg() { + if (assets.bg === null) { + return; + } + const canvasRatio = this.w / this.h; - const bgRatio = this.assets.bg.width / this.assets.bg.height; + const bgRatio = assets.bg.width / assets.bg.height; let drawWidth, drawHeight, offsetX, offsetY; @@ -74,14 +145,19 @@ export class Renderer { } this.ctx.globalAlpha = 0.5; - this.ctx.drawImage(this.assets.bg, offsetX, offsetY, drawWidth, drawHeight); + this.ctx.drawImage(assets.bg, offsetX, offsetY, drawWidth, drawHeight); this.ctx.globalAlpha = 1.0; } private drawEntity(entity: Entity) { - const entityImage = this.getEntityImage(entity); + const entityImage = getEntityImage(entity); + + if (entityImage === null) { + return; + } + const entityPosition = entity.getPosition(); - const entityOffset = this.getEntityOffset(entity); + const entityOffset = getEntityOffset(entity); this.ctx.globalAlpha = entity.getType() === EntityType.Zombie && entity.getHealth() === 1 @@ -96,28 +172,4 @@ export class Renderer { this.cellSize, ); } - - private getEntityImage(entity: Entity): HTMLImageElement { - switch (entity.getType()) { - case EntityType.Box: { - return this.assets.box; - } - case EntityType.Player: { - return this.assets.player; - } - case EntityType.Rock: { - return this.assets.rock; - } - case EntityType.Zombie: { - return this.assets.zombie; - } - } - } - - private getEntityOffset(entity: Entity): { x: number; y: number } { - return { - x: entity.getType() === EntityType.Zombie ? 16 : 0, - y: 0, - }; - } }