diff --git a/app/layout.tsx b/app/layout.tsx index a0e0ce0..f221e13 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,7 +11,7 @@ export const metadata: Metadata = { title: "Convex + Next.js + Convex Auth", description: "Generated by npm create convex", icons: { - icon: "/convex.svg", + icon: "/logo.png", }, }; diff --git a/games/ZombieSurvival/Direction.ts b/games/ZombieSurvival/Direction.ts new file mode 100644 index 0000000..49177b0 --- /dev/null +++ b/games/ZombieSurvival/Direction.ts @@ -0,0 +1,69 @@ +import { Position } from "./Position"; + +export enum Direction { + Down, + Left, + Right, + Up, +} + +export const allDirections = [ + Direction.Down, + Direction.Left, + Direction.Right, + Direction.Up, +]; + +export function directionToString(direction: Direction): string { + switch (direction) { + case Direction.Down: { + return "0"; + } + case Direction.Left: { + return "1"; + } + case Direction.Right: { + return "2"; + } + case Direction.Up: { + return "3"; + } + } +} + +export function directionFromString(val: string): Direction { + switch (val) { + case "0": { + return Direction.Down; + } + case "1": { + return Direction.Left; + } + case "2": { + return Direction.Right; + } + case "3": { + return Direction.Up; + } + default: { + throw new Error("Can't parse direction"); + } + } +} + +export function move(position: Position, direction: Direction): Position { + switch (direction) { + case Direction.Down: { + return { ...position, y: position.y + 1 }; + } + case Direction.Left: { + return { ...position, x: position.x - 1 }; + } + case Direction.Right: { + return { ...position, x: position.x + 1 }; + } + case Direction.Up: { + return { ...position, y: position.y - 1 }; + } + } +} diff --git a/games/ZombieSurvival/Position.ts b/games/ZombieSurvival/Position.ts new file mode 100644 index 0000000..61a08f9 --- /dev/null +++ b/games/ZombieSurvival/Position.ts @@ -0,0 +1,8 @@ +export interface Position { + x: number; + y: number; +} + +export function positionAsNumber(position: Position): number { + return position.x + position.y; +} diff --git a/games/ZombieSurvival/ZombieSurvival.spec.ts b/games/ZombieSurvival/ZombieSurvival.spec.ts new file mode 100644 index 0000000..44e0ed8 --- /dev/null +++ b/games/ZombieSurvival/ZombieSurvival.spec.ts @@ -0,0 +1,362 @@ +import { expect, test } from "vitest"; +import { ZombieSurvival } from "./ZombieSurvival"; + +test("fails on invalid config", () => { + expect(() => new ZombieSurvival([])).toThrowError("Config is empty"); + expect(() => new ZombieSurvival([[]])).toThrowError("Config is empty"); + + expect( + () => + new ZombieSurvival([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", "Z", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]), + ).toThrowError("Config has no player"); + + expect( + () => + new ZombieSurvival([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", "Z", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]), + ).toThrowError("Config contains multiple players"); + + expect( + () => + new ZombieSurvival([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]), + ).toThrowError("Config has no zombies"); +}); + +test("fails on impossible to beat config", () => { + const game = new ZombieSurvival([ + [" ", " ", " ", " ", "R", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "R", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "R", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", "Z", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + expect(() => game.step()).toThrowError("Unable to solve game"); +}); + +test("works with different boards sizes", () => { + expect(() => new ZombieSurvival([["P", "Z"]])).not.toThrow(); + expect(() => new ZombieSurvival([["P"], ["Z"]])).not.toThrow(); + + expect( + () => + new ZombieSurvival([ + [" ", " ", " ", " ", " "], + [" ", " ", "B", " ", " "], + ["Z", " ", "B", "P", " "], + ["R", "R", "R", " ", " "], + ]), + ).not.toThrow(); + + expect( + () => + new ZombieSurvival([["P", "B", "Z", "Z", "Z", " ", " ", " ", " ", " "]]), + ).not.toThrow(); + + expect( + () => + new ZombieSurvival([ + ["P"], + ["B"], + ["Z"], + ["Z"], + ["Z"], + [" "], + [" "], + [" "], + [" "], + ]), + ).not.toThrow(); +}); + +test("kills zombies", () => { + const game = new ZombieSurvival([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", "Z", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", "Z", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", "Z", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", "Z", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", "Z", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + expect(game.finished()).toBeTruthy(); +}); + +test("player gets killed instantly", () => { + const game = new ZombieSurvival([ + [" ", " ", " ", "Z", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", "Z", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", "Z", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", "Z", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + expect(game.finished()).toBeTruthy(); +}); + +test("player gets killed eventually", () => { + const game = new ZombieSurvival([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", "B", "Z", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", "Z", " ", " ", " ", " "], + ["R", "R", "R", "R", "Z", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", " ", "Z", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", "Z", " ", " ", " ", " "], + ["R", "R", "R", "R", "Z", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "Z", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", "Z", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "Z", "Z", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "P", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "Z", " ", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", "Z", " ", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + expect(game.finished()).toBeTruthy(); +}); + +test("player gets killed with too many zombies", () => { + const game = new ZombieSurvival([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", "Z"], + [" ", " ", " ", "P", "B", " ", " ", " ", " ", "Z"], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", "Z"], + ["R", "R", "R", "R", " ", " ", " ", " ", " ", "Z"], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", "Z"], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", "Z"], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", "Z"], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", "Z"], + ["Z", "Z", "Z", "Z", "Z", "Z", "Z", "Z", "Z", "Z"], + ]); + + game.step(); + game.step(); + game.step(); + game.step(); + game.step(); + game.step(); + game.step(); + game.step(); + game.step(); + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "B", " ", " ", " ", " ", " "], + [" ", " ", " ", "Z", "Z", " ", " ", " ", " ", " "], + ["R", "R", "R", "R", "Z", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", "Z", "Z", " ", " ", " ", " "], + [" ", " ", " ", " ", "Z", "Z", " ", " ", " ", " "], + [" ", " ", " ", " ", "Z", "Z", " ", " ", " ", " "], + [" ", " ", " ", " ", "Z", " ", " ", " ", " ", " "], + ["Z", "Z", " ", "Z", "Z", " ", " ", " ", " ", " "], + ]); + + expect(game.finished()).toBeTruthy(); +}); + +test("player gets killed behind walls", () => { + const game = new ZombieSurvival([ + ["P", "B", "Z", " ", " ", " ", " ", " ", " ", " "], + ["B", "B", "Z", " ", " ", " ", " ", " ", " ", " "], + ["Z", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + game.step(); + game.step(); + game.step(); + + expect(game.getState()).toStrictEqual([ + [" ", "Z", " ", " ", " ", " ", " ", " ", " ", " "], + ["Z", "B", " ", " ", " ", " ", " ", " ", " ", " "], + ["Z", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + ]); + + expect(game.finished()).toBeTruthy(); +}); diff --git a/games/ZombieSurvival/ZombieSurvival.ts b/games/ZombieSurvival/ZombieSurvival.ts new file mode 100644 index 0000000..2d0efe6 --- /dev/null +++ b/games/ZombieSurvival/ZombieSurvival.ts @@ -0,0 +1,140 @@ +import { Box } from "./entities/Box"; +import { Entity } from "./entities/Entity"; +import { Player } from "./entities/Player"; +import { Rock } from "./entities/Rock"; +import { Zombie } from "./entities/Zombie"; +import { entityAt } from "./lib/entity-at"; + +export class ZombieSurvival { + public readonly boardHeight: number; + public readonly boardWidth: number; + private entities: Entity[]; + private player: Player; + private zombies: Zombie[]; + + public static fromSnapshot(snapshot: string): ZombieSurvival { + const config = snapshot.split(".").map((it) => it.split("")); + return new ZombieSurvival(config); + } + + public constructor(config: string[][]) { + if (config.length === 0 || config[0].length == 0) { + throw new Error("Config is empty"); + } + + this.boardWidth = config[0].length; + this.boardHeight = config.length; + this.entities = []; + this.zombies = []; + + let player: Player | null = null; + + for (let y = 0; y < this.boardHeight; y++) { + for (let x = 0; x < this.boardWidth; x++) { + const code = config[y][x]; + + switch (code.toLowerCase()) { + case "b": { + this.entities.push(new Box({ x, y })); + break; + } + case "p": { + if (player !== null) { + throw new Error("Config contains multiple players"); + } + + player = new Player(this, { x, y }); + break; + } + case "r": { + this.entities.push(new Rock({ x, y })); + break; + } + case "z": { + this.zombies.push(new Zombie(this, { x, y })); + break; + } + } + } + } + + if (player === null) { + throw new Error("Config has no player"); + } + + this.player = player; + + if (this.zombies.length === 0) { + throw new Error("Config has no zombies"); + } + } + + public finished(): boolean { + return this.player.dead() || this.zombies.every((zombie) => zombie.dead()); + } + + public getAllEntities(): Entity[] { + return [this.entities, this.zombies, this.player].flat(); + } + + public getEntities(): Entity[] { + return this.entities; + } + + public getPlayer(): Player { + return this.player; + } + + public getSnapshot(): string { + return this.getState() + .map((it) => it.join("")) + .join("."); + } + + public getState(): string[][] { + const entities = this.getAllEntities(); + let config: string[][] = []; + + for (let y = 0; y < this.boardHeight; y++) { + const item: string[] = []; + + for (let x = 0; x < this.boardWidth; x++) { + const entity = entityAt(entities, { x, y }); + item.push(entity === null ? " " : entity.toConfig()); + } + + config.push(item); + } + + return config; + } + + public getZombie(): Zombie { + return this.zombies[0]; + } + + public getZombies(): Zombie[] { + return this.zombies; + } + + public setZombies(zombies: Zombie[]): this { + if (zombies.length === 0) { + throw new Error("Tried setting zero zombies"); + } + + this.zombies = zombies; + return this; + } + + public step() { + this.player.shoot(); + + for (const zombie of this.zombies) { + if (this.player.dead()) { + break; + } + + zombie.walk(); + } + } +} diff --git a/games/ZombieSurvival/entities/Box.ts b/games/ZombieSurvival/entities/Box.ts new file mode 100644 index 0000000..dd51fb1 --- /dev/null +++ b/games/ZombieSurvival/entities/Box.ts @@ -0,0 +1,11 @@ +import { Entity, EntityType } from "./Entity"; +import { Position } from "../Position"; + +export class Box extends Entity { + public static Destructible = true; + public static Health = 1; + + public constructor(position: Position) { + super(EntityType.Box, Box.Destructible, Box.Health, position); + } +} diff --git a/games/ZombieSurvival/entities/Entity.ts b/games/ZombieSurvival/entities/Entity.ts new file mode 100644 index 0000000..1a8a605 --- /dev/null +++ b/games/ZombieSurvival/entities/Entity.ts @@ -0,0 +1,71 @@ +import { Position } from "../Position"; + +export enum EntityType { + Box, + Player, + Rock, + Zombie, +} + +export class Entity { + protected destructible: boolean; + protected health: number; + protected position: Position; + protected type: EntityType; + + public constructor( + type: EntityType, + destructible: boolean, + health: number, + position: Position, + ) { + this.destructible = destructible; + this.health = health; + this.position = position; + this.type = type; + } + + public dead(): boolean { + return this.health === 0; + } + + public getPosition(): Position { + return this.position; + } + + public getPositionAsNumber(): number { + return this.position.x + this.position.y; + } + + public getType(): EntityType { + return this.type; + } + + public hit() { + if (!this.destructible) { + return; + } + + this.health--; + } + + public isDestructible(): boolean { + return this.destructible; + } + + public toConfig(): string { + let letter = " "; + + if (this.type === EntityType.Box) { + letter = "B"; + } else if (this.type === EntityType.Player) { + letter = "P"; + } else if (this.type === EntityType.Rock) { + letter = "R"; + } else if (this.type === EntityType.Zombie) { + letter = "Z"; + } + + return letter; + } +} diff --git a/games/ZombieSurvival/entities/Player.ts b/games/ZombieSurvival/entities/Player.ts new file mode 100644 index 0000000..caa757e --- /dev/null +++ b/games/ZombieSurvival/entities/Player.ts @@ -0,0 +1,22 @@ +import { Entity, EntityType } from "./Entity"; +import { Position } from "../Position"; +import { ZombieSurvival } from "../ZombieSurvival"; +import { closestEntity } from "../lib/closest-entity"; + +export class Player extends Entity { + public static Destructible = true; + public static Health = 1; + public static ShootDistance = Infinity; + + private game: ZombieSurvival; + + public constructor(game: ZombieSurvival, position: Position) { + super(EntityType.Player, Player.Destructible, Player.Health, position); + this.game = game; + } + + public shoot() { + const zombie = closestEntity(this, this.game.getZombies()); + zombie.hit(); + } +} diff --git a/games/ZombieSurvival/entities/Rock.ts b/games/ZombieSurvival/entities/Rock.ts new file mode 100644 index 0000000..7a42afc --- /dev/null +++ b/games/ZombieSurvival/entities/Rock.ts @@ -0,0 +1,11 @@ +import { Entity, EntityType } from "./Entity"; +import { Position } from "../Position"; + +export class Rock extends Entity { + public static Destructible = false; + public static Health = -1; + + public constructor(position: Position) { + super(EntityType.Rock, Rock.Destructible, Rock.Health, position); + } +} diff --git a/games/ZombieSurvival/entities/Zombie.ts b/games/ZombieSurvival/entities/Zombie.ts new file mode 100644 index 0000000..0b76524 --- /dev/null +++ b/games/ZombieSurvival/entities/Zombie.ts @@ -0,0 +1,110 @@ +import { Direction, allDirections, move } from "../Direction"; +import { Entity, EntityType } from "./Entity"; +import { Position } from "../Position"; +import { ZombieSurvival } from "../ZombieSurvival"; +import { entityAt } from "../lib/entity-at"; +import { pathfinder } from "../lib/pathfinder"; + +export class Zombie extends Entity { + public static Destructible = true; + public static Health = 2; + + private game: ZombieSurvival; + + public constructor(game: ZombieSurvival, position: Position) { + super(EntityType.Zombie, Zombie.Destructible, Zombie.Health, position); + this.game = game; + } + + public listMoves(): Direction[] { + const entities = this.game.getAllEntities(); + const result: Direction[] = []; + + for (const direction of allDirections) { + const position = move(this.position, direction); + + if ( + position.x < 0 || + position.y < 0 || + position.x >= this.game.boardWidth || + position.y >= this.game.boardHeight + ) { + continue; + } + + const entity = entityAt(entities, position); + + if (entity !== null && !entity.isDestructible()) { + continue; + } + + result.push(direction); + } + + return result; + } + + public predictLastMove(): Direction | null { + const entities = this.game.getAllEntities(); + const position = this.position; + + const downEntity = entityAt(entities, move(position, Direction.Down)); + const leftEntity = entityAt(entities, move(position, Direction.Left)); + const rightEntity = entityAt(entities, move(position, Direction.Right)); + const upEntity = entityAt(entities, move(position, Direction.Up)); + + if (downEntity?.getType() === EntityType.Player) { + return Direction.Down; + } + + if (leftEntity?.getType() === EntityType.Player) { + return Direction.Left; + } + + if (rightEntity?.getType() === EntityType.Player) { + return Direction.Right; + } + + if (upEntity?.getType() === EntityType.Player) { + return Direction.Up; + } + + return null; + } + + public walk(direction: Direction | null = null) { + if (this.dead()) { + return; + } + + let nextDirection = direction ?? pathfinder(this.game, this)[0]; + + if (typeof nextDirection === "undefined") { + const lastMove = this.predictLastMove(); + + if (lastMove === null) { + throw new Error("Zombie out of moves"); + } + + nextDirection = lastMove; + } + + const entities = this.game.getAllEntities(); + const newPosition = move(this.position, nextDirection); + const entity = entityAt(entities, newPosition); + + if (entity !== null) { + if (entity.getType() !== EntityType.Zombie) { + entity.hit(); + } + + return; + } + + this.walkTo(newPosition); + } + + public walkTo(position: Position) { + this.position = position; + } +} diff --git a/games/ZombieSurvival/index.ts b/games/ZombieSurvival/index.ts new file mode 100644 index 0000000..63ab792 --- /dev/null +++ b/games/ZombieSurvival/index.ts @@ -0,0 +1,8 @@ +export * from "./entities/Box"; +export * from "./entities/Entity"; +export * from "./entities/Player"; +export * from "./entities/Rock"; +export * from "./entities/Zombie"; +export * from "./Direction"; +export * from "./Position"; +export * from "./ZombieSurvival"; diff --git a/games/ZombieSurvival/lib/closest-entity.ts b/games/ZombieSurvival/lib/closest-entity.ts new file mode 100644 index 0000000..2facd5e --- /dev/null +++ b/games/ZombieSurvival/lib/closest-entity.ts @@ -0,0 +1,30 @@ +import { Entity } from "../entities/Entity"; +import { positionAsNumber } from "../Position"; + +export interface ClosestEntityScore { + score: number; + target: Entity; +} + +export function closestEntity(entity: Entity, targets: Entity[]): Entity { + const entityPosition = positionAsNumber(entity.getPosition()); + const scores: ClosestEntityScore[] = []; + + for (const target of targets) { + if (target.dead()) { + continue; + } + + const targetPosition = positionAsNumber(target.getPosition()); + const score = Math.abs(entityPosition - targetPosition); + + scores.push({ target, score }); + } + + if (scores.length === 0) { + throw new Error("No alive targets found"); + } + + scores.sort((a, b) => a.score - b.score); + return scores[0].target; +} diff --git a/games/ZombieSurvival/lib/entity-at.ts b/games/ZombieSurvival/lib/entity-at.ts new file mode 100644 index 0000000..5296f56 --- /dev/null +++ b/games/ZombieSurvival/lib/entity-at.ts @@ -0,0 +1,21 @@ +import { Entity } from "../entities/Entity"; +import { Position } from "../Position"; + +export function entityAt( + entities: Entity[], + position: Position, +): Entity | null { + for (const entity of entities) { + if (entity.dead()) { + continue; + } + + const entityPosition = entity.getPosition(); + + if (entityPosition.x === position.x && entityPosition.y === position.y) { + return entity; + } + } + + return null; +} diff --git a/games/ZombieSurvival/lib/pathfinder.ts b/games/ZombieSurvival/lib/pathfinder.ts new file mode 100644 index 0000000..1135896 --- /dev/null +++ b/games/ZombieSurvival/lib/pathfinder.ts @@ -0,0 +1,86 @@ +import { + Direction, + directionFromString, + directionToString, +} from "../Direction"; +import { Zombie } from "../entities/Zombie"; +import { ZombieSurvival } from "../ZombieSurvival"; + +export function pathfinder( + initialGame: ZombieSurvival, + initialZombie: Zombie, +): Direction[] { + const initialSnapshot = ZombieSurvival.fromSnapshot(initialGame.getSnapshot()) + .setZombies([initialZombie]) + .getSnapshot(); + + const graph = new Map>(); + const queue = [initialSnapshot]; + const wins = new Set(); + + while (queue.length > 0) { + const snapshot = queue.shift(); + + if (snapshot === undefined) { + continue; + } + + graph.set(snapshot, {}); + const game = ZombieSurvival.fromSnapshot(snapshot); + const moves = game.getZombie().listMoves(); + + moves.forEach((move) => { + const game = ZombieSurvival.fromSnapshot(snapshot); + game.getZombie().walk(move); + const newSnapshot = game.getSnapshot(); + + if (graph.has(newSnapshot) || queue.includes(newSnapshot)) { + return; + } + + const graphItem = graph.get(snapshot); + + if (graphItem !== undefined) { + graphItem[directionToString(move)] = newSnapshot; + } + + if (game.finished()) { + wins.add(newSnapshot); + } else { + queue.push(newSnapshot); + } + }); + } + + const bfsQueue: Array<{ + snapshot: string; + moves: Direction[]; + }> = [{ snapshot: initialSnapshot, moves: [] }]; + + while (bfsQueue.length > 0) { + const vertex = bfsQueue.shift(); + + if (vertex === undefined) { + continue; + } + + if (wins.has(vertex.snapshot)) { + return vertex.moves; + } + + const newVertex = graph.get(vertex.snapshot); + + if (newVertex === undefined) { + throw new Error("Tried getting undefined graph item"); + } + + Object.entries(newVertex).forEach(([move, snapshot]) => { + bfsQueue.push({ + snapshot, + moves: [...vertex.moves, directionFromString(move)], + }); + }); + } + + throw new Error("Unable to solve game"); +} diff --git a/package.json b/package.json index c862476..840730f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "predev": "convex dev --until-success && node setup.mjs --once && convex dashboard", "build": "next build", "start": "next start", + "test": "vitest", "lint": "next lint" }, "dependencies": { @@ -45,6 +46,7 @@ "postcss": "^8", "prettier": "3.3.2", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5", + "vitest": "^2.1.3" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8fb6f2d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({});