Skip to content

Commit

Permalink
Add multiplayer game replay visualizer
Browse files Browse the repository at this point in the history
  • Loading branch information
delasy committed Nov 8, 2024
1 parent 7ba21a3 commit 5db6ae3
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 20 deletions.
16 changes: 9 additions & 7 deletions app/multiplayer/[multiplayerGameId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -34,13 +34,15 @@ export default function MultiplayerPage({
return (
<Page>
<PageTitle>Multiplayer</PageTitle>
<div className="mb-4 flex justify-center">
<span>Cost: ${multiplayerGame.cost?.toFixed(2)}</span>
</div>
{multiplayerGame.cost && (
<div className="mb-4 flex justify-center">
<span>Cost: ${multiplayerGame.cost?.toFixed(2)}</span>
</div>
)}
<div className="flex justify-center">
<Visualizer
controls={false}
map={multiplayerGame.boardState}
<ReplayVisualizer
actions={multiplayerGame.actions}
map={multiplayerGame.map}
playerLabels={playerLabels}
simulatorOptions={{ multiplayer: true }}
/>
Expand Down
3 changes: 1 addition & 2 deletions components/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { Renderer } from "@/renderer";
export function useRenderer(
map: string[][] | null | undefined,
canvas: React.MutableRefObject<HTMLCanvasElement | null>,
playerLabels?: Record<string, string>,
cellSize: number = 64,
replaySpeed: number = DEFAULT_REPLAY_SPEED,
playerLabels?: Record<string, string>,
) {
const [renderer, setRenderer] = useState<Renderer | null>(null);

Expand All @@ -25,7 +25,6 @@ export function useRenderer(
);

void renderer.initialize().then(() => {
console.log("renderer initialized");
setRenderer(renderer);
});
}, [map, cellSize, replaySpeed, playerLabels]);
Expand Down
46 changes: 46 additions & 0 deletions components/ReplayVisualizer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
simulatorOptions?: ZombieSurvivalOptions;
}) {
const simulator = useRef(
new ZombieSurvival(
replay(new ZombieSurvival(map, simulatorOptions), actions).getState(),
simulatorOptions,
),
);

const cachedActions = useRef(actions);
const canvas = useRef<HTMLCanvasElement | null>(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 <canvas ref={canvas} />;
}
2 changes: 1 addition & 1 deletion components/Visualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 8 additions & 8 deletions convex/multiplayerGames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. . . . . . . . ,
Expand All @@ -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,
Expand Down Expand Up @@ -85,7 +88,6 @@ export const startMultiplayerGame = internalMutation({

const gameId = await ctx.db.insert("multiplayerGames", {
map: initialBoard,
boardState: initialBoard,
playerMap: args.playerMap,
actions: [],
});
Expand All @@ -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,
Expand All @@ -129,7 +130,6 @@ export const updateMultiplayerGameBoardState = internalMutation({
}

await ctx.db.patch(multiplayerGame._id, {
boardState: boardState,
cost: cost ?? multiplayerGame.cost,
actions: [...multiplayerGame.actions, ...actions],
});
Expand All @@ -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<Infer<typeof multiplayerGameActionValidator>> = [];
let cost = multiplayerGame.cost ?? 0;
Expand Down Expand Up @@ -246,7 +247,6 @@ export const runMultiplayerGameTurn = internalAction({
internal.multiplayerGames.updateMultiplayerGameBoardState,
{
multiplayerGameId,
boardState: simulator.getState(),
actions,
cost,
},
Expand Down
1 change: 0 additions & 1 deletion convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions simulator/Action.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
58 changes: 58 additions & 0 deletions simulator/Replay.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 9 additions & 1 deletion simulator/ZombieSurvival.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,22 @@ export class ZombieSurvival {
const position = { x, y };

if (this.isPositionEmpty(position)) {
this.zombies.push(new Zombie(this, position));
this.spawnZombieAt(position);
return position;
}
}

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) =>
Expand Down

0 comments on commit 5db6ae3

Please sign in to comment.