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;