diff --git a/app/multiplayer/[multiplayerGameId]/page.tsx b/app/multiplayer/[multiplayerGameId]/page.tsx
index e5e1e8c..4e877a1 100644
--- a/app/multiplayer/[multiplayerGameId]/page.tsx
+++ b/app/multiplayer/[multiplayerGameId]/page.tsx
@@ -23,6 +23,14 @@ export default function MultiplayerPage({
return
Game not found.
;
}
+ const playerLabels = multiplayerGame.playerMap.reduce(
+ (acc, { playerToken, modelSlug }) => {
+ acc[playerToken] = modelSlug;
+ return acc;
+ },
+ {} as Record,
+ );
+
return (
Multiplayer
@@ -30,6 +38,7 @@ export default function MultiplayerPage({
diff --git a/components/Renderer.tsx b/components/Renderer.tsx
index c502e44..450762b 100644
--- a/components/Renderer.tsx
+++ b/components/Renderer.tsx
@@ -7,20 +7,28 @@ export function useRenderer(
canvas: React.MutableRefObject,
cellSize: number = 64,
replaySpeed: number = DEFAULT_REPLAY_SPEED,
+ playerLabels?: Record,
) {
const [renderer, setRenderer] = useState(null);
useEffect(() => {
- if (map === null || map === undefined || canvas.current === null) {
+ if (!map || !canvas.current) {
return;
}
- const renderer = new Renderer(map, canvas.current, cellSize, replaySpeed);
+ const renderer = new Renderer(
+ map,
+ canvas.current,
+ cellSize,
+ replaySpeed,
+ playerLabels,
+ );
void renderer.initialize().then(() => {
+ console.log("renderer initialized");
setRenderer(renderer);
});
- }, [map, cellSize, replaySpeed]);
+ }, [map, cellSize, replaySpeed, playerLabels]);
return renderer;
}
diff --git a/components/Visualizer.tsx b/components/Visualizer.tsx
index 7cb22fa..9fc944e 100644
--- a/components/Visualizer.tsx
+++ b/components/Visualizer.tsx
@@ -15,6 +15,7 @@ export function Visualizer({
map,
onReset,
onSimulationEnd,
+ playerLabels,
replaySpeed = DEFAULT_REPLAY_SPEED,
simulatorOptions,
}: {
@@ -25,6 +26,7 @@ export function Visualizer({
map: string[][];
onReset?: () => unknown;
onSimulationEnd?: (isWin: boolean) => unknown;
+ playerLabels?: Record;
replaySpeed?: number;
simulatorOptions?: ZombieSurvivalOptions;
}) {
@@ -35,7 +37,13 @@ export function Visualizer({
const interval = useRef | null>(null);
const timeout = useRef | null>(null);
const canvas = useRef(null);
- const renderer = useRenderer(map, canvas, cellSize, replaySpeed);
+ const renderer = useRenderer(
+ map,
+ canvas,
+ cellSize,
+ replaySpeed,
+ playerLabels,
+ );
const visible = useRef(false);
const [running, setRunning] = useState(false);
@@ -85,7 +93,7 @@ export function Visualizer({
setRunning(false);
if (onSimulationEnd) {
- onSimulationEnd(!simulator.current.getPlayer().dead());
+ onSimulationEnd(!simulator.current.getPlayer()?.dead());
}
}, replaySpeed);
}
diff --git a/convex/multiplayerGames.ts b/convex/multiplayerGames.ts
index fc639f4..3d48f14 100644
--- a/convex/multiplayerGames.ts
+++ b/convex/multiplayerGames.ts
@@ -3,36 +3,56 @@ import { ZombieSurvival, fromDirectionString, move } from "../simulator";
import { v } from "convex/values";
import { api, internal } from "./_generated/api";
import { internalAction, internalMutation, query } from "./_generated/server";
-import { AI_MODELS, ModelSlug } from "./constants";
+import { ModelSlug } from "./constants";
-const HARD_CODED_PLAYER_TOKEN = "1";
-const TURN_DELAY = 0;
+const TURN_DELAY = 500;
const boardState = `
-Z.Z.Z. . . .B. . . . . . . . ,
-Z.Z. . . . .B. . . . . . . . ,
-Z. . . . .B. .1. . . . . . . ,
+ . . . . . .B. . . . . . . . ,
+ . . . . . .B. . . . . . . . ,
+ . . . . .B. . . . . . . . . ,
. . . .R. . . . .R. . . . . ,
. . . .R. . . . .R. . . . . ,
. . . .R. . . . .R. . . . . ,
- . . . . . . . .B. . . . . .Z,
- . . . . . . .B. . . . . .Z.Z,
- . . . . . . .B. . . . .Z.Z.Z,
+ . . . . . . . .B. . . . . . ,
+ . . . . . . .B. . . . . . . ,
+ . . . . . . .B. . . . . . . ,
`;
export const startMultiplayerGame = internalMutation({
- handler: async (ctx) => {
+ args: {
+ playerMap: v.array(
+ v.object({
+ modelSlug: v.string(),
+ playerToken: v.string(),
+ }),
+ ),
+ },
+ handler: async (ctx, args) => {
+ const initialBoard = boardState
+ .trim()
+ .split(",\n")
+ .map((it) => it.split("."));
+
+ console.log({ initialBoard });
+
+ // spawn random players on the board
+ for (const player of args.playerMap) {
+ while (true) {
+ const x = Math.floor(Math.random() * initialBoard[0].length);
+ const y = Math.floor(Math.random() * initialBoard.length);
+
+ if (initialBoard[y][x] === " ") {
+ initialBoard[y][x] = player.playerToken;
+ break;
+ }
+ }
+ }
+
const gameId = await ctx.db.insert("multiplayerGames", {
- boardState: boardState
- .trim()
- .split(",\n")
- .map((it) => it.split(".")),
- playerMap: [
- {
- modelSlug: AI_MODELS["gpt-4o"].slug,
- playerToken: HARD_CODED_PLAYER_TOKEN,
- },
- ],
+ boardState: initialBoard,
+ playerMap: args.playerMap,
+ completedTurns: 0,
});
await ctx.scheduler.runAfter(
@@ -40,7 +60,7 @@ export const startMultiplayerGame = internalMutation({
internal.multiplayerGames.runMultiplayerGameTurn,
{
multiplayerGameId: gameId,
- turn: HARD_CODED_PLAYER_TOKEN,
+ turn: args.playerMap[0].playerToken,
},
);
@@ -61,9 +81,13 @@ export const updateMultiplayerGameBoardState = internalMutation({
args: {
multiplayerGameId: v.id("multiplayerGames"),
boardState: v.array(v.array(v.string())),
+ completedTurns: v.number(),
},
handler: async (ctx, args) => {
- await ctx.db.patch(args.multiplayerGameId, { boardState: args.boardState });
+ await ctx.db.patch(args.multiplayerGameId, {
+ boardState: args.boardState,
+ completedTurns: args.completedTurns,
+ });
},
});
@@ -89,6 +113,20 @@ export const runMultiplayerGameTurn = internalAction({
const map = new ZombieSurvival(multiplayerGame.boardState);
if (turn === "Z") {
+ const numPlayers = multiplayerGame.playerMap.length;
+ let zombiesToSpawn = 1;
+ if (numPlayers === 1) {
+ zombiesToSpawn = 1;
+ } else if (numPlayers === 2) {
+ zombiesToSpawn = 2;
+ } else if (numPlayers === 3) {
+ zombiesToSpawn = 2;
+ } else if (numPlayers === 4) {
+ zombiesToSpawn = 3;
+ }
+ for (let i = 0; i < zombiesToSpawn; i++) {
+ map.spawnRandomZombie();
+ }
map.stepZombies();
await ctx.runMutation(
@@ -96,12 +134,10 @@ export const runMultiplayerGameTurn = internalAction({
{
multiplayerGameId,
boardState: map.getState(),
+ completedTurns: multiplayerGame.completedTurns + 1,
},
);
- } else if (
- ZombieSurvival.mapHasToken(map.getState(), turn) &&
- turn === HARD_CODED_PLAYER_TOKEN
- ) {
+ } else {
const model = multiplayerGame.playerMap.find(
(entry) => entry.playerToken === turn,
);
@@ -110,6 +146,31 @@ export const runMultiplayerGameTurn = internalAction({
throw new Error("Model not found");
}
+ const player = map.getPlayer(turn);
+ if (!player) {
+ const currentPlayerIndex = multiplayerGame.playerMap.findIndex(
+ (entry) => entry.playerToken === turn,
+ );
+ const nextPlayerIndex = currentPlayerIndex + 1;
+ let nextPlayer: string;
+ if (nextPlayerIndex >= multiplayerGame.playerMap.length) {
+ nextPlayer = "Z";
+ } else {
+ nextPlayer = multiplayerGame.playerMap[nextPlayerIndex].playerToken;
+ }
+
+ await ctx.scheduler.runAfter(
+ 0,
+ internal.multiplayerGames.runMultiplayerGameTurn,
+ {
+ multiplayerGameId,
+ turn: nextPlayer,
+ },
+ );
+
+ return;
+ }
+
const results = await runMultiplayerModel(
model.modelSlug as ModelSlug,
map.getState(),
@@ -118,17 +179,18 @@ export const runMultiplayerGameTurn = internalAction({
if (results.moveDirection && results.moveDirection !== "STAY") {
const moveDirection = fromDirectionString(results.moveDirection);
- const movePosition = move(
- map.getPlayer(turn).getPosition(),
- moveDirection,
- );
-
- if (
- map.isValidPosition(movePosition) &&
- map.isPositionEmpty(movePosition)
- ) {
- // only move if the position was valid, otherwise we don't move
- map.getPlayer(turn).moveTo(movePosition);
+ const p = map.getPlayer(turn);
+
+ if (p) {
+ const movePosition = move(p.getPosition(), moveDirection);
+
+ if (
+ map.isValidPosition(movePosition) &&
+ map.isPositionEmpty(movePosition)
+ ) {
+ // only move if the position was valid, otherwise we don't move
+ p.moveTo(movePosition);
+ }
}
}
@@ -142,17 +204,30 @@ export const runMultiplayerGameTurn = internalAction({
{
multiplayerGameId,
boardState: map.getState(),
+ completedTurns: multiplayerGame.completedTurns,
},
);
}
- if (!map.finished()) {
+ if (!map.allPlayersDead()) {
+ let nextPlayer: string;
+
+ const currentPlayerIndex = multiplayerGame.playerMap.findIndex(
+ (entry) => entry.playerToken === turn,
+ );
+ const nextPlayerIndex = currentPlayerIndex + 1;
+ if (nextPlayerIndex >= multiplayerGame.playerMap.length) {
+ nextPlayer = "Z";
+ } else {
+ nextPlayer = multiplayerGame.playerMap[nextPlayerIndex].playerToken;
+ }
+
await ctx.scheduler.runAfter(
TURN_DELAY,
internal.multiplayerGames.runMultiplayerGameTurn,
{
multiplayerGameId,
- turn: turn === "Z" ? HARD_CODED_PLAYER_TOKEN : "Z",
+ turn: nextPlayer,
},
);
}
diff --git a/convex/schema.ts b/convex/schema.ts
index 48897b9..06a142b 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -83,6 +83,7 @@ export default defineSchema({
}).index("by_active", ["isActive"]),
multiplayerGames: defineTable({
boardState: v.array(v.array(v.string())),
+ completedTurns: v.number(),
playerMap: v.array(
v.object({
modelSlug: v.string(),
diff --git a/lib/prepareCanvas.ts b/lib/prepareCanvas.ts
index 6a6bb96..c3fa3a0 100644
--- a/lib/prepareCanvas.ts
+++ b/lib/prepareCanvas.ts
@@ -9,11 +9,11 @@ export function prepareCanvas(
throw new Error("Unable to get 2d context");
}
- canvas.height = height * window.devicePixelRatio;
- canvas.width = width * window.devicePixelRatio;
+ canvas.height = height;
+ canvas.width = width;
canvas.style.height = `${height}px`;
canvas.style.width = `${width}px`;
- ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
+ // ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
return ctx;
}
diff --git a/models/multiplayer/index.ts b/models/multiplayer/index.ts
index c599a4a..1e408ae 100644
--- a/models/multiplayer/index.ts
+++ b/models/multiplayer/index.ts
@@ -89,9 +89,12 @@ export async function runMultiplayerModel(
"STAY",
];
+ const zombieLocations = ZombieSurvival.getZombieLocations(map);
+
const userPrompt =
`Grid: ${JSON.stringify(map)}\n\n` +
`Your Player Token: ${playerToken}\n\n` +
+ `Zombie Locations: ${JSON.stringify(zombieLocations)}\n\n` +
`Valid Move Locations: ${JSON.stringify(validDirections)}`;
let result;
diff --git a/renderer/Item.ts b/renderer/Item.ts
index 4b9e2d8..34d2ecf 100644
--- a/renderer/Item.ts
+++ b/renderer/Item.ts
@@ -7,17 +7,28 @@ export class RendererItem {
height: number;
position: Position;
width: number;
+ label?: string;
constructor(
data: HTMLImageElement | string,
position: Position,
width: number,
height: number,
+ label?: string,
) {
this.data = data;
this.height = height;
this.position = position;
this.width = width;
+ this.label = label;
+ }
+
+ public getDisplayName(): string {
+ return this.label ?? "";
+ }
+
+ public hasDisplayName(): boolean {
+ return this.label !== undefined;
}
public addEffect(...effects: RendererEffect[]) {
diff --git a/renderer/Renderer.ts b/renderer/Renderer.ts
index 99a0199..225e2f6 100644
--- a/renderer/Renderer.ts
+++ b/renderer/Renderer.ts
@@ -29,12 +29,14 @@ export class Renderer {
private initialized = false;
private items: RendererItem[] = [];
private req: number | null = null;
+ private playerLabels?: Record;
public constructor(
map: string[][],
canvas: HTMLCanvasElement,
cellSize: number,
replaySpeed: number,
+ playerLabels?: Record,
) {
this.cellSize = cellSize;
this.map = map;
@@ -46,6 +48,8 @@ export class Renderer {
this.ctx = prepareCanvas(canvas, this.w, this.h);
this.ctx2 = prepareCanvas(this.canvas2, this.cellSize, this.cellSize);
+
+ this.playerLabels = playerLabels;
}
public isInitialized() {
@@ -158,6 +162,12 @@ export class Renderer {
this.ctx.drawImage(source, x, y, item.width, item.height);
this.ctx.globalAlpha = 1;
+
+ if (item.hasDisplayName()) {
+ this.ctx.fillStyle = "#FFF";
+ this.ctx.font = "18px Arial";
+ this.ctx.fillText(item.getDisplayName(), x, y - 10);
+ }
}
private getEntityImage(entity: Entity): HTMLImageElement | null {
@@ -236,6 +246,7 @@ export class Renderer {
position,
this.cellSize,
this.cellSize,
+ this.playerLabels ? this.playerLabels[entity.getToken()] : undefined,
);
if (entity.hasVisualEvent(VisualEventType.Moving)) {
diff --git a/simulator/Entity.ts b/simulator/Entity.ts
index 577436c..95e1d44 100644
--- a/simulator/Entity.ts
+++ b/simulator/Entity.ts
@@ -66,6 +66,10 @@ export abstract class Entity {
return this.position;
}
+ public getDisplayName(): string {
+ return "";
+ }
+
public getPositionId(): string {
return `${this.position.x}.${this.position.y}`;
}
diff --git a/simulator/ZombieSurvival.ts b/simulator/ZombieSurvival.ts
index 0467b5e..0116b93 100644
--- a/simulator/ZombieSurvival.ts
+++ b/simulator/ZombieSurvival.ts
@@ -148,6 +148,20 @@ export class ZombieSurvival {
return game.getPlayer() === null || !game.getPlayer()?.dead();
}
+ public static getZombieLocations(map: string[][]): Array<[number, number]> {
+ return map.flatMap((row, y) =>
+ row.reduce(
+ (acc, cell, x) => {
+ if (cell.startsWith("Z")) {
+ acc.push([y, x]);
+ }
+ return acc;
+ },
+ [] as Array<[number, number]>,
+ ),
+ );
+ }
+
public static mapHasToken(map: string[][], token: string): boolean {
return map.flat().includes(token);
}
@@ -233,6 +247,10 @@ export class ZombieSurvival {
);
}
+ public allPlayersDead(): boolean {
+ return this.players.every((player) => player.dead());
+ }
+
public getClosestPlayer(position: Position): Player | undefined {
let closestPlayer: Player | undefined;
let closestDistance = Infinity;
@@ -260,7 +278,7 @@ export class ZombieSurvival {
return this.entities;
}
- public getPlayer(token: string | null = null): Player {
+ public getPlayer(token: string | null = null): Player | undefined {
if (!this.multiplayer) {
return this.players[0];
}
@@ -275,7 +293,30 @@ export class ZombieSurvival {
}
}
- throw new Error(`Tried getting non-existing player '${token}'`);
+ return undefined;
+ }
+
+ public spawnRandomZombie() {
+ for (let i = 0; i < 10; i++) {
+ let x: number;
+ let y: number;
+
+ // Randomly choose which edge to spawn on
+ if (Math.random() < 0.5) {
+ // Spawn on left or right edge
+ x = Math.random() < 0.5 ? 0 : this.boardWidth - 1;
+ y = Math.floor(Math.random() * this.boardHeight);
+ } else {
+ // Spawn on top or bottom edge
+ x = Math.floor(Math.random() * this.boardWidth);
+ y = Math.random() < 0.5 ? 0 : this.boardHeight - 1;
+ }
+
+ if (this.isPositionEmpty({ x, y })) {
+ this.zombies.push(new Zombie(this, { x, y }));
+ return;
+ }
+ }
}
public getZombieAt(position: Position): Zombie | undefined {
@@ -323,7 +364,7 @@ export class ZombieSurvival {
}
public stepPlayer(token: string): void {
- this.getPlayer(token).shoot();
+ this.getPlayer(token)?.shoot();
}
public stepPlayers(): void {
diff --git a/simulator/entities/Player.ts b/simulator/entities/Player.ts
index 3e2d830..30aee8c 100644
--- a/simulator/entities/Player.ts
+++ b/simulator/entities/Player.ts
@@ -9,21 +9,35 @@ export class Player extends Entity {
public static ShootDistance = Infinity;
public token = "P";
+ public displayName = "";
private game: ZombieSurvival;
- public constructor(game: ZombieSurvival, position: Position, token?: string) {
+ public constructor(
+ game: ZombieSurvival,
+ position: Position,
+ token?: string,
+ displayName?: string,
+ ) {
super(EntityType.Player, Player.Destructible, Player.Health, position);
this.game = game;
if (token !== undefined) {
this.token = token;
}
+
+ if (displayName !== undefined) {
+ this.displayName = displayName;
+ }
}
public getToken(): string {
return this.token;
}
+ public getDisplayName(): string {
+ return this.displayName;
+ }
+
public shoot() {
if (this.dead()) {
return;