diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx index a0cd4f3..16d0770 100644 --- a/app/multiplayer/[multiplayerGameId]/page.tsx +++ b/app/multiplayer/[multiplayerGameId]/page.tsx @@ -2,7 +2,7 @@ import { useQuery } from "convex/react"; import { Page, PageTitle } from "@/components/Page"; -import { Visualizer } from "@/components/Visualizer"; +import { ReplayVisualizer } from "@/components/ReplayVisualizer"; import { api } from "@/convex/_generated/api"; import { Id } from "@/convex/_generated/dataModel"; @@ -34,13 +34,15 @@ export default function MultiplayerPage({ return ( Multiplayer -
- Cost: ${multiplayerGame.cost?.toFixed(2)} -
+ {multiplayerGame.cost && ( +
+ Cost: ${multiplayerGame.cost?.toFixed(2)} +
+ )}
- diff --git a/components/Renderer.tsx b/components/Renderer.tsx index 450762b..32de2dc 100644 --- a/components/Renderer.tsx +++ b/components/Renderer.tsx @@ -5,9 +5,9 @@ import { Renderer } from "@/renderer"; export function useRenderer( map: string[][] | null | undefined, canvas: React.MutableRefObject, + playerLabels?: Record, cellSize: number = 64, replaySpeed: number = DEFAULT_REPLAY_SPEED, - playerLabels?: Record, ) { const [renderer, setRenderer] = useState(null); @@ -25,7 +25,6 @@ export function useRenderer( ); void renderer.initialize().then(() => { - console.log("renderer initialized"); setRenderer(renderer); }); }, [map, cellSize, replaySpeed, playerLabels]); diff --git a/components/ReplayVisualizer.tsx b/components/ReplayVisualizer.tsx new file mode 100644 index 0000000..4b8db37 --- /dev/null +++ b/components/ReplayVisualizer.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef } from "react"; +import { useRenderer } from "./Renderer"; +import { ZombieSurvival, type ZombieSurvivalOptions } from "@/simulator"; +import { Action } from "@/simulator/Action"; +import { replay } from "@/simulator/Replay"; + +export function ReplayVisualizer({ + actions, + cellSize = 64, + map, + playerLabels, + simulatorOptions, +}: { + actions: Action[]; + cellSize?: number; + map: string[][]; + playerLabels: Record; + simulatorOptions?: ZombieSurvivalOptions; +}) { + const simulator = useRef( + new ZombieSurvival( + replay(new ZombieSurvival(map, simulatorOptions), actions).getState(), + simulatorOptions, + ), + ); + + const cachedActions = useRef(actions); + const canvas = useRef(null); + const renderer = useRenderer(map, canvas, playerLabels, cellSize); + + useEffect(() => { + if (renderer !== null) { + renderer.render(simulator.current.getAllEntities()); + } + }, [renderer]); + + useEffect(() => { + const newActions = actions.slice(cachedActions.current.length); + simulator.current.resetVisualEvents(); + replay(simulator.current, newActions); + cachedActions.current.push(...newActions); + renderer?.render(simulator.current.getAllEntities()); + }, [actions]); + + return ; +} diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx index 9fc944e..9360621 100644 --- a/components/Visualizer.tsx +++ b/components/Visualizer.tsx @@ -40,9 +40,9 @@ export function Visualizer({ const renderer = useRenderer( map, canvas, + playerLabels, cellSize, replaySpeed, - playerLabels, ); const visible = useRef(false); const [running, setRunning] = useState(false); diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts index 173c701..9aac138 100644 --- a/convex/multiplayerGames.ts +++ b/convex/multiplayerGames.ts @@ -11,9 +11,11 @@ import { Doc } from "./_generated/dataModel"; import { internalAction, internalMutation, query } from "./_generated/server"; import { ModelSlug } from "./constants"; import { multiplayerGameActionValidator } from "./helpers"; +import { DEFAULT_REPLAY_SPEED } from "@/constants/visualizer"; import { ActionType } from "@/simulator/Action"; +import { replay } from "@/simulator/Replay"; -const TURN_DELAY = 500; +const TURN_DELAY = DEFAULT_REPLAY_SPEED; const boardState = ` . . . . . .B. . . . . . . . , @@ -35,7 +37,8 @@ function multiplayerGameTurn(multiplayerGame: Doc<"multiplayerGames">): string { } const players = multiplayerGame.playerMap; - const simulator = new ZombieSurvival(multiplayerGame.boardState); + const simulator = new ZombieSurvival(multiplayerGame.map); + replay(simulator, multiplayerGame.actions); const playerIndex = players.findIndex( (player) => player.playerToken === prevAction.token, @@ -85,7 +88,6 @@ export const startMultiplayerGame = internalMutation({ const gameId = await ctx.db.insert("multiplayerGames", { map: initialBoard, - boardState: initialBoard, playerMap: args.playerMap, actions: [], }); @@ -112,12 +114,11 @@ export const getMultiplayerGame = query({ export const updateMultiplayerGameBoardState = internalMutation({ args: { multiplayerGameId: v.id("multiplayerGames"), - boardState: v.array(v.array(v.string())), cost: v.optional(v.number()), actions: v.array(multiplayerGameActionValidator), }, handler: async (ctx, args) => { - const { actions, boardState, cost, multiplayerGameId } = args; + const { actions, cost, multiplayerGameId } = args; const multiplayerGame = await ctx.runQuery( api.multiplayerGames.getMultiplayerGame, @@ -129,7 +130,6 @@ export const updateMultiplayerGameBoardState = internalMutation({ } await ctx.db.patch(multiplayerGame._id, { - boardState: boardState, cost: cost ?? multiplayerGame.cost, actions: [...multiplayerGame.actions, ...actions], }); @@ -152,7 +152,8 @@ export const runMultiplayerGameTurn = internalAction({ throw new Error("Multiplayer game not found"); } - const simulator = new ZombieSurvival(multiplayerGame.boardState); + const simulator = new ZombieSurvival(multiplayerGame.map); + replay(simulator, multiplayerGame.actions); const turn = multiplayerGameTurn(multiplayerGame); const actions: Array> = []; let cost = multiplayerGame.cost ?? 0; @@ -246,7 +247,6 @@ export const runMultiplayerGameTurn = internalAction({ internal.multiplayerGames.updateMultiplayerGameBoardState, { multiplayerGameId, - boardState: simulator.getState(), actions, cost, }, diff --git a/convex/schema.ts b/convex/schema.ts index de939ef..c4addf4 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -84,7 +84,6 @@ export default defineSchema({ }).index("by_active", ["isActive"]), multiplayerGames: defineTable({ map: v.array(v.array(v.string())), - boardState: v.array(v.array(v.string())), cost: v.optional(v.number()), actions: v.array(multiplayerGameActionValidator), playerMap: v.array( diff --git a/simulator/Action.ts b/simulator/Action.ts index 685f9c4..d0eff0d 100644 --- a/simulator/Action.ts +++ b/simulator/Action.ts @@ -1,3 +1,11 @@ +import { type Position } from "./Position"; + +export interface Action { + type: ActionType; + token: string; + position?: Position; +} + export enum ActionType { PlayerShoot = "player-shoot", PlayerWalk = "player-walk", diff --git a/simulator/Replay.ts b/simulator/Replay.ts new file mode 100644 index 0000000..7aa774a --- /dev/null +++ b/simulator/Replay.ts @@ -0,0 +1,58 @@ +import { type Action, ActionType } from "./Action"; +import { type ZombieSurvival } from "./ZombieSurvival"; + +export function replay( + simulator: ZombieSurvival, + actions: Action | Action[], +): ZombieSurvival { + const actualActions = Array.isArray(actions) ? actions : [actions]; + + for (const action of actualActions) { + switch (action.type) { + case ActionType.PlayerShoot: { + if (action.position === undefined) { + throw new Error( + "Action position is required when player is shooting", + ); + } + + const zombie = simulator.getZombieAt(action.position); + + if (zombie === undefined) { + throw new Error("Unable to get action's zombie at a given position"); + } + + zombie.hit(); + break; + } + case ActionType.PlayerWalk: { + if (action.position === undefined) { + throw new Error("Action position is required when moving a player"); + } + + const player = simulator.getPlayer(action.token); + + if (player === undefined) { + throw new Error("Unable to get action's token player"); + } + + player.moveTo(action.position); + break; + } + case ActionType.ZombieSpawn: { + if (action.position === undefined) { + throw new Error("Action position is required when spawning a zombie"); + } + + simulator.spawnZombieAt(action.position); + break; + } + case ActionType.ZombieStep: { + simulator.stepZombies(); + break; + } + } + } + + return simulator; +} diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts index 1e3c06f..ea8d3d2 100644 --- a/simulator/ZombieSurvival.ts +++ b/simulator/ZombieSurvival.ts @@ -321,7 +321,7 @@ export class ZombieSurvival { const position = { x, y }; if (this.isPositionEmpty(position)) { - this.zombies.push(new Zombie(this, position)); + this.spawnZombieAt(position); return position; } } @@ -329,6 +329,14 @@ export class ZombieSurvival { throw new Error("Unable to spawn random zombie"); } + public spawnZombieAt(position: Position) { + if (!this.isPositionEmpty(position)) { + throw new Error("Trying to spawn a zombie at non-empty position"); + } + + this.zombies.push(new Zombie(this, position)); + } + public getZombieAt(position: Position): Zombie | undefined { return this.zombies.find( (zombie) =>