diff --git a/components/GameScreen.vue b/components/GameScreen.vue index dc8106d..6521997 100644 --- a/components/GameScreen.vue +++ b/components/GameScreen.vue @@ -1,20 +1,13 @@ diff --git a/package-lock.json b/package-lock.json index 9dad633..aba3d0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "bcrypt": "^5.1.1", "isolated-vm": "^4.6.0", "lru-cache": "^10.2.0", + "pixi.js": "^8.2.5", "prisma": "^5.9.1", "zod": "^3.22.4" }, @@ -2841,6 +2842,11 @@ "node": "^16 || ^18 || >= 20" } }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3527,6 +3533,16 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==" + }, + "node_modules/@types/earcut": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==" + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -4419,6 +4435,19 @@ "vue-component-type-helpers": "^2.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.43.tgz", + "integrity": "sha512-HoP+d+m+Kuq8CsE63BZ3+BYBKAemrqbHUNrCalxrUju5XW+q/094Q3oeIa+2pTraEbO8ckJmGpibzyGT4OV4YQ==" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -6298,6 +6327,11 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7125,8 +7159,7 @@ "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "node_modules/events": { "version": "3.3.0", @@ -8251,6 +8284,11 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==" + }, "node_modules/isolated-vm": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-4.7.2.tgz", @@ -10907,6 +10945,11 @@ "protocols": "^2.0.0" } }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==" + }, "node_modules/parse-url": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-8.1.0.tgz", @@ -11060,6 +11103,22 @@ "node": ">= 6" } }, + "node_modules/pixi.js": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.2.5.tgz", + "integrity": "sha512-cpN4f4Duj2mDPQeDQJQ33LXA8wIGMpWbhld1SklEwESRULYPUsY84zXC8B+Cyl+zojcXH7eY1OMEdbbIPMb+rA==", + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/css-font-loading-module": "^0.0.12", + "@types/earcut": "^2.1.4", + "@webgpu/types": "^0.1.40", + "@xmldom/xmldom": "^0.8.10", + "earcut": "^2.2.4", + "eventemitter3": "^5.0.1", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.1.2" + } + }, "node_modules/pkg-types": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.3.tgz", diff --git a/package.json b/package.json index c4ee73c..6fe418f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "bcrypt": "^5.1.1", "isolated-vm": "^4.6.0", "lru-cache": "^10.2.0", + "pixi.js": "^8.2.5", "prisma": "^5.9.1", "zod": "^3.22.4" } diff --git a/server/plugins/engine.ts b/server/plugins/engine.ts index 732471e..1543e13 100644 --- a/server/plugins/engine.ts +++ b/server/plugins/engine.ts @@ -4,7 +4,7 @@ import * as botUtils from "~/utils/botstore"; import World from "~/utils/world"; const MEMORY_LIMIT_MB = 64; -const TIME_LIMIT_MS = 100; +const TIME_LIMIT_MS = 75; export const WORLD = new World ({ width: 600, height: 600 }); @@ -14,6 +14,7 @@ async function runBot(code: string) { const jail = context.global; await jail.set("global", jail.derefInto()); const result = await context.eval(code, { timeout: TIME_LIMIT_MS }); + isolate.dispose(); return JSON.parse(result); } @@ -28,7 +29,7 @@ async function runBots({ bots, world, botApi }: RunBotArgs) { const botIds = Object.keys(bots); for (const botId of botIds) { - if (!state.bots.get(botId)) { + if (!world.hasBot(botId)) { world.addBot(botId); } } diff --git a/utils/prepareBotCode.spec.ts b/utils/prepareBotCode.spec.ts index 7fa285b..45a1d04 100644 --- a/utils/prepareBotCode.spec.ts +++ b/utils/prepareBotCode.spec.ts @@ -8,7 +8,7 @@ it("prepares bot code correctly when there is one bot in the world", () => { }; const state = { - bots: new Map([["1", { x: 0, y: 0, radius: 5, id: "1", color: "#00FF00" }]]), + bots: new Map([["1", { x: 0, y: 0, radius: 5, botId: "1", color: "#00FF00", spawnId: "s1" }]]), food: [], width: 100, height: 100, @@ -29,8 +29,8 @@ it("prepares bot code correctly when there are two bots in the world", () => { const state = { bots: new Map([ - ["1", { x: 0, y: 0, radius: 5, id: "1", color: "#00FF00" }], - ["2", { x: 10, y: 10, radius: 5, id: "2", color: "#00FF00" }], + ["1", { x: 0, y: 0, radius: 5, botId: "1", color: "#00FF00", spawnId: "s1" }], + ["2", { x: 10, y: 10, radius: 5, botId: "2", color: "#00FF00", spawnId: "s2" }], ]), food: [], width: 100, @@ -52,9 +52,9 @@ it("prepares bot code correctly when there are three bots and some food in the w const state = { bots: new Map([ - ["1", { x: 0, y: 0, radius: 5, id: "1", color: "#00FF00" }], - ["2", { x: 10, y: 10, radius: 5, id: "2", color: "#00FF00" }], - ["3", { x: 20, y: 20, radius: 5, id: "3", color: "#00FF00" }], + ["1", { x: 0, y: 0, radius: 5, botId: "1", color: "#00FF00", spawnId: "s1" }], + ["2", { x: 10, y: 10, radius: 5, botId: "2", color: "#00FF00", spawnId: "s2" }], + ["3", { x: 20, y: 20, radius: 5, botId: "3", color: "#00FF00", spawnId: "s3" }], ]), food: [{ x: 30, y: 30, radius: 5 }, { x: 40, y: 40, radius: 5 }], width: 100, diff --git a/utils/prepareBotCode.ts b/utils/prepareBotCode.ts index 9de91e0..e06a75f 100644 --- a/utils/prepareBotCode.ts +++ b/utils/prepareBotCode.ts @@ -10,7 +10,7 @@ type PrepareBotCodeArgs = { export default function prepareBotCode({ bot, state, botApi }: PrepareBotCodeArgs): string | undefined { const { code } = bot; - const botObject = state.bots.get(bot.id); + const botObject = [...state.bots.values()].find(b => bot.id === b.botId); if (!botObject) { // TODO(yurij): handle this better console.error(`Bot with id ${bot.id} not found in the world`); @@ -19,7 +19,7 @@ export default function prepareBotCode({ bot, state, botApi }: PrepareBotCodeArg const me = { x: botObject.x, y: botObject.y, radius: botObject.radius }; const otherPlayers = [...state.bots.values()] - .filter(b => b.id !== bot.id) + .filter(b => b.botId !== bot.id) .map(b => ({ x: b.x, y: b.y, radius: b.radius })); const food = state.food.map(f => ({ x: f.x, y: f.y, radius: f.radius })); diff --git a/utils/world.ts b/utils/world.ts index 7eb65fc..89788f8 100644 --- a/utils/world.ts +++ b/utils/world.ts @@ -12,8 +12,9 @@ interface Position { } export interface BotSprite extends Sprite { - id: string; + botId: string; color: string; + spawnId: string; } export interface WorldState { @@ -43,7 +44,8 @@ const BOT_COLORS = [ ]; export default class World { - private bots: BotSprites = new Map(); + private botSpawns: BotSprites = new Map(); + private botIdToSpawnId: Map = new Map(); private food: Sprite[] = []; private width: number; private height: number; @@ -75,7 +77,7 @@ export default class World { const takenPositions: Set = new Set(); // to avoid computing distances between points when spawning new bots, assume that bots are square - for (const bot of this.bots.values()) { + for (const bot of this.botSpawns.values()) { const { x, y, radius } = bot; const safeRadius = radius + this.minSpawnDistance + newBotRadius; @@ -109,7 +111,11 @@ export default class World { return availablePositions; } - addBot(id: string): BotSprite { + hasBot(botId: string) { + return this.botIdToSpawnId.has(botId); + } + + addBot(botId: string): BotSprite { const availablePositions = this.getAvailableBotPositions(this.newBotRadius); if (availablePositions.length === 0) { @@ -119,14 +125,25 @@ export default class World { const randomPosition = getRandomElement(availablePositions); const color = getRandomElement(BOT_COLORS); - const newBot = { ...randomPosition, id, radius: this.newBotRadius, color }; - this.bots.set(id, newBot); + // spawnId is unique per bot live + // useful to track respawns on the UI + const spawnId = Math.random().toString(36).substring(5); + + const newBot = { + ...randomPosition, + botId, + radius: this.newBotRadius, + color, + spawnId, + }; + this.botSpawns.set(spawnId, newBot); + this.botIdToSpawnId.set(botId, spawnId); return newBot; } getState(): WorldState { return { - bots: this.bots, + bots: this.botSpawns, food: this.food, width: this.width, height: this.height, @@ -146,10 +163,15 @@ export default class World { return { x: newX, y: newY }; } - moveBot(id: string, x: number, y: number) { - const bot = this.bots.get(id); + moveBot(botId: string, x: number, y: number) { + const spawnId = this.botIdToSpawnId.get(botId); + if (!spawnId) { + throw new Error(`Bot with id ${botId} not found`); + } + + const bot = this.botSpawns.get(spawnId); if (!bot) { - throw new Error(`Bot with id ${id} not found`); + throw new Error(`Bot with id ${botId} not found`); } const distance = World.distance(bot, { x, y }); @@ -164,28 +186,33 @@ export default class World { bot.y = y; let checkLimit = 5; - while (this.checkCollisions(id) && --checkLimit) { + while (this.checkCollisions(botId) && --checkLimit) { continue; } } checkCollisions(botId: string) { - const bot = this.bots.get(botId); + const spawnId = this.botIdToSpawnId.get(botId); + if (!spawnId) { + throw new Error(`Bot with id ${botId} not found`); + } + + const bot = this.botSpawns.get(spawnId); if (!bot) { throw new Error(`Bot with id ${botId} not found`); } // check if bot eats other bots - const bots = this.bots; + const botSpawns = this.botSpawns; const botIdsToRemove: string[] = []; - for (const [otherBotId, otherBot] of bots.entries()) { - if (otherBot.id === botId) { + for (const [otherBotSpawnId, otherBot] of botSpawns.entries()) { + if (otherBotSpawnId === spawnId) { continue; } const distance = World.distance(bot, otherBot); if (distance < bot.radius && bot.radius > otherBot.radius) { - botIdsToRemove.push(otherBotId); + botIdsToRemove.push(otherBotSpawnId); } } @@ -201,13 +228,14 @@ export default class World { } // remove food and bots - for (const botIdToRemove of botIdsToRemove) { - const botToRemove = bots.get(botIdToRemove); + for (const botSpawnIdToRemove of botIdsToRemove) { + const botToRemove = botSpawns.get(botSpawnIdToRemove); if (!botToRemove) { continue; } bot.radius += botToRemove.radius; - bots.delete(botIdToRemove); + this.botIdToSpawnId.delete(botToRemove.botId); + botSpawns.delete(botSpawnIdToRemove); } // sort in descending order to avoid index shifting when removing elements