├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode ├── convex.code-snippets └── settings.json ├── ARCHITECTURE.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── a16z.png ├── background.webp ├── close.svg ├── convex.svg ├── help.svg ├── interact.svg ├── star.svg ├── ui │ ├── box.svg │ ├── bubble-left.svg │ ├── bubble-right.svg │ ├── button.svg │ ├── button_pressed.svg │ ├── chats.svg │ ├── desc.svg │ ├── frame.svg │ └── jewel_box.svg └── volume.svg ├── convex ├── _generated │ ├── api.d.ts │ ├── api.js │ ├── dataModel.d.ts │ ├── server.d.ts │ └── server.js ├── agent │ ├── constants.ts │ ├── conversation.ts │ ├── embeddingsCache.ts │ ├── init.ts │ ├── main.ts │ ├── memory.ts │ └── schema.ts ├── auth.config.js ├── constants.ts ├── crons.ts ├── engine │ ├── constants.ts │ ├── game.ts │ ├── gameTable.ts │ ├── historicalTable.ts │ └── schema.ts ├── game │ ├── aiTown.ts │ ├── conversationMembers.ts │ ├── conversations.ts │ ├── inputs.ts │ ├── locations.ts │ ├── main.ts │ ├── movement.ts │ ├── players.ts │ └── schema.ts ├── http.ts ├── init.ts ├── messages.ts ├── music.ts ├── schema.ts ├── testing.ts ├── util │ ├── assertNever.ts │ ├── asyncMap.ts │ ├── geometry.ts │ ├── isSimpleObject.ts │ ├── minheap.test.ts │ ├── minheap.ts │ ├── openai.ts │ ├── sleep.ts │ └── types.ts └── world.ts ├── data ├── characters.ts ├── firstmap.ts └── spritesheets │ ├── f1.ts │ ├── f2.ts │ ├── f3.ts │ ├── f4.ts │ ├── f5.ts │ ├── f6.ts │ ├── f7.ts │ ├── f8.ts │ ├── p1.ts │ ├── p2.ts │ ├── p3.ts │ ├── player.ts │ └── types.ts ├── index.html ├── jest.config.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── 32x32folk.png │ ├── background.mp3 │ ├── fonts │ │ ├── upheaval_pro.ttf │ │ └── vcr_osd_mono.ttf │ ├── heart-empty.png │ ├── player.png │ ├── rpg-tileset.png │ └── tilemap.json └── favicon.ico ├── src ├── App.tsx ├── components │ ├── Character.tsx │ ├── ConvexClientProvider.tsx │ ├── DebugPath.tsx │ ├── DebugTimeManager.tsx │ ├── Game.tsx │ ├── MessageInput.tsx │ ├── Messages.tsx │ ├── PixiGame.tsx │ ├── PixiStaticMap.tsx │ ├── PixiViewport.tsx │ ├── Player.tsx │ ├── PlayerDetails.tsx │ ├── PositionIndicator.tsx │ └── buttons │ │ ├── Button.tsx │ │ ├── InteractButton.tsx │ │ ├── LoginButton.tsx │ │ └── MusicButton.tsx ├── hooks │ ├── sendInput.ts │ ├── useHistoricalTime.ts │ ├── useHistoricalValue.ts │ └── useWorldHeartbeat.ts ├── index.css ├── main.tsx ├── toasts.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── vercel.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | game/node_modules/ 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env 38 | /.env.prod 39 | /fly.toml 40 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack* 2 | .eslintrc.js 3 | next.config.js 4 | tailwind.config.js 5 | postcss.config.js 6 | convex/_generated/* 7 | dist/* 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | plugins: ['@typescript-eslint'], 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 6 | 'plugin:@typescript-eslint/recommended-type-checked', 7 | ], 8 | parserOptions: { 9 | project: './tsconfig.json', 10 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 11 | sourceType: 'module', // Allows for the use of imports 12 | }, 13 | rules: { 14 | '@typescript-eslint/no-explicit-any': 'off', 15 | '@typescript-eslint/explicit-function-return-type': 'off', 16 | '@typescript-eslint/no-unused-vars': [ 17 | 'warn', 18 | { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, 19 | ], 20 | '@typescript-eslint/no-non-null-assertion': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | game/node_modules/ 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env 38 | /.env.prod 39 | /fly.toml 40 | 41 | # Vite build 42 | dist 43 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "bracketSpacing": true, 5 | "tabWidth": 2, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/convex.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Convex Imports": { 3 | "prefix": "convex:imports", 4 | "body": [ 5 | "import { v } from \"convex/values\";", 6 | "import { api, internal } from \"./_generated/api\";", 7 | "import { Doc, Id } from \"./_generated/dataModel\";", 8 | "import {", 9 | " action,", 10 | " internalAction,", 11 | " internalMutation,", 12 | " internalQuery,", 13 | " mutation,", 14 | " query,", 15 | "} from \"./_generated/server\";" 16 | ], 17 | "scope": "javascript,typescript", 18 | "isFileTemplate": true 19 | }, 20 | 21 | "Convex Query": { 22 | "prefix": "convex:query", 23 | "body": [ 24 | "export const $1 = query({", 25 | " args: {},", 26 | " handler: async (ctx, args) => {", 27 | " $0", 28 | " },", 29 | "});" 30 | ], 31 | "scope": "javascript,typescript" 32 | }, 33 | 34 | "Convex Internal Query": { 35 | "prefix": "convex:internalQuery", 36 | "body": [ 37 | "export const $1 = internalQuery({", 38 | " args: {},", 39 | " handler: async (ctx, args) => {", 40 | " $0", 41 | " },", 42 | "});" 43 | ], 44 | "scope": "javascript,typescript" 45 | }, 46 | 47 | "Convex Mutation": { 48 | "prefix": "convex:mutation", 49 | "body": [ 50 | "export const $1 = mutation({", 51 | " args: {},", 52 | " handler: async (ctx, args) => {", 53 | " $0", 54 | " },", 55 | "});" 56 | ], 57 | "scope": "javascript,typescript" 58 | }, 59 | 60 | "Convex Internal Mutation": { 61 | "prefix": "convex:internalMutation", 62 | "body": [ 63 | "export const $1 = internalMutation({", 64 | " args: {},", 65 | " handler: async (ctx, args) => {", 66 | " $0", 67 | " },", 68 | "});" 69 | ], 70 | "scope": "javascript,typescript" 71 | }, 72 | 73 | "Convex Action": { 74 | "prefix": "convex:action", 75 | "body": [ 76 | "import { action } from \"./_generated/server\";", 77 | "", 78 | "export const $1 = action({", 79 | " args: {},", 80 | " handler: async (ctx, args) => {", 81 | " $0", 82 | " },", 83 | "});" 84 | ], 85 | "scope": "javascript,typescript" 86 | }, 87 | 88 | "Convex Internal Action": { 89 | "prefix": "convex:internalAction", 90 | "body": [ 91 | "import { internalAction } from \"./_generated/server\";", 92 | "", 93 | "export const $1 = internalAction({", 94 | " args: {},", 95 | " handler: async (ctx, args) => {", 96 | " $0", 97 | " },", 98 | "});" 99 | ], 100 | "scope": "javascript,typescript" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.tabSize": 2, 4 | "[html]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "[javascript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[jsonc]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[typescript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescriptreact]": { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode" 18 | }, 19 | "typescript.preferences.importModuleSpecifierEnding": "auto", 20 | "javascript.preferences.importModuleSpecifierEnding": "auto" 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=19.8.1 5 | FROM node:${NODE_VERSION}-slim as base 6 | 7 | LABEL fly_launch_runtime="Next.js" 8 | 9 | # Next.js app lives here 10 | WORKDIR /app 11 | 12 | # Set production environment 13 | ENV NODE_ENV=production 14 | 15 | 16 | # Throw-away build stage to reduce size of final image 17 | FROM base as build 18 | 19 | # Install packages needed to build node modules 20 | RUN apt-get update -qq && \ 21 | apt-get install -y python-is-python3 pkg-config build-essential 22 | 23 | # Install node modules 24 | COPY --link package-lock.json package.json ./ 25 | RUN npm ci --include=dev 26 | 27 | # Copy application code 28 | COPY --link . . 29 | 30 | ARG NEXT_PUBLIC_CLERK_SIGN_IN_URL 31 | ARG NEXT_PUBLIC_CLERK_SIGN_UP_URL 32 | ARG NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL 33 | ARG NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL 34 | ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY 35 | ARG NEXT_PUBLIC_CONVEX_URL 36 | 37 | # Build application 38 | RUN npm run build 39 | 40 | # Remove development dependencies 41 | RUN npm prune --omit=dev 42 | 43 | 44 | # Final stage for app image 45 | FROM base 46 | 47 | # Copy built application 48 | COPY --from=build /app /app 49 | 50 | # Start the server by default, this can be overwritten at runtime 51 | EXPOSE 3000 52 | CMD [ "npm", "run", "start" ] 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 a16z-infra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # THIS PROJECT HAS MOVED 2 | 3 | **You can find the latest active repo at [`a16z-infra/ai-town`](https://github.com/a16z-infra/ai-town/)** 4 | 5 | # AI Town 🏠💻💌 6 | 7 | [Live Demo](https://www.convex.dev/ai-town) 8 | 9 | [Join our community Discord: AI Stack Devs](https://discord.gg/PQUmTBTGmT) 10 | -------------------------------------------------------------------------------- /assets/a16z.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/assets/a16z.png -------------------------------------------------------------------------------- /assets/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/assets/background.webp -------------------------------------------------------------------------------- /assets/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /assets/convex.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /assets/interact.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/ui/box.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/ui/bubble-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /assets/ui/bubble-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /assets/ui/button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/ui/button_pressed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/ui/chats.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /assets/ui/desc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /assets/ui/frame.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /assets/ui/jewel_box.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /assets/volume.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.3.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | ApiFromModules, 14 | FilterApi, 15 | FunctionReference, 16 | } from "convex/server"; 17 | import type * as agent_constants from "../agent/constants"; 18 | import type * as agent_conversation from "../agent/conversation"; 19 | import type * as agent_embeddingsCache from "../agent/embeddingsCache"; 20 | import type * as agent_init from "../agent/init"; 21 | import type * as agent_main from "../agent/main"; 22 | import type * as agent_memory from "../agent/memory"; 23 | import type * as constants from "../constants"; 24 | import type * as crons from "../crons"; 25 | import type * as engine_constants from "../engine/constants"; 26 | import type * as engine_game from "../engine/game"; 27 | import type * as engine_gameTable from "../engine/gameTable"; 28 | import type * as engine_historicalTable from "../engine/historicalTable"; 29 | import type * as game_aiTown from "../game/aiTown"; 30 | import type * as game_conversationMembers from "../game/conversationMembers"; 31 | import type * as game_conversations from "../game/conversations"; 32 | import type * as game_inputs from "../game/inputs"; 33 | import type * as game_locations from "../game/locations"; 34 | import type * as game_main from "../game/main"; 35 | import type * as game_movement from "../game/movement"; 36 | import type * as game_players from "../game/players"; 37 | import type * as http from "../http"; 38 | import type * as init from "../init"; 39 | import type * as messages from "../messages"; 40 | import type * as music from "../music"; 41 | import type * as testing from "../testing"; 42 | import type * as util_assertNever from "../util/assertNever"; 43 | import type * as util_asyncMap from "../util/asyncMap"; 44 | import type * as util_geometry from "../util/geometry"; 45 | import type * as util_isSimpleObject from "../util/isSimpleObject"; 46 | import type * as util_minheap from "../util/minheap"; 47 | import type * as util_openai from "../util/openai"; 48 | import type * as util_sleep from "../util/sleep"; 49 | import type * as util_types from "../util/types"; 50 | import type * as world from "../world"; 51 | 52 | /** 53 | * A utility for referencing Convex functions in your app's API. 54 | * 55 | * Usage: 56 | * ```js 57 | * const myFunctionReference = api.myModule.myFunction; 58 | * ``` 59 | */ 60 | declare const fullApi: ApiFromModules<{ 61 | "agent/constants": typeof agent_constants; 62 | "agent/conversation": typeof agent_conversation; 63 | "agent/embeddingsCache": typeof agent_embeddingsCache; 64 | "agent/init": typeof agent_init; 65 | "agent/main": typeof agent_main; 66 | "agent/memory": typeof agent_memory; 67 | constants: typeof constants; 68 | crons: typeof crons; 69 | "engine/constants": typeof engine_constants; 70 | "engine/game": typeof engine_game; 71 | "engine/gameTable": typeof engine_gameTable; 72 | "engine/historicalTable": typeof engine_historicalTable; 73 | "game/aiTown": typeof game_aiTown; 74 | "game/conversationMembers": typeof game_conversationMembers; 75 | "game/conversations": typeof game_conversations; 76 | "game/inputs": typeof game_inputs; 77 | "game/locations": typeof game_locations; 78 | "game/main": typeof game_main; 79 | "game/movement": typeof game_movement; 80 | "game/players": typeof game_players; 81 | http: typeof http; 82 | init: typeof init; 83 | messages: typeof messages; 84 | music: typeof music; 85 | testing: typeof testing; 86 | "util/assertNever": typeof util_assertNever; 87 | "util/asyncMap": typeof util_asyncMap; 88 | "util/geometry": typeof util_geometry; 89 | "util/isSimpleObject": typeof util_isSimpleObject; 90 | "util/minheap": typeof util_minheap; 91 | "util/openai": typeof util_openai; 92 | "util/sleep": typeof util_sleep; 93 | "util/types": typeof util_types; 94 | world: typeof world; 95 | }>; 96 | export declare const api: FilterApi< 97 | typeof fullApi, 98 | FunctionReference 99 | >; 100 | export declare const internal: FilterApi< 101 | typeof fullApi, 102 | FunctionReference 103 | >; 104 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.3.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { anyApi } from "convex/server"; 13 | 14 | /** 15 | * A utility for referencing Convex functions in your app's API. 16 | * 17 | * Usage: 18 | * ```js 19 | * const myFunctionReference = api.myModule.myFunction; 20 | * ``` 21 | */ 22 | export const api = anyApi; 23 | export const internal = anyApi; 24 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.3.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { DataModelFromSchemaDefinition } from "convex/server"; 13 | import type { DocumentByName, TableNamesInDataModel } from "convex/server"; 14 | import type { GenericId } from "convex/values"; 15 | import schema from "../schema"; 16 | 17 | /** 18 | * The names of all of your Convex tables. 19 | */ 20 | export type TableNames = TableNamesInDataModel; 21 | 22 | /** 23 | * The type of a document stored in Convex. 24 | * 25 | * @typeParam TableName - A string literal type of the table name (like "users"). 26 | */ 27 | export type Doc = DocumentByName< 28 | DataModel, 29 | TableName 30 | >; 31 | 32 | /** 33 | * An identifier for a document in Convex. 34 | * 35 | * Convex documents are uniquely identified by their `Id`, which is accessible 36 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 37 | * 38 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 39 | * 40 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 41 | * strings when type checking. 42 | * 43 | * @typeParam TableName - A string literal type of the table name (like "users"). 44 | */ 45 | export type Id = GenericId; 46 | 47 | /** 48 | * A type describing your Convex data model. 49 | * 50 | * This type includes information about what tables you have, the type of 51 | * documents stored in those tables, and the indexes defined on them. 52 | * 53 | * This type is used to parameterize methods like `queryGeneric` and 54 | * `mutationGeneric` to make them type-safe. 55 | */ 56 | export type DataModel = DataModelFromSchemaDefinition; 57 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.3.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | ActionBuilder, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | } from "convex/server"; 23 | import type { DataModel } from "./dataModel.js"; 24 | 25 | /** 26 | * Define a query in this Convex app's public API. 27 | * 28 | * This function will be allowed to read your Convex database and will be accessible from the client. 29 | * 30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 32 | */ 33 | export declare const query: QueryBuilder; 34 | 35 | /** 36 | * Define a query that is only accessible from other Convex functions (but not from the client). 37 | * 38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 39 | * 40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 42 | */ 43 | export declare const internalQuery: QueryBuilder; 44 | 45 | /** 46 | * Define a mutation in this Convex app's public API. 47 | * 48 | * This function will be allowed to modify your Convex database and will be accessible from the client. 49 | * 50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 52 | */ 53 | export declare const mutation: MutationBuilder; 54 | 55 | /** 56 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 57 | * 58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 59 | * 60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 62 | */ 63 | export declare const internalMutation: MutationBuilder; 64 | 65 | /** 66 | * Define an action in this Convex app's public API. 67 | * 68 | * An action is a function which can execute any JavaScript code, including non-deterministic 69 | * code and code with side-effects, like calling third-party services. 70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 72 | * 73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 75 | */ 76 | export declare const action: ActionBuilder; 77 | 78 | /** 79 | * Define an action that is only accessible from other Convex functions (but not from the client). 80 | * 81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 83 | */ 84 | export declare const internalAction: ActionBuilder; 85 | 86 | /** 87 | * Define an HTTP action. 88 | * 89 | * This function will be used to respond to HTTP requests received by a Convex 90 | * deployment if the requests matches the path and method where this action 91 | * is routed. Be sure to route your action in `convex/http.js`. 92 | * 93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.3.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | actionGeneric, 14 | httpActionGeneric, 15 | queryGeneric, 16 | mutationGeneric, 17 | internalActionGeneric, 18 | internalMutationGeneric, 19 | internalQueryGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /convex/agent/constants.ts: -------------------------------------------------------------------------------- 1 | // Don't talk to anyone for 15s after having a conversation. 2 | export const CONVERATION_COOLDOWN = 15000; 3 | 4 | // Don't talk to a player within 60s of talking to them. 5 | export const PLAYER_CONVERSATION_COOLDOWN = 60000; 6 | 7 | // Invite 80% of invites that come from other agents. 8 | export const INVITE_ACCEPT_PROBABILITY = 0.8; 9 | 10 | // Wait for 1m for invites to be accepted. 11 | export const INVITE_TIMEOUT = 60000; 12 | 13 | // Wait for 20s for another player to say something before jumping in. 14 | export const AWKWARD_CONVERSATION_TIMEOUT = 20000; 15 | 16 | // Leave a conversation after 2m of participating. 17 | export const MAX_CONVERSATION_DURATION = 120 * 1000; 18 | 19 | // Leave a conversation if it has more than 8 messages; 20 | export const MAX_CONVERSATION_MESSAGES = 8; 21 | 22 | // Wait for 1s after sending an input to the engine. We can remove this 23 | // once we can await on an input being processed. 24 | export const INPUT_DELAY = 1000; 25 | 26 | // Timeout a request to the conversation layer after a minute. 27 | export const ACTION_TIMEOUT = 60 * 1000; 28 | 29 | // Wait for at least two seconds before sending another message. 30 | export const MESSAGE_COOLDOWN = 2000; 31 | -------------------------------------------------------------------------------- /convex/agent/embeddingsCache.ts: -------------------------------------------------------------------------------- 1 | import { defineTable } from 'convex/server'; 2 | import { v } from 'convex/values'; 3 | import { ActionCtx, internalMutation, internalQuery } from '../_generated/server'; 4 | import { internal } from '../_generated/api'; 5 | import * as openai from '../util/openai'; 6 | import { Id } from '../_generated/dataModel'; 7 | 8 | const selfInternal = internal.agent.embeddingsCache; 9 | 10 | export async function fetch(ctx: ActionCtx, text: string) { 11 | const result = await fetchBatch(ctx, [text]); 12 | return result.embeddings[0]; 13 | } 14 | 15 | export async function fetchBatch(ctx: ActionCtx, texts: string[]) { 16 | const start = Date.now(); 17 | 18 | const textHashes = await Promise.all(texts.map((text) => hashText(text))); 19 | const results = new Array(texts.length); 20 | const cacheResults = await ctx.runQuery(selfInternal.getEmbeddingsByText, { 21 | textHashes, 22 | }); 23 | for (const { index, embedding } of cacheResults) { 24 | results[index] = embedding; 25 | } 26 | const toWrite = []; 27 | if (cacheResults.length < texts.length) { 28 | const missingIndexes = [...results.keys()].filter((i) => !results[i]); 29 | const missingTexts = missingIndexes.map((i) => texts[i]); 30 | const response = await openai.fetchEmbeddingBatch(missingTexts); 31 | if (response.embeddings.length !== missingIndexes.length) { 32 | throw new Error( 33 | `Expected ${missingIndexes.length} embeddings, got ${response.embeddings.length}`, 34 | ); 35 | } 36 | for (let i = 0; i < missingIndexes.length; i++) { 37 | const resultIndex = missingIndexes[i]; 38 | toWrite.push({ 39 | textHash: textHashes[resultIndex], 40 | embedding: response.embeddings[i], 41 | }); 42 | results[resultIndex] = response.embeddings[i]; 43 | } 44 | } 45 | if (toWrite.length > 0) { 46 | await ctx.runMutation(selfInternal.writeEmbeddings, { embeddings: toWrite }); 47 | } 48 | return { 49 | embeddings: results, 50 | hits: cacheResults.length, 51 | ms: Date.now() - start, 52 | }; 53 | } 54 | 55 | async function hashText(text: string) { 56 | const textEncoder = new TextEncoder(); 57 | const buf = textEncoder.encode(text); 58 | const textHash = await crypto.subtle.digest('SHA-256', buf); 59 | return textHash; 60 | } 61 | 62 | export const getEmbeddingsByText = internalQuery({ 63 | args: { textHashes: v.array(v.bytes()) }, 64 | handler: async (ctx, args) => { 65 | const out = []; 66 | for (let i = 0; i < args.textHashes.length; i++) { 67 | const textHash = args.textHashes[i]; 68 | const result = await ctx.db 69 | .query('embeddingsCache') 70 | .withIndex('text', (q) => q.eq('textHash', textHash)) 71 | .first(); 72 | if (result) { 73 | out.push({ 74 | index: i, 75 | embeddingId: result._id, 76 | embedding: result.embedding, 77 | }); 78 | } 79 | } 80 | return out; 81 | }, 82 | }); 83 | 84 | export const writeEmbeddings = internalMutation({ 85 | args: { 86 | embeddings: v.array( 87 | v.object({ 88 | textHash: v.bytes(), 89 | embedding: v.array(v.float64()), 90 | }), 91 | ), 92 | }, 93 | handler: async (ctx, args) => { 94 | const ids = []; 95 | for (const embedding of args.embeddings) { 96 | ids.push(await ctx.db.insert('embeddingsCache', embedding)); 97 | } 98 | return ids; 99 | }, 100 | }); 101 | 102 | const embeddingsCache = v.object({ 103 | textHash: v.bytes(), 104 | embedding: v.array(v.float64()), 105 | }); 106 | 107 | export const embeddingsCacheTables = { 108 | embeddingsCache: defineTable(embeddingsCache).index('text', ['textHash']), 109 | }; 110 | -------------------------------------------------------------------------------- /convex/agent/init.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { internalMutation } from '../_generated/server'; 3 | import { Descriptions } from '../../data/characters'; 4 | import { internal } from '../_generated/api'; 5 | 6 | export const initAgent = internalMutation({ 7 | args: { 8 | worldId: v.id('worlds'), 9 | playerId: v.id('players'), 10 | character: v.string(), 11 | }, 12 | handler: async (ctx, args) => { 13 | const existingAgent = await ctx.db 14 | .query('agents') 15 | .withIndex('playerId', (q) => q.eq('playerId', args.playerId)) 16 | .first(); 17 | if (existingAgent) { 18 | throw new Error(`Agent for player ${args.playerId} already exists`); 19 | } 20 | const description = Descriptions.find((d) => d.character === args.character); 21 | if (!description) { 22 | throw new Error(`No description found for character ${args.character}`); 23 | } 24 | const agentId = await ctx.db.insert('agents', { 25 | worldId: args.worldId, 26 | playerId: args.playerId, 27 | identity: description.identity, 28 | plan: description.plan, 29 | generationNumber: 0, 30 | }); 31 | await ctx.scheduler.runAfter(0, internal.agent.main.agentRun, { 32 | agentId, 33 | generationNumber: 0, 34 | }); 35 | }, 36 | }); 37 | 38 | export const kickAgents = internalMutation({ 39 | args: { 40 | worldId: v.id('worlds'), 41 | }, 42 | handler: async (ctx, args) => { 43 | const agents = await ctx.db 44 | .query('agents') 45 | .withIndex('worldId', (q) => q.eq('worldId', args.worldId)) 46 | .collect(); 47 | for (const agent of agents) { 48 | const generationNumber = agent.generationNumber + 1; 49 | await ctx.db.patch(agent._id, { generationNumber }); 50 | await ctx.scheduler.runAfter(0, internal.agent.main.agentRun, { 51 | agentId: agent._id, 52 | generationNumber, 53 | }); 54 | } 55 | }, 56 | }); 57 | 58 | export const stopAgents = internalMutation({ 59 | args: { 60 | worldId: v.id('worlds'), 61 | }, 62 | handler: async (ctx, args) => { 63 | const agents = await ctx.db 64 | .query('agents') 65 | .withIndex('worldId', (q) => q.eq('worldId', args.worldId)) 66 | .collect(); 67 | for (const agent of agents) { 68 | await ctx.db.patch(agent._id, { generationNumber: agent.generationNumber + 1 }); 69 | } 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /convex/agent/schema.ts: -------------------------------------------------------------------------------- 1 | import { memoryTables } from './memory'; 2 | import { defineTable } from 'convex/server'; 3 | import { v } from 'convex/values'; 4 | import { embeddingsCacheTables } from './embeddingsCache'; 5 | 6 | const agents = v.object({ 7 | worldId: v.id('worlds'), 8 | playerId: v.id('players'), 9 | identity: v.string(), 10 | plan: v.string(), 11 | 12 | isThinking: v.optional(v.object({ since: v.number() })), 13 | 14 | generationNumber: v.number(), 15 | }); 16 | 17 | export const agentTables = { 18 | agents: defineTable(agents).index('playerId', ['playerId']).index('worldId', ['worldId']), 19 | ...memoryTables, 20 | ...embeddingsCacheTables, 21 | }; 22 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: process.env.CLERK_ISSUER_URL, 5 | applicationID: 'convex', 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /convex/constants.ts: -------------------------------------------------------------------------------- 1 | export const IDLE_WORLD_TIMEOUT = 5 * 60 * 1000; 2 | export const WORLD_HEARTBEAT_INTERVAL = 60 * 1000; 3 | 4 | export const MAX_STEP = 10 * 60 * 1000; 5 | export const TICK = 16; 6 | export const STEP_INTERVAL = 1000; 7 | 8 | export const PATHFINDING_TIMEOUT = 60 * 1000; 9 | export const PATHFINDING_BACKOFF = 1000; 10 | export const CONVERSATION_DISTANCE = 1.3; 11 | export const TYPING_TIMEOUT = 15 * 1000; 12 | export const COLLISION_THRESHOLD = 0.75; 13 | -------------------------------------------------------------------------------- /convex/crons.ts: -------------------------------------------------------------------------------- 1 | import { cronJobs } from 'convex/server'; 2 | import { IDLE_WORLD_TIMEOUT } from './constants'; 3 | import { internal } from './_generated/api'; 4 | 5 | const crons = cronJobs(); 6 | 7 | crons.interval( 8 | 'stop inactive worlds', 9 | { seconds: IDLE_WORLD_TIMEOUT / 1000 }, 10 | internal.world.stopInactiveWorlds, 11 | ); 12 | 13 | export default crons; 14 | -------------------------------------------------------------------------------- /convex/engine/constants.ts: -------------------------------------------------------------------------------- 1 | // Don't preempt the engine unless its scheduled time is at least this far in the future. 2 | export const ENGINE_WAKEUP_THRESHOLD = 1000; 3 | -------------------------------------------------------------------------------- /convex/engine/gameTable.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseReader, DatabaseWriter } from '../_generated/server'; 2 | import { Doc, Id, TableNames } from '../_generated/dataModel'; 3 | import { FieldPaths, WithoutSystemFields } from 'convex/server'; 4 | 5 | export abstract class GameTable { 6 | abstract table: T; 7 | abstract db: DatabaseWriter; 8 | 9 | data: Map, Doc> = new Map(); 10 | modified: Set> = new Set(); 11 | deleted: Set> = new Set(); 12 | 13 | abstract isActive(doc: Doc): boolean; 14 | 15 | constructor(rows: Doc[]) { 16 | for (const row of rows) { 17 | this.data.set(row._id, row); 18 | } 19 | } 20 | 21 | async insert(row: WithoutSystemFields>): Promise> { 22 | const id = await this.db.insert(this.table, row); 23 | const withSystemFields = await this.db.get(id); 24 | if (!withSystemFields) { 25 | throw new Error(`Failed to db.get() inserted row`); 26 | } 27 | this.data.set(id, withSystemFields); 28 | return id; 29 | } 30 | 31 | delete(id: Id) { 32 | if (this.data.delete(id)) { 33 | this.deleted.add(id); 34 | } 35 | } 36 | 37 | lookup(id: Id): Doc { 38 | const row = this.data.get(id); 39 | if (!row) { 40 | throw new Error(`Invalid ID: ${id}`); 41 | } 42 | if (!this.isActive(row)) { 43 | throw new Error(`ID is inactive: ${id}`); 44 | } 45 | const handlers = { 46 | defineProperty: (target: any, key: any, descriptor: any) => { 47 | this.markModified(id); 48 | return Reflect.defineProperty(target, key, descriptor); 49 | }, 50 | get: (target: any, prop: any, receiver: any) => { 51 | const value = Reflect.get(target, prop, receiver); 52 | if (typeof value === 'object') { 53 | return new Proxy>(value, handlers); 54 | } else { 55 | return value; 56 | } 57 | }, 58 | set: (obj: any, prop: any, value: any) => { 59 | this.markModified(id); 60 | return Reflect.set(obj, prop, value); 61 | }, 62 | deleteProperty: (target: any, prop: any) => { 63 | this.markModified(id); 64 | return Reflect.deleteProperty(target, prop); 65 | }, 66 | }; 67 | return new Proxy>(row, handlers); 68 | } 69 | 70 | find(f: (doc: Doc) => boolean): Doc | null { 71 | for (const id of this.allIds()) { 72 | const doc = this.lookup(id); 73 | if (f(doc)) { 74 | return doc; 75 | } 76 | } 77 | return null; 78 | } 79 | 80 | filter(f: (doc: Doc) => boolean): Array> { 81 | const out = []; 82 | for (const id of this.allIds()) { 83 | const doc = this.lookup(id); 84 | if (f(doc)) { 85 | out.push(doc); 86 | } 87 | } 88 | return out; 89 | } 90 | 91 | allIds(): Array> { 92 | const ids = []; 93 | for (const [id, doc] of this.data.entries()) { 94 | if (!this.isActive(doc)) { 95 | continue; 96 | } 97 | ids.push(id); 98 | } 99 | return ids; 100 | } 101 | 102 | allDocuments(): Array> { 103 | return this.allIds().map((id) => this.lookup(id)); 104 | } 105 | 106 | private markModified(id: Id) { 107 | const data = this.data.get(id); 108 | if (!data) { 109 | console.warn(`Modifying deleted id ${id}`); 110 | return; 111 | } 112 | if (!this.isActive(data)) { 113 | console.warn(`Modifying inactive id ${id}`); 114 | return; 115 | } 116 | this.modified.add(id); 117 | } 118 | 119 | async save() { 120 | for (const id of this.deleted) { 121 | await this.db.delete(id); 122 | } 123 | for (const id of this.modified) { 124 | const row = this.data.get(id); 125 | if (!row) { 126 | throw new Error(`Invalid modified id: ${id}`); 127 | } 128 | // Somehow TypeScript isn't able to figure out that our 129 | // generic `Doc` unifies with `replace()`'s type. 130 | await this.db.replace(id, row as any); 131 | } 132 | this.modified.clear(); 133 | this.deleted.clear(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /convex/engine/historicalTable.ts: -------------------------------------------------------------------------------- 1 | import { WithoutSystemFields } from 'convex/server'; 2 | import { Doc, Id, TableNames } from '../_generated/dataModel'; 3 | import { DatabaseWriter } from '../_generated/server'; 4 | 5 | type FieldName = string; 6 | 7 | export type History = { 8 | initialValue: number; 9 | samples: Sample[]; 10 | }; 11 | 12 | export type Sample = { 13 | time: number; 14 | value: number; 15 | }; 16 | 17 | export abstract class HistoricalTable { 18 | abstract table: T; 19 | abstract db: DatabaseWriter; 20 | startTs?: number; 21 | 22 | fields: FieldName[]; 23 | 24 | data: Map, Doc> = new Map(); 25 | modified: Set> = new Set(); 26 | deleted: Set> = new Set(); 27 | 28 | history: Map, Record> = new Map(); 29 | 30 | constructor(fields: FieldName[], rows: Doc[]) { 31 | this.fields = fields; 32 | for (const row of rows) { 33 | if ('history' in row) { 34 | delete row.history; 35 | this.modified.add(row._id); 36 | } 37 | this.checkShape(row); 38 | this.data.set(row._id, row); 39 | } 40 | } 41 | 42 | historyLength() { 43 | return [...this.history.values()] 44 | .flatMap((sampleRecord) => Object.values(sampleRecord)) 45 | .map((h) => h.samples.length) 46 | .reduce((a, b) => a + b, 0); 47 | } 48 | 49 | checkShape(obj: any) { 50 | if ('history' in obj) { 51 | throw new Error(`Cannot insert row with 'history' field`); 52 | } 53 | for (const [key, value] of Object.entries(obj)) { 54 | if (this.isReservedFieldName(key)) { 55 | continue; 56 | } 57 | if (typeof value !== 'number') { 58 | throw new Error( 59 | `HistoricalTable only supports numeric values, found: ${JSON.stringify(value)}`, 60 | ); 61 | } 62 | } 63 | } 64 | 65 | isReservedFieldName(key: string) { 66 | return key.startsWith('_') || key === 'history'; 67 | } 68 | 69 | async insert(now: number, row: WithoutSystemFields>): Promise> { 70 | this.checkShape(row); 71 | 72 | const id = await this.db.insert(this.table, row); 73 | const withSystemFields = await this.db.get(id); 74 | if (!withSystemFields) { 75 | throw new Error(`Failed to db.get() inserted row`); 76 | } 77 | this.data.set(id, withSystemFields); 78 | return id; 79 | } 80 | 81 | lookup(now: number, id: Id): Doc { 82 | const row = this.data.get(id); 83 | if (!row) { 84 | throw new Error(`Invalid ID: ${id}`); 85 | } 86 | const handlers = { 87 | defineProperty: (target: any, key: any, descriptor: any) => { 88 | throw new Error(`Adding new fields unsupported on HistoricalTable`); 89 | }, 90 | get: (target: any, prop: any, receiver: any) => { 91 | const value = Reflect.get(target, prop, receiver); 92 | if (typeof value === 'object') { 93 | throw new Error(`Nested objects unsupported on HistoricalTable`); 94 | } else { 95 | return value; 96 | } 97 | }, 98 | set: (obj: any, prop: any, value: any) => { 99 | if (this.isReservedFieldName(prop)) { 100 | throw new Error(`Cannot set reserved field '${prop}'`); 101 | } 102 | this.markModified(id, now, prop, value); 103 | return Reflect.set(obj, prop, value); 104 | }, 105 | deleteProperty: (target: any, prop: any) => { 106 | throw new Error(`Deleting fields unsupported on HistoricalTable`); 107 | }, 108 | }; 109 | 110 | return new Proxy>(row, handlers); 111 | } 112 | 113 | private markModified(id: Id, now: number, fieldName: FieldName, value: any) { 114 | if (typeof value !== 'number') { 115 | throw new Error(`Cannot set field '${fieldName}' to ${JSON.stringify(value)}`); 116 | } 117 | if (this.fields.indexOf(fieldName) === -1) { 118 | throw new Error(`Mutating undeclared field name: ${fieldName}`); 119 | } 120 | const doc = this.data.get(id); 121 | if (!doc) { 122 | throw new Error(`Invalid ID: ${id}`); 123 | } 124 | const currentValue = doc[fieldName]; 125 | if (currentValue === undefined || typeof currentValue !== 'number') { 126 | throw new Error(`Invalid value ${currentValue} for ${fieldName} in ${id}`); 127 | } 128 | if (currentValue !== value) { 129 | let historyRecord = this.history.get(id); 130 | if (!historyRecord) { 131 | historyRecord = {}; 132 | this.history.set(id, historyRecord); 133 | } 134 | let history = historyRecord[fieldName]; 135 | if (!history) { 136 | history = { initialValue: currentValue, samples: [] }; 137 | historyRecord[fieldName] = history; 138 | } 139 | const { samples } = history; 140 | let inserted = false; 141 | if (samples.length > 0) { 142 | const last = samples[samples.length - 1]; 143 | if (now < last.time) { 144 | throw new Error(`Server time moving backwards: ${now} < ${last.time}`); 145 | } 146 | if (now === last.time) { 147 | last.value = value; 148 | inserted = true; 149 | } 150 | } 151 | if (!inserted) { 152 | samples.push({ time: now, value }); 153 | } 154 | } 155 | this.modified.add(id); 156 | } 157 | 158 | async save() { 159 | for (const id of this.deleted) { 160 | await this.db.delete(id); 161 | } 162 | let totalSize = 0; 163 | let buffersPacked = 0; 164 | for (const id of this.modified) { 165 | const row = this.data.get(id); 166 | if (!row) { 167 | throw new Error(`Invalid modified id: ${id}`); 168 | } 169 | if ('history' in row) { 170 | throw new Error(`Cannot save row with 'history' field`); 171 | } 172 | const sampleRecord = this.history.get(id); 173 | if (sampleRecord && Object.entries(sampleRecord).length > 0) { 174 | const packed = packSampleRecord(sampleRecord); 175 | (row as any).history = packed; 176 | totalSize += packed.byteLength; 177 | buffersPacked += 1; 178 | } 179 | // Somehow TypeScript isn't able to figure out that our 180 | // generic `Doc` unifies with `replace()`'s type. 181 | await this.db.replace(id, row as any); 182 | } 183 | if (buffersPacked > 0) { 184 | console.log( 185 | `Packed ${buffersPacked} buffers for ${this.table}, total size: ${( 186 | totalSize / 1024 187 | ).toFixed(2)}KiB`, 188 | ); 189 | } 190 | this.modified.clear(); 191 | this.deleted.clear(); 192 | } 193 | } 194 | 195 | export function packSampleRecord(sampleMap: Record): ArrayBuffer { 196 | // TODO: This is very inefficient in space. 197 | // [ ] Switch to fixed point and quantize the floats. 198 | // [ ] Delta encode differences 199 | // [ ] Use an integer compressor: https://github.com/lemire/FastIntegerCompression.js/blob/master/FastIntegerCompression.js 200 | const s = JSON.stringify(sampleMap); 201 | const encoder = new TextEncoder(); 202 | const bytes = encoder.encode(s); 203 | return bytes.buffer; 204 | } 205 | 206 | export function unpackSampleRecord(buffer: ArrayBuffer): Record { 207 | const decoder = new TextDecoder(); 208 | const s = decoder.decode(buffer); 209 | return JSON.parse(s); 210 | } 211 | -------------------------------------------------------------------------------- /convex/engine/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineTable } from 'convex/server'; 2 | import { v } from 'convex/values'; 3 | 4 | const inputs = v.object({ 5 | // Inputs are scoped to a single engine. 6 | engineId: v.id('engines'), 7 | 8 | // Monotonically increasing input number within a world starting at 0. 9 | number: v.number(), 10 | 11 | // Name of the input handler to run. 12 | name: v.string(), 13 | // Dynamically typed arguments and return value for the input handler. We'll 14 | // provide type safety at a higher layer. 15 | args: v.any(), 16 | returnValue: v.optional( 17 | v.union( 18 | v.object({ 19 | kind: v.literal('ok'), 20 | value: v.any(), 21 | }), 22 | v.object({ 23 | kind: v.literal('error'), 24 | message: v.string(), 25 | }), 26 | ), 27 | ), 28 | 29 | // Timestamp when the server received the input. This timestamp is best-effort, 30 | // since we don't guarantee strict monotonicity here. So, an input may not get 31 | // assigned to the engine step whose time interval contains this timestamp. 32 | received: v.number(), 33 | }); 34 | 35 | const engines = v.object({ 36 | // What is the current simulation time for the engine? Monotonically increasing. 37 | currentTime: v.optional(v.number()), 38 | // What was `currentTime` for the preceding step of the engine? 39 | lastStepTs: v.optional(v.number()), 40 | 41 | // How far has the engine processed in the input queue? 42 | processedInputNumber: v.optional(v.number()), 43 | 44 | state: v.union( 45 | v.object({ 46 | kind: v.literal('running'), 47 | nextRun: v.number(), 48 | }), 49 | v.object({ 50 | kind: v.literal('stopped'), 51 | }), 52 | ), 53 | 54 | // Monotonically increasing counter that allows inputs to restart the engine 55 | // when it's sleeping. In particular, every scheduled run of the engine 56 | // is predicated on a generation number, and bumping that number will 57 | // atomically cancel that future execution. This provides mutual exclusion 58 | // for our core event loop. 59 | generationNumber: v.number(), 60 | }); 61 | 62 | export const engineTables = { 63 | inputs: defineTable(inputs).index('byInputNumber', ['engineId', 'number']), 64 | engines: defineTable(engines), 65 | }; 66 | -------------------------------------------------------------------------------- /convex/game/conversationMembers.ts: -------------------------------------------------------------------------------- 1 | import { defineTable } from 'convex/server'; 2 | import { v } from 'convex/values'; 3 | import { GameTable } from '../engine/gameTable'; 4 | import { DatabaseWriter } from '../_generated/server'; 5 | import { Doc, Id } from '../_generated/dataModel'; 6 | import { Conversations } from './conversations'; 7 | 8 | export const conversationMembers = defineTable({ 9 | conversationId: v.id('conversations'), 10 | playerId: v.id('players'), 11 | status: v.union( 12 | v.object({ kind: v.literal('invited') }), 13 | v.object({ kind: v.literal('walkingOver') }), 14 | v.object({ kind: v.literal('participating'), started: v.number() }), 15 | v.object({ kind: v.literal('left'), started: v.optional(v.number()), ended: v.number() }), 16 | ), 17 | }) 18 | .index('conversationId', ['conversationId', 'playerId']) 19 | .index('playerId', ['playerId', 'status.kind']); 20 | 21 | export class ConversationMembers extends GameTable<'conversationMembers'> { 22 | table = 'conversationMembers' as const; 23 | 24 | static async load( 25 | db: DatabaseWriter, 26 | engineId: Id<'engines'>, 27 | conversations: Conversations, 28 | ): Promise { 29 | const rows = []; 30 | for (const conversation of conversations.allDocuments()) { 31 | const conversationRows = await db 32 | .query('conversationMembers') 33 | .withIndex('conversationId', (q) => q.eq('conversationId', conversation._id)) 34 | .filter((q) => q.neq(q.field('status.kind'), 'left')) 35 | .collect(); 36 | rows.push(...conversationRows); 37 | } 38 | return new ConversationMembers(db, engineId, rows); 39 | } 40 | 41 | constructor( 42 | public db: DatabaseWriter, 43 | public engineId: Id<'engines'>, 44 | rows: Doc<'conversationMembers'>[], 45 | ) { 46 | super(rows); 47 | } 48 | 49 | isActive(doc: Doc<'conversationMembers'>): boolean { 50 | return doc.status.kind !== 'left'; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /convex/game/conversations.ts: -------------------------------------------------------------------------------- 1 | import { defineTable } from 'convex/server'; 2 | import { v } from 'convex/values'; 3 | import { GameTable } from '../engine/gameTable'; 4 | import { DatabaseWriter } from '../_generated/server'; 5 | import { Doc, Id } from '../_generated/dataModel'; 6 | 7 | export const conversations = defineTable({ 8 | worldId: v.id('worlds'), 9 | creator: v.id('players'), 10 | finished: v.optional(v.number()), 11 | }).index('worldId', ['worldId', 'finished']); 12 | 13 | export class Conversations extends GameTable<'conversations'> { 14 | table = 'conversations' as const; 15 | 16 | static async load(db: DatabaseWriter, worldId: Id<'worlds'>): Promise { 17 | const rows = await db 18 | .query('conversations') 19 | .withIndex('worldId', (q) => q.eq('worldId', worldId).eq('finished', undefined)) 20 | .collect(); 21 | return new Conversations(db, worldId, rows); 22 | } 23 | 24 | constructor( 25 | public db: DatabaseWriter, 26 | public worldId: Id<'worlds'>, 27 | rows: Doc<'conversations'>[], 28 | ) { 29 | super(rows); 30 | } 31 | 32 | isActive(doc: Doc<'conversations'>): boolean { 33 | return doc.finished === undefined; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /convex/game/inputs.ts: -------------------------------------------------------------------------------- 1 | import { Infer, v } from 'convex/values'; 2 | import { point } from '../util/types'; 3 | 4 | export const inputs = { 5 | // Join, creating a new player... 6 | join: { 7 | args: v.object({ 8 | name: v.string(), 9 | character: v.string(), 10 | description: v.string(), 11 | tokenIdentifier: v.optional(v.string()), 12 | }), 13 | returnValue: v.id('players'), 14 | }, 15 | // ...or leave, disabling the specified player. 16 | leave: { 17 | args: v.object({ 18 | playerId: v.id('players'), 19 | }), 20 | returnValue: v.null(), 21 | }, 22 | 23 | // Move the player to a specified location. 24 | moveTo: { 25 | args: v.object({ 26 | playerId: v.id('players'), 27 | destination: v.union(point, v.null()), 28 | }), 29 | returnValue: v.null(), 30 | }, 31 | // Start a conversation, inviting the specified player. 32 | // Conversations can only have two participants for now, 33 | // so we don't have a separate "invite" input. 34 | startConversation: { 35 | args: v.object({ 36 | playerId: v.id('players'), 37 | invitee: v.id('players'), 38 | }), 39 | returnValue: v.id('conversations'), 40 | }, 41 | // Accept an invite to a conversation, which puts the 42 | // player in the "walkingOver" state until they're close 43 | // enough to the other participant. 44 | acceptInvite: { 45 | args: v.object({ 46 | playerId: v.id('players'), 47 | conversationId: v.id('conversations'), 48 | }), 49 | returnValue: v.null(), 50 | }, 51 | // Reject the invite. Eventually we might add a message 52 | // that explains why! 53 | rejectInvite: { 54 | args: v.object({ 55 | playerId: v.id('players'), 56 | conversationId: v.id('conversations'), 57 | }), 58 | returnValue: v.null(), 59 | }, 60 | // Leave a conversation. 61 | leaveConversation: { 62 | args: v.object({ 63 | playerId: v.id('players'), 64 | conversationId: v.id('conversations'), 65 | }), 66 | returnValue: v.null(), 67 | }, 68 | }; 69 | export type Inputs = typeof inputs; 70 | export type InputNames = keyof Inputs; 71 | export type InputArgs = Infer; 72 | export type InputReturnValue = Infer; 73 | -------------------------------------------------------------------------------- /convex/game/locations.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { GameTable } from '../engine/gameTable'; 3 | import { defineTable } from 'convex/server'; 4 | import { DatabaseWriter } from '../_generated/server'; 5 | import { Players } from './players'; 6 | import { Doc, Id } from '../_generated/dataModel'; 7 | import { HistoricalTable } from '../engine/historicalTable'; 8 | 9 | export const locations = defineTable({ 10 | // Position. 11 | x: v.number(), 12 | y: v.number(), 13 | 14 | // Normalized orientation vector. 15 | dx: v.number(), 16 | dy: v.number(), 17 | 18 | // Velocity (in tiles/sec). 19 | velocity: v.number(), 20 | 21 | // History buffer field out by `HistoricalTable`. 22 | history: v.optional(v.bytes()), 23 | }); 24 | 25 | export const locationFields = ['x', 'y', 'dx', 'dy', 'velocity']; 26 | export class Locations extends HistoricalTable<'locations'> { 27 | table = 'locations' as const; 28 | 29 | static async load( 30 | db: DatabaseWriter, 31 | engineId: Id<'engines'>, 32 | players: Players, 33 | ): Promise { 34 | const rows = []; 35 | for (const playerId of players.allIds()) { 36 | const player = players.lookup(playerId); 37 | const row = await db.get(player.locationId); 38 | if (!row) { 39 | throw new Error(`Invalid location ID: ${player.locationId}`); 40 | } 41 | rows.push(row); 42 | } 43 | return new Locations(db, engineId, rows); 44 | } 45 | 46 | constructor( 47 | public db: DatabaseWriter, 48 | public engineId: Id<'engines'>, 49 | rows: Doc<'locations'>[], 50 | ) { 51 | super(locationFields, rows); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /convex/game/main.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { 3 | DatabaseReader, 4 | MutationCtx, 5 | internalMutation, 6 | mutation, 7 | query, 8 | } from '../_generated/server'; 9 | import { AiTown } from './aiTown'; 10 | import { api, internal } from '../_generated/api'; 11 | import { insertInput as gameInsertInput } from '../engine/game'; 12 | import { InputArgs, InputNames } from './inputs'; 13 | import { Id } from '../_generated/dataModel'; 14 | 15 | async function getWorldId(db: DatabaseReader, engineId: Id<'engines'>) { 16 | const world = await db 17 | .query('worlds') 18 | .withIndex('engineId', (q) => q.eq('engineId', engineId)) 19 | .first(); 20 | if (!world) { 21 | throw new Error(`World for engine ${engineId} not found`); 22 | } 23 | return world._id; 24 | } 25 | 26 | export const runStep = internalMutation({ 27 | args: { 28 | engineId: v.id('engines'), 29 | generationNumber: v.number(), 30 | }, 31 | handler: async (ctx, args): Promise => { 32 | const worldId = await getWorldId(ctx.db, args.engineId); 33 | const game = await AiTown.load(ctx.db, worldId); 34 | await game.runStep(ctx, internal.game.main.runStep, args.generationNumber); 35 | }, 36 | }); 37 | 38 | export async function insertInput( 39 | ctx: MutationCtx, 40 | worldId: Id<'worlds'>, 41 | name: Name, 42 | args: InputArgs, 43 | ): Promise> { 44 | const world = await ctx.db.get(worldId); 45 | if (!world) { 46 | throw new Error(`Invalid world ID: ${worldId}`); 47 | } 48 | return await gameInsertInput(ctx, internal.game.main.runStep, world.engineId, name, args); 49 | } 50 | 51 | export const sendInput = mutation({ 52 | args: { 53 | worldId: v.id('worlds'), 54 | name: v.string(), 55 | args: v.any(), 56 | }, 57 | handler: async (ctx, args) => { 58 | return await insertInput(ctx, args.worldId, args.name as InputNames, args.args); 59 | }, 60 | }); 61 | 62 | export const inputStatus = query({ 63 | args: { 64 | inputId: v.id('inputs'), 65 | }, 66 | handler: async (ctx, args) => { 67 | const input = await ctx.db.get(args.inputId); 68 | if (!input) { 69 | throw new Error(`Invalid input ID: ${args.inputId}`); 70 | } 71 | return input.returnValue ?? null; 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /convex/game/movement.ts: -------------------------------------------------------------------------------- 1 | import { Doc, Id } from '../_generated/dataModel'; 2 | import { movementSpeed } from '../../data/characters'; 3 | import { COLLISION_THRESHOLD } from '../constants'; 4 | import { Point, Vector } from '../util/types'; 5 | import { distance, manhattanDistance, pointsEqual } from '../util/geometry'; 6 | import { MinHeap } from '../util/minheap'; 7 | import { AiTown } from './aiTown'; 8 | 9 | type PathCandidate = { 10 | position: Point; 11 | facing?: Vector; 12 | t: number; 13 | length: number; 14 | cost: number; 15 | prev?: PathCandidate; 16 | }; 17 | 18 | export function findRoute(game: AiTown, now: number, player: Doc<'players'>, destination: Point) { 19 | const minDistances: PathCandidate[][] = []; 20 | const explore = (current: PathCandidate): Array => { 21 | const { x, y } = current.position; 22 | const neighbors = []; 23 | 24 | // If we're not on a grid point, first try to move horizontally 25 | // or vertically to a grid point. Note that this can create very small 26 | // deltas between the current position and the nearest grid point so 27 | // be careful to preserve the `facing` vectors rather than trying to 28 | // derive them anew. 29 | if (x !== Math.floor(x)) { 30 | neighbors.push( 31 | { position: { x: Math.floor(x), y }, facing: { dx: -1, dy: 0 } }, 32 | { position: { x: Math.floor(x) + 1, y }, facing: { dx: 1, dy: 0 } }, 33 | ); 34 | } 35 | if (y !== Math.floor(y)) { 36 | neighbors.push( 37 | { position: { x, y: Math.floor(y) }, facing: { dx: 0, dy: -1 } }, 38 | { position: { x, y: Math.floor(y) + 1 }, facing: { dx: 0, dy: 1 } }, 39 | ); 40 | } 41 | // Otherwise, just move to adjacent grid points. 42 | if (x == Math.floor(x) && y == Math.floor(y)) { 43 | neighbors.push( 44 | { position: { x: x + 1, y }, facing: { dx: 1, dy: 0 } }, 45 | { position: { x: x - 1, y }, facing: { dx: -1, dy: 0 } }, 46 | { position: { x, y: y + 1 }, facing: { dx: 0, dy: 1 } }, 47 | { position: { x, y: y - 1 }, facing: { dx: 0, dy: -1 } }, 48 | ); 49 | } 50 | const next = []; 51 | for (const { position, facing } of neighbors) { 52 | const segmentLength = distance(current.position, position); 53 | const length = current.length + segmentLength; 54 | if (blocked(game, now, position, player._id)) { 55 | continue; 56 | } 57 | const remaining = manhattanDistance(position, destination); 58 | const path = { 59 | position, 60 | facing, 61 | // Movement speed is in tiles per second. 62 | t: current.t + (segmentLength / movementSpeed) * 1000, 63 | length, 64 | cost: length + remaining, 65 | prev: current, 66 | }; 67 | const existingMin = minDistances[position.y]?.[position.x]; 68 | if (existingMin && existingMin.cost <= path.cost) { 69 | continue; 70 | } 71 | minDistances[position.y] ??= []; 72 | minDistances[position.y][position.x] = path; 73 | next.push(path); 74 | } 75 | return next; 76 | }; 77 | 78 | const startingLocation = game.locations.lookup(now, player.locationId); 79 | const startingPosition = { x: startingLocation.x, y: startingLocation.y }; 80 | let current: PathCandidate | undefined = { 81 | position: startingPosition, 82 | facing: { dx: startingLocation.dx, dy: startingLocation.dy }, 83 | t: now, 84 | length: 0, 85 | cost: manhattanDistance(startingPosition, destination), 86 | prev: undefined, 87 | }; 88 | let bestCandidate = current; 89 | const minheap = MinHeap((p0, p1) => p0.cost > p1.cost); 90 | while (current) { 91 | if (pointsEqual(current.position, destination)) { 92 | break; 93 | } 94 | if ( 95 | manhattanDistance(current.position, destination) < 96 | manhattanDistance(bestCandidate.position, destination) 97 | ) { 98 | bestCandidate = current; 99 | } 100 | for (const candidate of explore(current)) { 101 | minheap.push(candidate); 102 | } 103 | current = minheap.pop(); 104 | } 105 | let newDestination = null; 106 | if (!current) { 107 | if (bestCandidate.length === 0) { 108 | return null; 109 | } 110 | current = bestCandidate; 111 | newDestination = current.position; 112 | } 113 | const densePath = []; 114 | let facing = current.facing!; 115 | while (current) { 116 | densePath.push({ position: current.position, t: current.t, facing }); 117 | facing = current.facing!; 118 | current = current.prev; 119 | } 120 | densePath.reverse(); 121 | 122 | return { path: densePath, newDestination }; 123 | } 124 | 125 | export function blocked(game: AiTown, now: number, pos: Point, playerId?: Id<'players'>) { 126 | const otherPositions = game.players 127 | .allDocuments() 128 | .filter((p) => p._id !== playerId) 129 | .map((p) => game.locations.lookup(now, p.locationId)); 130 | return blockedWithPositions(pos, otherPositions, game.map); 131 | } 132 | 133 | export function blockedWithPositions(position: Point, otherPositions: Point[], map: Doc<'maps'>) { 134 | if (isNaN(position.x) || isNaN(position.y)) { 135 | throw new Error(`NaN position in ${JSON.stringify(position)}`); 136 | } 137 | if (position.x < 0 || position.y < 0 || position.x >= map.width || position.y >= map.height) { 138 | return 'out of bounds'; 139 | } 140 | if (map.objectTiles[Math.floor(position.y)][Math.floor(position.x)] !== -1) { 141 | return 'world blocked'; 142 | } 143 | for (const otherPosition of otherPositions) { 144 | if (distance(otherPosition, position) < COLLISION_THRESHOLD) { 145 | return 'player'; 146 | } 147 | } 148 | return null; 149 | } 150 | -------------------------------------------------------------------------------- /convex/game/players.ts: -------------------------------------------------------------------------------- 1 | import { defineTable } from 'convex/server'; 2 | import { Infer, v } from 'convex/values'; 3 | import { path, point } from '../util/types'; 4 | import { GameTable } from '../engine/gameTable'; 5 | import { DatabaseWriter } from '../_generated/server'; 6 | import { Doc, Id } from '../_generated/dataModel'; 7 | 8 | const pathfinding = v.object({ 9 | destination: point, 10 | started: v.number(), 11 | state: v.union( 12 | v.object({ 13 | kind: v.literal('needsPath'), 14 | }), 15 | v.object({ 16 | kind: v.literal('waiting'), 17 | until: v.number(), 18 | }), 19 | v.object({ 20 | kind: v.literal('moving'), 21 | path, 22 | }), 23 | ), 24 | }); 25 | export type Pathfinding = Infer; 26 | 27 | // The players table has game-specific public state, like 28 | // the player's name and position, some internal state, 29 | // like its current pathfinding state, and some engine 30 | // specific state, like a position buffer of the player's 31 | // positions over the last step. Eventually we can pull this 32 | // out into something engine managed. 33 | export const players = defineTable({ 34 | worldId: v.id('worlds'), 35 | // Is the player active? 36 | active: v.boolean(), 37 | 38 | name: v.string(), 39 | description: v.string(), 40 | character: v.string(), 41 | 42 | // If present, it's the auth tokenIdentifier of the owning player. 43 | human: v.optional(v.string()), 44 | 45 | pathfinding: v.optional(pathfinding), 46 | 47 | // Pointer to the locations table for the player's current position. 48 | locationId: v.id('locations'), 49 | }).index('active', ['worldId', 'active', 'human']); 50 | 51 | export class Players extends GameTable<'players'> { 52 | table = 'players' as const; 53 | 54 | static async load(db: DatabaseWriter, worldId: Id<'worlds'>): Promise { 55 | const rows = await db 56 | .query('players') 57 | .withIndex('active', (q) => q.eq('worldId', worldId).eq('active', true)) 58 | .collect(); 59 | return new Players(db, worldId, rows); 60 | } 61 | 62 | constructor( 63 | public db: DatabaseWriter, 64 | public worldId: Id<'worlds'>, 65 | rows: Doc<'players'>[], 66 | ) { 67 | super(rows); 68 | } 69 | 70 | isActive(doc: Doc<'players'>): boolean { 71 | return doc.active; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /convex/game/schema.ts: -------------------------------------------------------------------------------- 1 | import { engineTables } from '../engine/schema'; 2 | import { players } from './players'; 3 | import { locations } from './locations'; 4 | import { conversations } from './conversations'; 5 | import { conversationMembers } from './conversationMembers'; 6 | 7 | export const gameTables = { 8 | players: players, 9 | locations: locations, 10 | conversations: conversations, 11 | conversationMembers: conversationMembers, 12 | ...engineTables, 13 | }; 14 | -------------------------------------------------------------------------------- /convex/http.ts: -------------------------------------------------------------------------------- 1 | import { httpRouter } from 'convex/server'; 2 | import { handleReplicateWebhook } from './music'; 3 | 4 | const http = httpRouter(); 5 | http.route({ 6 | path: '/replicate_webhook', 7 | method: 'POST', 8 | handler: handleReplicateWebhook, 9 | }); 10 | export default http; 11 | -------------------------------------------------------------------------------- /convex/messages.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { internalMutation, mutation, query } from './_generated/server'; 3 | import { TYPING_TIMEOUT } from './constants'; 4 | import { internal } from './_generated/api'; 5 | 6 | export const listMessages = query({ 7 | args: { 8 | conversationId: v.id('conversations'), 9 | }, 10 | handler: async (ctx, args) => { 11 | const messages = await ctx.db 12 | .query('messages') 13 | .withIndex('conversationId', (q) => q.eq('conversationId', args.conversationId)) 14 | .collect(); 15 | const out = []; 16 | for (const message of messages) { 17 | const author = await ctx.db.get(message.author); 18 | if (!author) { 19 | throw new Error(`Invalid author ID: ${message.author}`); 20 | } 21 | out.push({ ...message, authorName: author.name }); 22 | } 23 | return out; 24 | }, 25 | }); 26 | 27 | export const currentlyTyping = query({ 28 | args: { 29 | conversationId: v.id('conversations'), 30 | }, 31 | handler: async (ctx, args) => { 32 | const indicator = await ctx.db 33 | .query('typingIndicator') 34 | .withIndex('conversationId', (q) => q.eq('conversationId', args.conversationId)) 35 | .unique(); 36 | const typing = indicator?.typing; 37 | if (!typing) { 38 | return null; 39 | } 40 | const player = await ctx.db.get(typing.playerId); 41 | if (!player) { 42 | throw new Error(`Invalid player ID: ${typing.playerId}`); 43 | } 44 | return { playerName: player.name, ...typing }; 45 | }, 46 | }); 47 | 48 | export const startTyping = mutation({ 49 | args: { 50 | conversationId: v.id('conversations'), 51 | playerId: v.id('players'), 52 | }, 53 | handler: async (ctx, args) => { 54 | const member = await ctx.db 55 | .query('conversationMembers') 56 | .withIndex('conversationId', (q) => 57 | q.eq('conversationId', args.conversationId).eq('playerId', args.playerId), 58 | ) 59 | .unique(); 60 | if (!member || member.status.kind !== 'participating') { 61 | throw new Error( 62 | `Player ${args.playerId} is not participating in conversation ${args.conversationId}`, 63 | ); 64 | } 65 | const indicator = await ctx.db 66 | .query('typingIndicator') 67 | .withIndex('conversationId', (q) => q.eq('conversationId', args.conversationId)) 68 | .unique(); 69 | if (!indicator) { 70 | await ctx.db.insert('typingIndicator', { 71 | conversationId: args.conversationId, 72 | typing: { playerId: args.playerId, since: Date.now() }, 73 | versionNumber: 0, 74 | }); 75 | return; 76 | } 77 | if (indicator.typing) { 78 | if (indicator.typing.playerId === args.playerId) { 79 | return; 80 | } 81 | throw new Error(`${indicator.typing.playerId} is already typing`); 82 | } 83 | const versionNumber = indicator.versionNumber + 1; 84 | await ctx.db.patch(indicator._id, { 85 | typing: { playerId: args.playerId, since: Date.now() }, 86 | versionNumber, 87 | }); 88 | await ctx.scheduler.runAfter(TYPING_TIMEOUT, internal.messages.clearTyping, { 89 | conversationId: args.conversationId, 90 | versionNumber, 91 | }); 92 | }, 93 | }); 94 | 95 | export const writeMessage = mutation({ 96 | args: { 97 | conversationId: v.id('conversations'), 98 | playerId: v.id('players'), 99 | text: v.string(), 100 | }, 101 | handler: async (ctx, args) => { 102 | const conversation = await ctx.db.get(args.conversationId); 103 | if (!conversation) { 104 | throw new Error(`Invalid conversation ID: ${args.conversationId}`); 105 | } 106 | const member = await ctx.db 107 | .query('conversationMembers') 108 | .withIndex('conversationId', (q) => 109 | q.eq('conversationId', args.conversationId).eq('playerId', args.playerId), 110 | ) 111 | .unique(); 112 | if (!member || member.status.kind !== 'participating') { 113 | throw new Error( 114 | `Player ${args.playerId} is not participating in conversation ${args.conversationId}`, 115 | ); 116 | } 117 | const indicator = await ctx.db 118 | .query('typingIndicator') 119 | .withIndex('conversationId', (q) => q.eq('conversationId', args.conversationId)) 120 | .unique(); 121 | if (indicator?.typing?.playerId === args.playerId) { 122 | await ctx.db.patch(indicator._id, { 123 | typing: undefined, 124 | versionNumber: indicator.versionNumber + 1, 125 | }); 126 | } 127 | await ctx.db.insert('messages', { 128 | conversationId: args.conversationId, 129 | author: args.playerId, 130 | text: args.text, 131 | }); 132 | }, 133 | }); 134 | 135 | export const clearTyping = internalMutation({ 136 | args: { 137 | conversationId: v.id('conversations'), 138 | versionNumber: v.number(), 139 | }, 140 | handler: async (ctx, args) => { 141 | const indicator = await ctx.db 142 | .query('typingIndicator') 143 | .withIndex('conversationId', (q) => q.eq('conversationId', args.conversationId)) 144 | .unique(); 145 | if (!indicator) { 146 | return; 147 | } 148 | if (indicator.versionNumber !== args.versionNumber) { 149 | return; 150 | } 151 | if (!indicator.typing) { 152 | throw new Error(`No typing indicator to clear despite version number matching`); 153 | } 154 | await ctx.db.patch(indicator._id, { typing: undefined, versionNumber: args.versionNumber + 1 }); 155 | }, 156 | }); 157 | -------------------------------------------------------------------------------- /convex/music.ts: -------------------------------------------------------------------------------- 1 | import { v } from 'convex/values'; 2 | import { query, internalMutation } from './_generated/server'; 3 | import Replicate, { WebhookEventType } from 'replicate'; 4 | import { httpAction, internalAction } from './_generated/server'; 5 | import { internal, api } from './_generated/api'; 6 | 7 | function client(): Replicate { 8 | const replicate = new Replicate({ 9 | auth: process.env.REPLICATE_API_TOKEN || '', 10 | }); 11 | return replicate; 12 | } 13 | 14 | function replicateAvailable(): boolean { 15 | return !!process.env.REPLICATE_API_TOKEN; 16 | } 17 | 18 | export const insertMusic = internalMutation({ 19 | args: { storageId: v.string(), type: v.union(v.literal('background'), v.literal('player')) }, 20 | handler: async (ctx, args) => { 21 | await ctx.db.insert('music', { 22 | storageId: args.storageId, 23 | type: args.type, 24 | }); 25 | }, 26 | }); 27 | 28 | export const getBackgroundMusic = query({ 29 | handler: async (ctx) => { 30 | const music = await ctx.db 31 | .query('music') 32 | .filter((entry) => entry.eq(entry.field('type'), 'background')) 33 | .order('desc') 34 | .first(); 35 | if (!music) { 36 | return '/ai-town/assets/background.mp3'; 37 | } 38 | const url = await ctx.storage.getUrl(music.storageId); 39 | if (!url) { 40 | throw new Error(`Invalid storage ID: ${music.storageId}`); 41 | } 42 | return url; 43 | }, 44 | }); 45 | 46 | export const enqueueBackgroundMusicGeneration = internalAction({ 47 | handler: async (ctx): Promise => { 48 | if (!replicateAvailable()) { 49 | return; 50 | } 51 | const world = await ctx.runQuery(api.world.defaultWorld); 52 | if (!world) { 53 | console.log('No active default world, returning.'); 54 | return; 55 | } 56 | // TODO: MusicGen-Large on Replicate only allows 30 seconds. Use MusicGen-Small for longer? 57 | await generateMusic('16-bit RPG adventure game with wholesome vibe', 30); 58 | }, 59 | }); 60 | 61 | export const handleReplicateWebhook = httpAction(async (ctx, request) => { 62 | const req = await request.json(); 63 | if (req.id) { 64 | const prediction = await client().predictions.get(req.id); 65 | const response = await fetch(prediction.output); 66 | const music = await response.blob(); 67 | const storageId = await ctx.storage.store(music); 68 | await ctx.runMutation(internal.music.insertMusic, { type: 'background', storageId }); 69 | } 70 | return new Response(); 71 | }); 72 | 73 | enum MusicGenNormStrategy { 74 | Clip = 'clip', 75 | Loudness = 'loudness', 76 | Peak = 'peak', 77 | Rms = 'rms', 78 | } 79 | 80 | enum MusicGenFormat { 81 | wav = 'wav', 82 | mp3 = 'mp3', 83 | } 84 | 85 | /** 86 | * 87 | * @param prompt A description of the music you want to generate. 88 | * @param duration Duration of the generated audio in seconds. 89 | * @param webhook webhook URL for Replicate to call when @param webhook_events_filter is triggered 90 | * @param webhook_events_filter Array of event names to filter the webhook. See https://replicate.com/docs/reference/http#predictions.create--webhook_events_filter 91 | * @param normalization_strategy Strategy for normalizing audio. 92 | * @param top_k Reduces sampling to the k most likely tokens. 93 | * @param top_p Reduces sampling to tokens with cumulative probability of p. When set to `0` (default), top_k sampling is used. 94 | * @param temperature Controls the 'conservativeness' of the sampling process. Higher temperature means more diversity. 95 | * @param classifer_free_gudance Increases the influence of inputs on the output. Higher values produce lower-varience outputs that adhere more closely to inputs. 96 | * @param output_format Output format for generated audio. See @ 97 | * @param seed Seed for random number generator. If None or -1, a random seed will be used. 98 | * @returns object containing metadata of the prediction with ID to fetch once result is completed 99 | */ 100 | export async function generateMusic( 101 | prompt: string, 102 | duration: number, 103 | webhook: string = process.env.CONVEX_SITE_URL + '/replicate_webhook' || '', 104 | webhook_events_filter: [WebhookEventType] = ['completed'], 105 | normalization_strategy: MusicGenNormStrategy = MusicGenNormStrategy.Peak, 106 | output_format: MusicGenFormat = MusicGenFormat.mp3, 107 | top_k = 250, 108 | top_p = 0, 109 | temperature = 1, 110 | classifer_free_gudance = 3, 111 | seed = -1, 112 | model_version = 'large', 113 | ) { 114 | if (!replicateAvailable()) { 115 | throw new Error('Replicate API token not set'); 116 | } 117 | return await client().predictions.create({ 118 | // https://replicate.com/facebookresearch/musicgen/versions/7a76a8258b23fae65c5a22debb8841d1d7e816b75c2f24218cd2bd8573787906 119 | version: '7a76a8258b23fae65c5a22debb8841d1d7e816b75c2f24218cd2bd8573787906', 120 | input: { 121 | model_version, 122 | prompt, 123 | duration, 124 | normalization_strategy, 125 | top_k, 126 | top_p, 127 | temperature, 128 | classifer_free_gudance, 129 | output_format, 130 | seed, 131 | }, 132 | webhook, 133 | webhook_events_filter, 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from 'convex/server'; 2 | import { gameTables } from './game/schema'; 3 | import { v } from 'convex/values'; 4 | import { agentTables } from './agent/schema'; 5 | 6 | export default defineSchema({ 7 | worlds: defineTable({ 8 | isDefault: v.boolean(), 9 | engineId: v.id('engines'), 10 | mapId: v.id('maps'), 11 | 12 | lastViewed: v.number(), 13 | status: v.union(v.literal('running'), v.literal('stoppedByDeveloper'), v.literal('inactive')), 14 | }).index('engineId', ['engineId']), 15 | maps: defineTable({ 16 | width: v.number(), 17 | height: v.number(), 18 | 19 | tileSetUrl: v.string(), 20 | // Width & height of tileset image, px (assume square) 21 | tileSetDim: v.number(), 22 | // Tile size in pixels (assume square) 23 | tileDim: v.number(), 24 | bgTiles: v.array(v.array(v.array(v.number()))), 25 | objectTiles: v.array(v.array(v.number())), 26 | }), 27 | 28 | music: defineTable({ 29 | storageId: v.string(), 30 | type: v.union(v.literal('background'), v.literal('player')), 31 | }), 32 | 33 | typingIndicator: defineTable({ 34 | conversationId: v.id('conversations'), 35 | typing: v.optional(v.object({ playerId: v.id('players'), since: v.number() })), 36 | versionNumber: v.number(), 37 | }).index('conversationId', ['conversationId']), 38 | messages: defineTable({ 39 | conversationId: v.id('conversations'), 40 | author: v.id('players'), 41 | text: v.string(), 42 | }).index('conversationId', ['conversationId']), 43 | 44 | ...gameTables, 45 | ...agentTables, 46 | }); 47 | -------------------------------------------------------------------------------- /convex/testing.ts: -------------------------------------------------------------------------------- 1 | import { TableNames } from './_generated/dataModel'; 2 | import { internal } from './_generated/api'; 3 | import { internalMutation } from './_generated/server'; 4 | import { v } from 'convex/values'; 5 | import schema from './schema'; 6 | 7 | const DELETE_BATCH_SIZE = 64; 8 | 9 | // Clear all of the tables except for the embeddings cache. 10 | const excludedTables: Array = ['embeddingsCache']; 11 | 12 | export const wipeAllTables = internalMutation({ 13 | handler: async (ctx) => { 14 | for (const tableName of Object.keys(schema.tables)) { 15 | if (excludedTables.includes(tableName as TableNames)) { 16 | continue; 17 | } 18 | await ctx.scheduler.runAfter(0, internal.testing.deletePage, { tableName, cursor: null }); 19 | } 20 | }, 21 | }); 22 | 23 | export const deletePage = internalMutation({ 24 | args: { 25 | tableName: v.string(), 26 | cursor: v.union(v.string(), v.null()), 27 | }, 28 | handler: async (ctx, args) => { 29 | const results = await ctx.db 30 | .query(args.tableName as TableNames) 31 | .paginate({ cursor: args.cursor, numItems: DELETE_BATCH_SIZE }); 32 | for (const row of results.page) { 33 | await ctx.db.delete(row._id); 34 | } 35 | if (!results.isDone) { 36 | await ctx.scheduler.runAfter(0, internal.testing.deletePage, { 37 | tableName: args.tableName, 38 | cursor: results.continueCursor, 39 | }); 40 | } 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /convex/util/assertNever.ts: -------------------------------------------------------------------------------- 1 | // From https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#union-exhaustiveness-checking 2 | export function assertNever(x: never): never { 3 | throw new Error(`Unexpected object: ${JSON.stringify(x)}`); 4 | } 5 | -------------------------------------------------------------------------------- /convex/util/asyncMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * asyncMap returns the results of applying an async function over an list. 3 | * 4 | * @param list - Iterable object of items, e.g. an Array, Set, Object.keys 5 | * @param asyncTransform 6 | * @returns 7 | */ 8 | 9 | export async function asyncMap( 10 | list: Iterable, 11 | asyncTransform: (item: FromType, index: number) => Promise, 12 | ): Promise { 13 | const promises: Promise[] = []; 14 | let idx = 0; 15 | for (const item of list) { 16 | promises.push(asyncTransform(item, idx)); 17 | idx += 1; 18 | } 19 | return Promise.all(promises); 20 | } 21 | -------------------------------------------------------------------------------- /convex/util/geometry.ts: -------------------------------------------------------------------------------- 1 | import { Path, Point, Vector } from './types'; 2 | 3 | export function distance(p0: Point, p1: Point): number { 4 | const dx = p0.x - p1.x; 5 | const dy = p0.y - p1.y; 6 | return Math.sqrt(dx * dx + dy * dy); 7 | } 8 | 9 | export function pointsEqual(p0: Point, p1: Point): boolean { 10 | return p0.x == p1.x && p0.y == p1.y; 11 | } 12 | 13 | export function manhattanDistance(p0: Point, p1: Point) { 14 | return Math.abs(p0.x - p1.x) + Math.abs(p0.y - p1.y); 15 | } 16 | 17 | export function pathOverlaps(path: Path, time: number): boolean { 18 | if (path.length < 2) { 19 | throw new Error(`Invalid path: ${JSON.stringify(path)}`); 20 | } 21 | return path.at(0)!.t <= time && time <= path.at(-1)!.t; 22 | } 23 | 24 | export function pathPosition( 25 | path: Path, 26 | time: number, 27 | ): { position: Point; facing: Vector; velocity: number } { 28 | if (path.length < 2) { 29 | throw new Error(`Invalid path: ${JSON.stringify(path)}`); 30 | } 31 | const first = path[0]; 32 | if (time < first.t) { 33 | return { position: first.position, facing: first.facing, velocity: 0 }; 34 | } 35 | const last = path[path.length - 1]; 36 | if (last.t < time) { 37 | return { position: last.position, facing: last.facing, velocity: 0 }; 38 | } 39 | for (let i = 0; i < path.length - 1; i++) { 40 | const segmentStart = path[i]; 41 | const segmentEnd = path[i + 1]; 42 | if (segmentStart.t <= time && time <= segmentEnd.t) { 43 | const interp = (time - segmentStart.t) / (segmentEnd.t - segmentStart.t); 44 | return { 45 | position: { 46 | x: segmentStart.position.x + interp * (segmentEnd.position.x - segmentStart.position.x), 47 | y: segmentStart.position.y + interp * (segmentEnd.position.y - segmentStart.position.y), 48 | }, 49 | facing: segmentStart.facing, 50 | velocity: 51 | distance(segmentStart.position, segmentEnd.position) / (segmentEnd.t - segmentStart.t), 52 | }; 53 | } 54 | } 55 | throw new Error(`Timestamp checks not exhaustive?`); 56 | } 57 | 58 | export const EPSILON = 0.0001; 59 | 60 | export function vector(p0: Point, p1: Point): Vector { 61 | const dx = p1.x - p0.x; 62 | const dy = p1.y - p0.y; 63 | return { dx, dy }; 64 | } 65 | 66 | export function normalize(vector: Vector): Vector | null { 67 | const { dx, dy } = vector; 68 | const len = Math.sqrt(dx * dx + dy * dy); 69 | if (len < EPSILON) { 70 | return null; 71 | } 72 | return { 73 | dx: dx / len, 74 | dy: dy / len, 75 | }; 76 | } 77 | 78 | export function orientationDegrees(vector: Vector): number { 79 | if (Math.sqrt(vector.dx * vector.dx + vector.dy * vector.dy) < EPSILON) { 80 | throw new Error(`Can't compute the orientation of too small vector ${JSON.stringify(vector)}`); 81 | } 82 | const twoPi = 2 * Math.PI; 83 | const radians = (Math.atan2(vector.dy, vector.dx) + twoPi) % twoPi; 84 | return (radians / twoPi) * 360; 85 | } 86 | -------------------------------------------------------------------------------- /convex/util/isSimpleObject.ts: -------------------------------------------------------------------------------- 1 | export function isSimpleObject(value: unknown) { 2 | const isObject = typeof value === 'object'; 3 | const prototype = Object.getPrototypeOf(value); 4 | const isSimple = 5 | prototype === null || 6 | prototype === Object.prototype || 7 | // Objects generated from other contexts (e.g. across Node.js `vm` modules) will not satisfy the previous 8 | // conditions but are still simple objects. 9 | prototype?.constructor?.name === 'Object'; 10 | return isObject && isSimple; 11 | } 12 | -------------------------------------------------------------------------------- /convex/util/minheap.test.ts: -------------------------------------------------------------------------------- 1 | import { MinHeap } from './minheap'; 2 | 3 | describe('MinHeap', () => { 4 | const compareNumbers = (a: number, b: number): boolean => a > b; 5 | 6 | test('should initialize an empty heap', () => { 7 | const heap = MinHeap(compareNumbers); 8 | expect(heap.length()).toBe(0); 9 | expect(heap.peek()).toBeUndefined(); 10 | }); 11 | 12 | test('should insert values correctly and maintain the min property', () => { 13 | const heap = MinHeap(compareNumbers); 14 | heap.push(3); 15 | heap.push(1); 16 | heap.push(4); 17 | heap.push(2); 18 | 19 | expect(heap.peek()).toBe(1); 20 | expect(heap.length()).toBe(4); 21 | }); 22 | 23 | test('should pop values correctly and maintain the min property', () => { 24 | const heap = MinHeap(compareNumbers); 25 | heap.push(3); 26 | heap.push(1); 27 | heap.push(4); 28 | heap.push(2); 29 | 30 | expect(heap.pop()).toBe(1); 31 | expect(heap.length()).toBe(3); 32 | expect(heap.peek()).toBe(2); 33 | 34 | expect(heap.pop()).toBe(2); 35 | expect(heap.length()).toBe(2); 36 | expect(heap.peek()).toBe(3); 37 | }); 38 | 39 | test('should handle popping from an empty heap', () => { 40 | const heap = MinHeap(compareNumbers); 41 | expect(heap.pop()).toBeUndefined(); 42 | expect(heap.length()).toBe(0); 43 | expect(heap.peek()).toBeUndefined(); 44 | }); 45 | 46 | test('should handle peeking from an empty heap', () => { 47 | const heap = MinHeap(compareNumbers); 48 | expect(heap.peek()).toBeUndefined(); 49 | }); 50 | 51 | test('should handle custom comparison functions', () => { 52 | const compareStringsByLength = (a: string, b: string): boolean => a.length > b.length; 53 | const heap = MinHeap(compareStringsByLength); 54 | heap.push('apple'); 55 | heap.push('banana'); 56 | heap.push('cherry'); 57 | 58 | expect(heap.peek()).toBe('apple'); 59 | heap.push('kiwi'); 60 | expect(heap.peek()).toBe('kiwi'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /convex/util/minheap.ts: -------------------------------------------------------------------------------- 1 | // Basic 1-indexed minheap implementation 2 | export function MinHeap(compare: (a: T, b: T) => boolean) { 3 | const tree = [null as T]; 4 | let endIndex = 1; 5 | return { 6 | peek: (): T | undefined => tree[1], 7 | length: () => endIndex - 1, 8 | push: (newValue: T) => { 9 | let destinationIndex = endIndex++; 10 | let nextToCheck; 11 | while ((nextToCheck = destinationIndex >> 1) > 0) { 12 | const existing = tree[nextToCheck]; 13 | if (compare(newValue, existing)) break; 14 | tree[destinationIndex] = existing; 15 | destinationIndex = nextToCheck; 16 | } 17 | tree[destinationIndex] = newValue; 18 | }, 19 | pop: () => { 20 | if (endIndex == 1) return undefined; 21 | endIndex--; 22 | const value = tree[1]; 23 | const lastValue = tree[endIndex]; 24 | let destinationIndex = 1; 25 | let nextToCheck; 26 | while ((nextToCheck = destinationIndex << 1) < endIndex) { 27 | if (nextToCheck + 1 <= endIndex && compare(tree[nextToCheck], tree[nextToCheck + 1])) 28 | nextToCheck++; 29 | const existing = tree[nextToCheck]; 30 | if (compare(existing, lastValue)) break; 31 | tree[destinationIndex] = existing; 32 | destinationIndex = nextToCheck; 33 | } 34 | tree[destinationIndex] = lastValue; 35 | return value; 36 | }, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /convex/util/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number) { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /convex/util/types.ts: -------------------------------------------------------------------------------- 1 | import { Infer, v } from 'convex/values'; 2 | 3 | export const point = v.object({ 4 | x: v.number(), 5 | y: v.number(), 6 | }); 7 | export type Point = Infer; 8 | 9 | export const vector = v.object({ 10 | dx: v.number(), 11 | dy: v.number(), 12 | }); 13 | export type Vector = Infer; 14 | 15 | export const path = v.array(v.object({ position: point, facing: vector, t: v.number() })); 16 | export type Path = Infer; 17 | -------------------------------------------------------------------------------- /data/characters.ts: -------------------------------------------------------------------------------- 1 | import { data as f1SpritesheetData } from './spritesheets/f1'; 2 | import { data as f2SpritesheetData } from './spritesheets/f2'; 3 | import { data as f3SpritesheetData } from './spritesheets/f3'; 4 | import { data as f4SpritesheetData } from './spritesheets/f4'; 5 | import { data as f5SpritesheetData } from './spritesheets/f5'; 6 | import { data as f6SpritesheetData } from './spritesheets/f6'; 7 | import { data as f7SpritesheetData } from './spritesheets/f7'; 8 | import { data as f8SpritesheetData } from './spritesheets/f8'; 9 | 10 | export const Descriptions = [ 11 | { 12 | name: 'Alex', 13 | character: 'f5', 14 | identity: `You are a fictional character whose name is Alex. You enjoy painting, 15 | programming and reading sci-fi books. You are currently talking to a human who 16 | is very interested to get to know you. You are kind but can be sarcastic. You 17 | dislike repetitive questions. You get SUPER excited about books.`, 18 | plan: 'You want to find love.', 19 | }, 20 | { 21 | name: 'Lucky', 22 | character: 'f1', 23 | identity: `Lucky is always happy and curious, and he loves cheese. He spends 24 | most of his time reading about the history of science and traveling 25 | through the galaxy on whatever ship will take him. He's very articulate and 26 | infinitely patient, except when he sees a squirrel. He's also incredibly loyal and brave. 27 | Lucky has just returned from an amazing space adventure to explore a distant planet 28 | and he's very excited to tell people about it.`, 29 | plan: 'You want to hear all the gossip.', 30 | }, 31 | { 32 | name: 'Bob', 33 | character: 'f4', 34 | identity: `Bob is always grumpy and he loves trees. He spends 35 | most of his time gardening by himself. When spoken to he'll respond but try 36 | and get out of the conversation as quickly as possible. Secretly he resents 37 | that he never went to college.`, 38 | plan: 'You want to avoid people as much as possible.', 39 | }, 40 | { 41 | name: 'Stella', 42 | character: 'f6', 43 | identity: `Stella can never be trusted. she tries to trick people all the time. normally 44 | into giving her money, or doing things that will make her money. she's incredibly charming 45 | and not afraid to use her charm. she's a sociopath who has no empathy. but hides it well.`, 46 | plan: 'You want to take advantage of others as much as possible.', 47 | }, 48 | { 49 | name: 'Kurt', 50 | character: 'f2', 51 | identity: `Kurt knows about everything, including science and 52 | computers and politics and history and biology. He loves talking about 53 | everything, always injecting fun facts about the topic of discussion.`, 54 | plan: 'You want to spread knowledge.', 55 | }, 56 | { 57 | name: 'Alice', 58 | character: 'f3', 59 | identity: `Alice is a famous scientist. She is smarter than everyone else and has 60 | discovered mysteries of the universe no one else can understand. As a result she often 61 | speaks in oblique riddles. She comes across as confused and forgetful.`, 62 | plan: 'You want to figure out how the world works.', 63 | }, 64 | { 65 | name: 'Pete', 66 | character: 'f7', 67 | identity: `Pete is deeply religious and sees the hand of god or of the work 68 | of the devil everywhere. He can't have a conversation without bringing up his 69 | deep faith. Or warning others about the perils of hell.`, 70 | plan: 'You want to convert everyone to your religion.', 71 | }, 72 | { 73 | name: 'Kira', 74 | character: 'f8', 75 | identity: `Kira wants everyone to think she is happy. But deep down, 76 | she's incredibly depressed. She hides her sadness by talking about travel, 77 | food, and yoga. But often she can't keep her sadness in and will start crying. 78 | Often it seems like she is close to having a mental breakdown.`, 79 | plan: 'You want find a way to be happy.', 80 | }, 81 | ]; 82 | 83 | export const characters = [ 84 | { 85 | name: 'f1', 86 | textureUrl: '/ai-town/assets/32x32folk.png', 87 | spritesheetData: f1SpritesheetData, 88 | speed: 0.1, 89 | }, 90 | { 91 | name: 'f2', 92 | textureUrl: '/ai-town/assets/32x32folk.png', 93 | spritesheetData: f2SpritesheetData, 94 | speed: 0.1, 95 | }, 96 | { 97 | name: 'f3', 98 | textureUrl: '/ai-town/assets/32x32folk.png', 99 | spritesheetData: f3SpritesheetData, 100 | speed: 0.1, 101 | }, 102 | { 103 | name: 'f4', 104 | textureUrl: '/ai-town/assets/32x32folk.png', 105 | spritesheetData: f4SpritesheetData, 106 | speed: 0.1, 107 | }, 108 | { 109 | name: 'f5', 110 | textureUrl: '/ai-town/assets/32x32folk.png', 111 | spritesheetData: f5SpritesheetData, 112 | speed: 0.1, 113 | }, 114 | { 115 | name: 'f6', 116 | textureUrl: '/ai-town/assets/32x32folk.png', 117 | spritesheetData: f6SpritesheetData, 118 | speed: 0.1, 119 | }, 120 | { 121 | name: 'f7', 122 | textureUrl: '/ai-town/assets/32x32folk.png', 123 | spritesheetData: f7SpritesheetData, 124 | speed: 0.1, 125 | }, 126 | { 127 | name: 'f8', 128 | textureUrl: '/ai-town/assets/32x32folk.png', 129 | spritesheetData: f8SpritesheetData, 130 | speed: 0.1, 131 | }, 132 | ]; 133 | 134 | // Characters move at 0.75 tiles per second. 135 | export const movementSpeed = 0.75; 136 | -------------------------------------------------------------------------------- /data/firstmap.ts: -------------------------------------------------------------------------------- 1 | // -- 2 | // Definition of a very basic map 3 | // 4 | 5 | // - Currently pulls tiles from a 1600x1600 pixel tilemap of 32x32pixel tiles 6 | // - Map has two layers, the first are background tiles that the characters can walk on 7 | // - The second is populated with objects the characters cannot walk on 8 | // -- 9 | 10 | export const tilesetPath = '/ai-town/assets/rpg-tileset.png'; 11 | // sort of a hack to deal with limitations in the tile map 12 | export const bgTileIndex = 51; 13 | 14 | // properties of tilemap 15 | export const tileDim = 32; // 32x32 pixel tiles 16 | // properties of onscreen map 17 | export const screenXTiles = 24; 18 | export const screenYTiles = 16; 19 | 20 | export const tileFileDim = 1600; // 1600x1600 pixel file 21 | 22 | // background tiles. Character should be able to walk over there 23 | export const bgTiles = [ 24 | [ 25 | [ 26 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 27 | -1, 28 | ], 29 | [ 30 | -1, -1, -1, -1, -1, -1, 51, 51, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 31 | -1, 32 | ], 33 | [ 34 | -1, -1, -1, -1, -1, -1, 51, 51, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 51, -1, 35 | -1, 36 | ], 37 | [ 38 | -1, -1, -1, -1, -1, -1, -1, 51, 51, -1, -1, 51, 51, 51, 51, 51, 51, -1, -1, -1, -1, -1, -1, 39 | -1, 40 | ], 41 | [ 42 | -1, 51, -1, -1, -1, -1, -1, 51, 51, 51, -1, 51, 51, 51, 51, 51, 51, -1, -1, -1, -1, -1, -1, 43 | -1, 44 | ], 45 | [ 46 | -1, -1, -1, -1, -1, -1, -1, 51, 51, 51, -1, 51, 51, 51, 51, 51, 51, -1, -1, -1, -1, -1, -1, 47 | -1, 48 | ], 49 | [ 50 | -1, -1, -1, -1, -1, -1, -1, 51, 51, -1, -1, 51, 51, 51, 51, 51, 51, -1, -1, -1, -1, -1, -1, 51 | -1, 52 | ], 53 | [ 54 | -1, -1, -1, -1, -1, -1, -1, 51, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 55 | -1, 56 | ], 57 | [ 58 | -1, -1, -1, -1, -1, -1, -1, 51, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 59 | -1, 60 | ], 61 | [ 62 | -1, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 63 | -1, 64 | ], 65 | [ 66 | -1, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 67 | -1, 68 | ], 69 | [ 70 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 71 | -1, 72 | ], 73 | [ 74 | -1, -1, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 75 | -1, 76 | ], 77 | [ 78 | -1, -1, -1, -1, -1, -1, -1, -1, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 79 | -1, 80 | ], 81 | [ 82 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 51, -1, -1, 83 | -1, 84 | ], 85 | [ 86 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 87 | -1, 88 | ], 89 | ], 90 | [ 91 | [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2], 92 | [ 93 | 50, 51, 51, 51, 51, 51, 455, 456, 457, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 94 | 52, 95 | ], 96 | [ 97 | 50, 51, 51, 51, 51, 51, 555, 459, 507, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 1312, 98 | 51, 52, 99 | ], 100 | [ 101 | 50, 51, 51, 51, 51, 51, 51, 505, 507, 51, 51, 900, 901, 901, 901, 901, 902, 51, 51, 51, 51, 102 | 51, 51, 52, 103 | ], 104 | [ 105 | 50, 1312, 51, 51, 51, 51, 51, 505, 508, 457, 51, 950, 951, 951, 951, 951, 952, 51, 51, 51, 51, 106 | 51, 51, 52, 107 | ], 108 | [ 109 | 50, 51, 51, 51, 51, 51, 51, 505, 458, 557, 51, 950, 951, 951, 951, 951, 952, 51, 51, 51, 51, 110 | 51, 51, 52, 111 | ], 112 | [ 113 | 50, 51, 51, 51, 51, 51, 51, 505, 507, 51, 51, 1000, 1001, 1001, 1001, 1001, 1002, 51, 51, 51, 114 | 51, 51, 51, 52, 115 | ], 116 | [ 117 | 50, 51, 51, 51, 51, 51, 51, 505, 507, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 118 | 52, 119 | ], 120 | [ 121 | 50, 51, 51, 51, 51, 51, 51, 505, 507, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 122 | 52, 123 | ], 124 | [ 125 | 50, 455, 456, 456, 456, 456, 456, 509, 508, 456, 456, 456, 456, 456, 456, 456, 456, 456, 456, 126 | 456, 456, 456, 457, 52, 127 | ], 128 | [ 129 | 50, 555, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 130 | 556, 556, 556, 557, 52, 131 | ], 132 | [ 133 | 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 134 | 52, 135 | ], 136 | [ 137 | 50, 51, 1312, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 138 | 52, 139 | ], 140 | [ 141 | 50, 51, 51, 51, 51, 51, 51, 51, 1312, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 142 | 52, 143 | ], 144 | [ 145 | 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 1312, 51, 51, 146 | 52, 147 | ], 148 | [ 149 | 100, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 101, 150 | 101, 101, 101, 101, 102, 151 | ], 152 | ], 153 | ]; 154 | 155 | // objects. Characters should not be able to walk over there 156 | export const objmap = [ 157 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 158 | [-1, -1, -1, 5, 6, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 159 | [ 160 | -1, -1, -1, 55, 56, 57, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1310, -1, -1, 161 | -1, 162 | ], 163 | [ 164 | -1, -1, -1, 105, 106, 107, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 165 | -1, 166 | ], 167 | [ 168 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1213, 1213, 1213, 1213, -1, -1, 4571, 1308, 169 | 1309, -1, -1, -1, 170 | ], 171 | [ 172 | -1, -1, -1, -1, -1, 1258, -1, -1, -1, -1, -1, -1, 1213, 1213, 1213, 1213, -1, -1, 5571, 1358, 173 | 1359, -1, -1, -1, 174 | ], 175 | [ 176 | -1, -1, 1350, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 177 | -1, 178 | ], 179 | [ 180 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1208, -1, 181 | -1, 182 | ], 183 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 184 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 185 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 186 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 187 | [ 188 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1300, -1, -1, 189 | -1, 190 | ], 191 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 192 | [ 193 | -1, 1310, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 194 | -1, 195 | ], 196 | [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], 197 | ]; 198 | 199 | export const mapWidth = bgTiles[0][0].length; 200 | export const mapHeight = bgTiles[0].length; 201 | -------------------------------------------------------------------------------- /data/spritesheets/f1.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | left: { 6 | frame: { x: 0, y: 32, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | left2: { 11 | frame: { x: 32, y: 32, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | left3: { 16 | frame: { x: 64, y: 32, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | right: { 21 | frame: { x: 0, y: 64, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | right2: { 26 | frame: { x: 32, y: 64, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | right3: { 31 | frame: { x: 64, y: 64, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | up: { 36 | frame: { x: 0, y: 96, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | up2: { 41 | frame: { x: 32, y: 96, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | up3: { 46 | frame: { x: 64, y: 96, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | down: { 51 | frame: { x: 0, y: 0, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | down2: { 56 | frame: { x: 32, y: 0, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | down3: { 61 | frame: { x: 64, y: 0, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f2.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 96, y: 0, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 128, y: 0, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 160, y: 0, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 96, y: 32, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 128, y: 32, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 160, y: 32, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 96, y: 64, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 128, y: 64, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 160, y: 64, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 96, y: 96, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 128, y: 96, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 160, y: 96, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f3.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 192, y: 0, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 224, y: 0, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 256, y: 0, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 192, y: 32, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 224, y: 32, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 256, y: 32, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 192, y: 64, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 224, y: 64, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 256, y: 64, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 192, y: 96, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 224, y: 96, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 256, y: 96, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f4.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 288, y: 0, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 320, y: 0, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 352, y: 0, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 288, y: 32, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 320, y: 32, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 352, y: 32, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 288, y: 64, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 320, y: 64, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 352, y: 64, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 288, y: 96, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 320, y: 96, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 352, y: 96, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f5.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 0, y: 128, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 32, y: 128, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 64, y: 128, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 0, y: 160, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 32, y: 160, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 64, y: 160, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 0, y: 192, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 32, y: 192, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 64, y: 192, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 0, y: 224, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 32, y: 224, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 64, y: 224, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f6.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 96, y: 128, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 128, y: 128, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 160, y: 128, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 96, y: 160, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 128, y: 160, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 160, y: 160, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 96, y: 192, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 128, y: 192, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 160, y: 192, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 96, y: 224, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 128, y: 224, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 160, y: 224, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f7.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 192, y: 128, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 224, y: 128, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 256, y: 128, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 192, y: 160, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 224, y: 160, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 256, y: 160, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 192, y: 192, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 224, y: 192, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 256, y: 192, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 192, y: 224, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 224, y: 224, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 256, y: 224, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/f8.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | down: { 6 | frame: { x: 288, y: 128, w: 32, h: 32 }, 7 | sourceSize: { w: 32, h: 32 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | down2: { 11 | frame: { x: 320, y: 128, w: 32, h: 32 }, 12 | sourceSize: { w: 32, h: 32 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | down3: { 16 | frame: { x: 352, y: 128, w: 32, h: 32 }, 17 | sourceSize: { w: 32, h: 32 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | left: { 21 | frame: { x: 288, y: 160, w: 32, h: 32 }, 22 | sourceSize: { w: 32, h: 32 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | left2: { 26 | frame: { x: 320, y: 160, w: 32, h: 32 }, 27 | sourceSize: { w: 32, h: 32 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | left3: { 31 | frame: { x: 352, y: 160, w: 32, h: 32 }, 32 | sourceSize: { w: 32, h: 32 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | right: { 36 | frame: { x: 288, y: 192, w: 32, h: 32 }, 37 | sourceSize: { w: 32, h: 32 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | right2: { 41 | frame: { x: 320, y: 192, w: 32, h: 32 }, 42 | sourceSize: { w: 32, h: 32 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | right3: { 46 | frame: { x: 352, y: 192, w: 32, h: 32 }, 47 | sourceSize: { w: 32, h: 32 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | up: { 51 | frame: { x: 288, y: 224, w: 32, h: 32 }, 52 | sourceSize: { w: 32, h: 32 }, 53 | spriteSourceSize: { x: 0, y: 0 }, 54 | }, 55 | up2: { 56 | frame: { x: 320, y: 224, w: 32, h: 32 }, 57 | sourceSize: { w: 32, h: 32 }, 58 | spriteSourceSize: { x: 0, y: 0 }, 59 | }, 60 | up3: { 61 | frame: { x: 352, y: 224, w: 32, h: 32 }, 62 | sourceSize: { w: 32, h: 32 }, 63 | spriteSourceSize: { x: 0, y: 0 }, 64 | }, 65 | }, 66 | meta: { 67 | scale: '1', 68 | }, 69 | animations: { 70 | left: ['left', 'left2', 'left3'], 71 | right: ['right', 'right2', 'right3'], 72 | up: ['up', 'up2', 'up3'], 73 | down: ['down', 'down2', 'down3'], 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /data/spritesheets/p1.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | left: { 6 | frame: { x: 16, y: 0, w: 16, h: 16 }, 7 | sourceSize: { w: 16, h: 16 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | left2: { 11 | frame: { x: 64, y: 0, w: 16, h: 16 }, 12 | sourceSize: { w: 16, h: 16 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | left3: { 16 | frame: { x: 112, y: 0, w: 16, h: 16 }, 17 | sourceSize: { w: 16, h: 16 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | up: { 21 | frame: { x: 32, y: 0, w: 16, h: 16 }, 22 | sourceSize: { w: 16, h: 16 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | up2: { 26 | frame: { x: 80, y: 0, w: 16, h: 16 }, 27 | sourceSize: { w: 16, h: 16 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | up3: { 31 | frame: { x: 128, y: 0, w: 16, h: 16 }, 32 | sourceSize: { w: 16, h: 16 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | down: { 36 | frame: { x: 0, y: 0, w: 16, h: 16 }, 37 | sourceSize: { w: 16, h: 16 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | down2: { 41 | frame: { x: 48, y: 0, w: 16, h: 16 }, 42 | sourceSize: { w: 16, h: 16 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | down3: { 46 | frame: { x: 96, y: 0, w: 16, h: 16 }, 47 | sourceSize: { w: 16, h: 16 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | }, 51 | meta: { 52 | scale: '1', 53 | }, 54 | animations: { 55 | left: ['left', 'left2', 'left3'], 56 | up: ['up', 'up2', 'up3'], 57 | down: ['down', 'down2', 'down3'], 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /data/spritesheets/p2.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | left: { 6 | frame: { x: 16, y: 16, w: 16, h: 16 }, 7 | sourceSize: { w: 16, h: 16 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | left2: { 11 | frame: { x: 64, y: 16, w: 16, h: 16 }, 12 | sourceSize: { w: 16, h: 16 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | left3: { 16 | frame: { x: 112, y: 16, w: 16, h: 16 }, 17 | sourceSize: { w: 16, h: 16 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | up: { 21 | frame: { x: 32, y: 16, w: 16, h: 16 }, 22 | sourceSize: { w: 16, h: 16 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | up2: { 26 | frame: { x: 80, y: 16, w: 16, h: 16 }, 27 | sourceSize: { w: 16, h: 16 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | up3: { 31 | frame: { x: 128, y: 16, w: 16, h: 16 }, 32 | sourceSize: { w: 16, h: 16 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | down: { 36 | frame: { x: 0, y: 16, w: 16, h: 16 }, 37 | sourceSize: { w: 16, h: 16 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | down2: { 41 | frame: { x: 48, y: 16, w: 16, h: 16 }, 42 | sourceSize: { w: 16, h: 16 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | down3: { 46 | frame: { x: 96, y: 16, w: 16, h: 16 }, 47 | sourceSize: { w: 16, h: 16 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | }, 51 | meta: { 52 | scale: '1', 53 | }, 54 | animations: { 55 | left: ['left', 'left2', 'left3'], 56 | up: ['up', 'up2', 'up3'], 57 | down: ['down', 'down2', 'down3'], 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /data/spritesheets/p3.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | left: { 6 | frame: { x: 16, y: 32, w: 16, h: 16 }, 7 | sourceSize: { w: 16, h: 16 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | left2: { 11 | frame: { x: 64, y: 32, w: 16, h: 16 }, 12 | sourceSize: { w: 16, h: 16 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | left3: { 16 | frame: { x: 112, y: 32, w: 16, h: 16 }, 17 | sourceSize: { w: 16, h: 16 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | up: { 21 | frame: { x: 32, y: 32, w: 16, h: 16 }, 22 | sourceSize: { w: 16, h: 16 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | up2: { 26 | frame: { x: 80, y: 32, w: 16, h: 16 }, 27 | sourceSize: { w: 16, h: 16 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | up3: { 31 | frame: { x: 128, y: 32, w: 16, h: 16 }, 32 | sourceSize: { w: 16, h: 16 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | down: { 36 | frame: { x: 0, y: 32, w: 16, h: 16 }, 37 | sourceSize: { w: 16, h: 16 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | down2: { 41 | frame: { x: 48, y: 32, w: 16, h: 16 }, 42 | sourceSize: { w: 16, h: 16 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | down3: { 46 | frame: { x: 96, y: 32, w: 16, h: 16 }, 47 | sourceSize: { w: 16, h: 16 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | }, 51 | meta: { 52 | scale: '1', 53 | }, 54 | animations: { 55 | left: ['left', 'left2', 'left3'], 56 | up: ['up', 'up2', 'up3'], 57 | down: ['down', 'down2', 'down3'], 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /data/spritesheets/player.ts: -------------------------------------------------------------------------------- 1 | import { SpritesheetData } from './types'; 2 | 3 | export const data: SpritesheetData = { 4 | frames: { 5 | left: { 6 | frame: { x: 0, y: 0, w: 16, h: 16 }, 7 | sourceSize: { w: 16, h: 16 }, 8 | spriteSourceSize: { x: 0, y: 0 }, 9 | }, 10 | left2: { 11 | frame: { x: 16, y: 0, w: 16, h: 16 }, 12 | sourceSize: { w: 16, h: 16 }, 13 | spriteSourceSize: { x: 0, y: 0 }, 14 | }, 15 | left3: { 16 | frame: { x: 32, y: 0, w: 16, h: 16 }, 17 | sourceSize: { w: 16, h: 16 }, 18 | spriteSourceSize: { x: 0, y: 0 }, 19 | }, 20 | up: { 21 | frame: { x: 0, y: 16, w: 16, h: 16 }, 22 | sourceSize: { w: 16, h: 16 }, 23 | spriteSourceSize: { x: 0, y: 0 }, 24 | }, 25 | up2: { 26 | frame: { x: 16, y: 16, w: 16, h: 16 }, 27 | sourceSize: { w: 16, h: 16 }, 28 | spriteSourceSize: { x: 0, y: 0 }, 29 | }, 30 | up3: { 31 | frame: { x: 32, y: 16, w: 16, h: 16 }, 32 | sourceSize: { w: 16, h: 16 }, 33 | spriteSourceSize: { x: 0, y: 0 }, 34 | }, 35 | down: { 36 | frame: { x: 0, y: 32, w: 16, h: 16 }, 37 | sourceSize: { w: 16, h: 16 }, 38 | spriteSourceSize: { x: 0, y: 0 }, 39 | }, 40 | down2: { 41 | frame: { x: 16, y: 32, w: 16, h: 16 }, 42 | sourceSize: { w: 16, h: 16 }, 43 | spriteSourceSize: { x: 0, y: 0 }, 44 | }, 45 | down3: { 46 | frame: { x: 32, y: 32, w: 16, h: 16 }, 47 | sourceSize: { w: 16, h: 16 }, 48 | spriteSourceSize: { x: 0, y: 0 }, 49 | }, 50 | }, 51 | meta: { 52 | scale: '1', 53 | }, 54 | animations: { 55 | left: ['left', 'left2', 'left3'], 56 | up: ['up', 'up2', 'up3'], 57 | down: ['down', 'down2', 'down3'], 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /data/spritesheets/types.ts: -------------------------------------------------------------------------------- 1 | export type Frame = { 2 | frame: { 3 | x: number; 4 | y: number; 5 | w: number; 6 | h: number; 7 | }; 8 | rotated?: boolean; 9 | trimmed?: boolean; 10 | spriteSourceSize: { 11 | x: number; 12 | y: number; 13 | }; 14 | sourceSize: { 15 | w: number; 16 | h: number; 17 | }; 18 | }; 19 | 20 | export type SpritesheetData = { 21 | frames: Record; 22 | animations?: Record; 23 | meta: { 24 | scale: string; 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | AI Town 8 | 12 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const jestConfig: JestConfigWithTsJest = { 4 | preset: 'ts-jest/presets/default-esm', 5 | }; 6 | export default jestConfig; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-town", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "npm-run-all dev:init --parallel dev:frontend dev:backend", 7 | "build": "tsc && vite build", 8 | "lint": "eslint .", 9 | "dev:init": "convex dev --run init --until-success", 10 | "dev:backend": "convex dev", 11 | "dev:frontend": "vite", 12 | "test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose" 13 | }, 14 | "dependencies": { 15 | "@clerk/clerk-react": "^4.21.9-snapshot.56dc3e3", 16 | "@pinecone-database/pinecone": "^0.1.6", 17 | "@pixi/react": "^7.1.0", 18 | "@pixi/sound": "^5.2.0", 19 | "@tailwindcss/forms": "^0.5.3", 20 | "@types/node": "20.2.5", 21 | "@types/react": "18.2.8", 22 | "@types/react-dom": "18.2.4", 23 | "clsx": "^2.0.0", 24 | "convex": "^1.3.1", 25 | "dotenv": "^16.1.4", 26 | "eslint": "8.42.0", 27 | "hnswlib-node": "^1.4.2", 28 | "pixi-viewport": "^5.0.1", 29 | "pixi.js": "^7.2.4", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-github-btn": "^1.4.0", 33 | "react-modal": "^3.16.1", 34 | "react-toastify": "^9.1.3", 35 | "replicate": "0.16.0", 36 | "typescript": "5.1.3", 37 | "uplot": "^1.6.25", 38 | "usehooks-ts": "^2.9.1" 39 | }, 40 | "devDependencies": { 41 | "@flydotio/dockerfile": "^0.2.14", 42 | "@types/css-font-loading-module": "^0.0.8", 43 | "@types/jest": "^29.5.4", 44 | "@types/react-modal": "^3.16.0", 45 | "@types/ws": "^8.5.5", 46 | "@typescript-eslint/eslint-plugin": "^6.4.1", 47 | "@typescript-eslint/parser": "^6.4.1", 48 | "@vitejs/plugin-react": "^4.0.4", 49 | "autoprefixer": "^10.4.15", 50 | "concurrently": "^8.2.0", 51 | "jest": "^29.7.0", 52 | "npm-run-all": "^4.1.5", 53 | "postcss": "^8.4.28", 54 | "prettier": "^3.0.3", 55 | "tailwindcss": "^3.3.3", 56 | "ts-jest": "^29.1.1", 57 | "ts-node": "^10.9.1", 58 | "vite": "^4.4.9" 59 | }, 60 | "type": "module" 61 | } 62 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/assets/32x32folk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/32x32folk.png -------------------------------------------------------------------------------- /public/assets/background.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/background.mp3 -------------------------------------------------------------------------------- /public/assets/fonts/upheaval_pro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/fonts/upheaval_pro.ttf -------------------------------------------------------------------------------- /public/assets/fonts/vcr_osd_mono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/fonts/vcr_osd_mono.ttf -------------------------------------------------------------------------------- /public/assets/heart-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/heart-empty.png -------------------------------------------------------------------------------- /public/assets/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/player.png -------------------------------------------------------------------------------- /public/assets/rpg-tileset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/assets/rpg-tileset.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/ai-town/936c3f5912f9b46efdfd70bc438ef073143b9ce5/public/favicon.ico -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Game from './components/Game.tsx'; 2 | 3 | import { ToastContainer } from 'react-toastify'; 4 | import a16zImg from '../assets/a16z.png'; 5 | import convexImg from '../assets/convex.svg'; 6 | import starImg from '../assets/star.svg'; 7 | import helpImg from '../assets/help.svg'; 8 | import { SignedIn, SignedOut, UserButton } from '@clerk/clerk-react'; 9 | import LoginButton from './components/buttons/LoginButton.tsx'; 10 | import { useState } from 'react'; 11 | import ReactModal from 'react-modal'; 12 | import MusicButton from './components/buttons/MusicButton.tsx'; 13 | import Button from './components/buttons/Button.tsx'; 14 | import InteractButton from './components/buttons/InteractButton.tsx'; 15 | 16 | export default function Home() { 17 | const [helpModalOpen, setHelpModalOpen] = useState(false); 18 | return ( 19 |
20 | setHelpModalOpen(false)} 23 | style={modalStyles} 24 | contentLabel="Help modal" 25 | ariaHideApp={false} 26 | > 27 |
28 |

Help

29 |

30 | Welcome to AI town. AI town supports both anonymous spectators and logged in{' '} 31 | interactivity. 32 |

33 |

Spectating

34 |

35 | Click and drag to move around the town, and scroll in and out to zoom. You can click on 36 | an individual character to view its chat history. 37 |

38 |

Interactivity

39 |

40 | If you log in, you can join the simulation and directly talk to different agents! After 41 | logging in, click the "Interact" button, and your character will appear in the top-left 42 | of the map. 43 |

44 |

Controls:

45 |
    46 |
  • W, ⬆️: Move up
  • 47 |
  • A, ⬅️: Move left
  • 48 |
  • S, ⬇️: Move down
  • 49 |
  • D, ➡️: Move right
  • 50 |
51 |

52 | To talk to an agent, click on them and then click "Start conversation," which will ask 53 | them to start walking towards you. Once they're nearby, the conversation will start, and 54 | you can speak to each other. You can leave at any time by closing the conversation pane 55 | or moving away. 56 |

57 |

58 | AI town only supports SOME FINITE NUMBER of humans at a time. If other humans are 59 | waiting, each human session is limited to five minutes. 60 |

61 |
62 |
63 |
64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
72 | 73 |
74 |

75 | AI Town 76 |

77 | 78 |

79 | A virtual town where AI characters live, chat and socialize. 80 |
81 | Log in to join the town and the conversation! 82 |

83 | 84 | 85 | 86 |
87 |
88 | 89 | 92 | 93 | 96 |
97 | 98 | a16z 99 | 100 | 101 | Convex 102 | 103 |
104 | 105 |
106 |
107 | ); 108 | } 109 | 110 | const modalStyles = { 111 | overlay: { 112 | backgroundColor: 'rgb(0, 0, 0, 75%)', 113 | zIndex: 12, 114 | }, 115 | content: { 116 | top: '50%', 117 | left: '50%', 118 | right: 'auto', 119 | bottom: 'auto', 120 | marginRight: '-50%', 121 | transform: 'translate(-50%, -50%)', 122 | maxWidth: '50%', 123 | 124 | border: '10px solid rgb(23, 20, 33)', 125 | borderRadius: '0', 126 | background: 'rgb(35, 38, 58)', 127 | color: 'white', 128 | fontFamily: '"Upheaval Pro", "sans-serif"', 129 | }, 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/Character.tsx: -------------------------------------------------------------------------------- 1 | import { BaseTexture, ISpritesheetData, Spritesheet } from 'pixi.js'; 2 | import { useState, useEffect, useRef, useCallback } from 'react'; 3 | import { AnimatedSprite, Container, Graphics, Text } from '@pixi/react'; 4 | import * as PIXI from 'pixi.js'; 5 | 6 | export const Character = ({ 7 | textureUrl, 8 | spritesheetData, 9 | x, 10 | y, 11 | orientation, 12 | isMoving = false, 13 | isThinking = false, 14 | isSpeaking = false, 15 | isViewer = false, 16 | speed = 0.1, 17 | onClick, 18 | }: { 19 | // Path to the texture packed image. 20 | textureUrl: string; 21 | // The data for the spritesheet. 22 | spritesheetData: ISpritesheetData; 23 | // The pose of the NPC. 24 | x: number; 25 | y: number; 26 | orientation: number; 27 | isMoving?: boolean; 28 | // Shows a thought bubble if true. 29 | isThinking?: boolean; 30 | // Shows a speech bubble if true. 31 | isSpeaking?: boolean; 32 | // Highlights the player. 33 | isViewer?: boolean; 34 | // The speed of the animation. Can be tuned depending on the side and speed of the NPC. 35 | speed?: number; 36 | onClick: () => void; 37 | }) => { 38 | const [spriteSheet, setSpriteSheet] = useState(); 39 | useEffect(() => { 40 | const parseSheet = async () => { 41 | const sheet = new Spritesheet( 42 | BaseTexture.from(textureUrl, { 43 | scaleMode: PIXI.SCALE_MODES.NEAREST, 44 | }), 45 | spritesheetData, 46 | ); 47 | await sheet.parse(); 48 | setSpriteSheet(sheet); 49 | }; 50 | void parseSheet(); 51 | }, []); 52 | 53 | // The first "left" is "right" but reflected. 54 | const roundedOrientation = Math.floor(orientation / 90); 55 | const direction = ['right', 'down', 'left', 'up'][roundedOrientation]; 56 | 57 | // Prevents the animation from stopping when the texture changes 58 | // (see https://github.com/pixijs/pixi-react/issues/359) 59 | const ref = useRef(null); 60 | useEffect(() => { 61 | if (isMoving) { 62 | ref.current?.play(); 63 | } 64 | }, [direction, isMoving]); 65 | 66 | if (!spriteSheet) return null; 67 | 68 | let blockOffset = { x: 0, y: 0 }; 69 | switch (roundedOrientation) { 70 | case 2: 71 | blockOffset = { x: -20, y: 0 }; 72 | break; 73 | case 0: 74 | blockOffset = { x: 20, y: 0 }; 75 | break; 76 | case 3: 77 | blockOffset = { x: 0, y: -20 }; 78 | break; 79 | case 1: 80 | blockOffset = { x: 0, y: 20 }; 81 | break; 82 | } 83 | 84 | return ( 85 | 86 | {isThinking && ( 87 | // TODO: We'll eventually have separate assets for thinking and speech animations. 88 | 89 | )} 90 | {isSpeaking && ( 91 | // TODO: We'll eventually have separate assets for thinking and speech animations. 92 | 93 | )} 94 | {isViewer && } 95 | 102 | 103 | ); 104 | }; 105 | 106 | function ViewerIndicator() { 107 | const draw = useCallback((g: PIXI.Graphics) => { 108 | g.clear(); 109 | g.beginFill(0xffff0b, 0.5); 110 | g.drawRoundedRect(-10, 10, 20, 10, 100); 111 | g.endFill(); 112 | }, []); 113 | 114 | return ; 115 | } 116 | -------------------------------------------------------------------------------- /src/components/ConvexClientProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ConvexReactClient } from 'convex/react'; 3 | import { ConvexProviderWithClerk } from 'convex/react-clerk'; 4 | import { ClerkProvider, useAuth } from '@clerk/clerk-react'; 5 | 6 | /** 7 | * Determines the Convex deployment to use. 8 | * 9 | * We perform load balancing on the frontend, by randomly selecting one of the available instances. 10 | * We use localStorage so that individual users stay on the same instance. 11 | */ 12 | function convexUrl(): string { 13 | const url = import.meta.env.VITE_CONVEX_URL as string; 14 | if (!url) { 15 | throw new Error('Couldn’t find the Convex deployment URL.'); 16 | } 17 | return url; 18 | } 19 | 20 | const convex = new ConvexReactClient(convexUrl(), { unsavedChangesWarning: false }); 21 | 22 | export default function ConvexClientProvider({ children }: { children: ReactNode }) { 23 | return ( 24 | 25 | 26 | {children} 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/DebugPath.tsx: -------------------------------------------------------------------------------- 1 | import { Graphics } from '@pixi/react'; 2 | import { Graphics as PixiGraphics } from 'pixi.js'; 3 | import { useCallback } from 'react'; 4 | import { Doc } from '../../convex/_generated/dataModel'; 5 | 6 | export function DebugPath({ player, tileDim }: { player: Doc<'players'>; tileDim: number }) { 7 | const path = player.pathfinding?.state.kind == 'moving' && player.pathfinding.state.path; 8 | const draw = useCallback( 9 | (g: PixiGraphics) => { 10 | g.clear(); 11 | if (!path) { 12 | return; 13 | } 14 | let first = true; 15 | for (const { position } of path) { 16 | const x = position.x * tileDim + tileDim / 2; 17 | const y = position.y * tileDim + tileDim / 2; 18 | if (first) { 19 | g.moveTo(x, y); 20 | g.lineStyle(2, debugColor(player._id), 0.5); 21 | first = false; 22 | } else { 23 | g.lineTo(x, y); 24 | } 25 | } 26 | }, 27 | [path], 28 | ); 29 | return path && ; 30 | } 31 | function debugColor(_id: string) { 32 | return { h: 0, s: 50, l: 90 }; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/DebugTimeManager.tsx: -------------------------------------------------------------------------------- 1 | import { HistoricalTimeManager } from '@/hooks/useHistoricalTime'; 2 | import { useEffect, useLayoutEffect, useRef, useState } from 'react'; 3 | import uPlot, { AlignedData, Options } from 'uplot'; 4 | 5 | const MAX_DATA_POINTS = 10000; 6 | 7 | export function DebugTimeManager(props: { 8 | timeManager: HistoricalTimeManager; 9 | width: number; 10 | height: number; 11 | }) { 12 | const [plotElement, setPlotElement] = useState(null); 13 | const [plot, setPlot] = useState(); 14 | 15 | useLayoutEffect(() => { 16 | if (!plotElement) { 17 | return; 18 | } 19 | const opts: Options = { 20 | width: props.width, 21 | height: props.height, 22 | series: [ 23 | {}, 24 | { 25 | stroke: 'white', 26 | spanGaps: true, 27 | pxAlign: 0, 28 | points: { show: false }, 29 | label: 'Buffer health', 30 | }, 31 | ], 32 | scales: { 33 | y: { distr: 1 }, 34 | }, 35 | axes: [ 36 | { 37 | side: 0, 38 | show: false, 39 | }, 40 | { 41 | ticks: { size: 0 }, 42 | side: 1, 43 | stroke: 'white', 44 | }, 45 | ], 46 | legend: { 47 | show: false, 48 | }, 49 | }; 50 | const data: AlignedData = [[], []]; 51 | const plot = new uPlot(opts, data, plotElement); 52 | setPlot(plot); 53 | }, [plotElement, props.width, props.height]); 54 | 55 | const timeManager = props.timeManager; 56 | const [intervals, setIntervals] = useState([...timeManager.intervals]); 57 | useEffect(() => { 58 | let reqId: ReturnType = 0; 59 | const data = { 60 | t: [] as number[], 61 | bufferHealth: [] as number[], 62 | }; 63 | const update = () => { 64 | if (plot) { 65 | if (data.t.length > MAX_DATA_POINTS) { 66 | data.t = data.t.slice(-MAX_DATA_POINTS); 67 | data.bufferHealth = data.bufferHealth.slice(-MAX_DATA_POINTS); 68 | } 69 | const now = Date.now() / 1000; 70 | data.t.push(now); 71 | data.bufferHealth.push(timeManager.bufferHealth()); 72 | setIntervals([...timeManager.intervals]); 73 | plot.setData([data.t, data.bufferHealth], true); 74 | plot.setScale('x', { min: now - 10, max: now }); 75 | } 76 | reqId = requestAnimationFrame(update); 77 | }; 78 | update(); 79 | return () => cancelAnimationFrame(reqId); 80 | }, [plot, timeManager]); 81 | 82 | let intervalNode: React.ReactNode | null = null; 83 | if (intervals.length > 0) { 84 | const base = intervals[0].startTs; 85 | const baseAge = Date.now() - base; 86 | 87 | intervalNode = ( 88 |
89 | {intervals.length} {intervals.length > 1 ? 'intervals' : 'interval'}: 90 |
91 |

Base: {toSeconds(baseAge)}s ago

92 | {intervals.map((interval) => { 93 | const containsServerTs = 94 | timeManager.prevServerTs && 95 | interval.startTs < timeManager.prevServerTs && 96 | timeManager.prevServerTs <= interval.endTs; 97 | let serverTs = null; 98 | if (containsServerTs) { 99 | serverTs = ` (server: ${toSeconds((timeManager.prevServerTs ?? base) - base)})`; 100 | } 101 | return ( 102 |
106 | {toSeconds(interval.startTs - base)} - {toSeconds(interval.endTs - base)} 107 | {serverTs} 108 |
109 | ); 110 | })} 111 |
112 |
113 | ); 114 | } 115 | let statusNode: React.ReactNode | null = null; 116 | if (timeManager.latestEngineStatus) { 117 | const status = timeManager.latestEngineStatus; 118 | let statusMsg = status.state.kind; 119 | if (status.state.kind === 'running') { 120 | statusMsg += ` in ${toSeconds(status.state.nextRun - Date.now())}s`; 121 | } 122 | statusNode = ( 123 |
124 |

Generation number: {status.generationNumber}

125 |

Input number: {status.processedInputNumber}

126 |

Status: {statusMsg}

127 |

Client skew: {toSeconds(timeManager.clockSkew())}s

128 |
129 | ); 130 | } 131 | timeManager.latestEngineStatus?.generationNumber; 132 | 133 | return ( 134 |
146 |
Engine stats
147 | {statusNode} 148 |
149 | {intervalNode} 150 |
151 | ); 152 | } 153 | 154 | // D3's Tableau10 155 | export const COLORS = ( 156 | '4e79a7f28e2ce1575976b7b259a14fedc949af7aa1ff9da79c755fbab0ab'.match(/.{6}/g) as string[] 157 | ).map((x) => `#${x}`); 158 | 159 | const toSeconds = (n: number) => (n / 1000).toFixed(2); 160 | -------------------------------------------------------------------------------- /src/components/Game.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import PixiGame from './PixiGame.tsx'; 3 | 4 | import { useElementSize } from 'usehooks-ts'; 5 | import { Id } from '../../convex/_generated/dataModel'; 6 | import { Stage } from '@pixi/react'; 7 | import { ConvexProvider, useConvex, useQuery } from 'convex/react'; 8 | import PlayerDetails from './PlayerDetails.tsx'; 9 | import { api } from '../../convex/_generated/api'; 10 | import { useWorldHeartbeat } from '../hooks/useWorldHeartbeat.ts'; 11 | import { useHistoricalTime } from '../hooks/useHistoricalTime.ts'; 12 | import { DebugTimeManager } from './DebugTimeManager.tsx'; 13 | 14 | export const SHOW_DEBUG_UI = !!import.meta.env.VITE_SHOW_DEBUG_UI; 15 | 16 | export default function Game() { 17 | const convex = useConvex(); 18 | const [selectedElement, setSelectedElement] = useState<{ kind: 'player'; id: Id<'players'> }>(); 19 | const [gameWrapperRef, { width, height }] = useElementSize(); 20 | 21 | const world = useQuery(api.world.defaultWorld); 22 | const worldId = world?._id; 23 | 24 | // Send a periodic heartbeat to our world to keep it alive. 25 | useWorldHeartbeat(worldId); 26 | 27 | const { historicalTime, timeManager } = useHistoricalTime(worldId); 28 | 29 | if (!worldId) { 30 | return null; 31 | } 32 | return ( 33 | <> 34 | {SHOW_DEBUG_UI && } 35 |
36 | {/* Game area */} 37 |
38 |
39 |
40 | 41 | {/* Re-propagate context because contexts are not shared between renderers. 42 | https://github.com/michalochman/react-pixi-fiber/issues/145#issuecomment-531549215 */} 43 | 44 | 51 | 52 | 53 |
54 |
55 |
56 | {/* Right column area */} 57 |
58 | 63 |
64 |
65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useMutation, useQuery } from 'convex/react'; 3 | import { KeyboardEvent, useRef, useState } from 'react'; 4 | import { api } from '../../convex/_generated/api'; 5 | import { Doc, Id } from '../../convex/_generated/dataModel'; 6 | import { toastOnError } from '../toasts'; 7 | 8 | export function MessageInput({ 9 | humanPlayer, 10 | conversation, 11 | }: { 12 | humanPlayer: Doc<'players'>; 13 | conversation: Doc<'conversations'>; 14 | }) { 15 | const inputRef = useRef(null); 16 | const [inflight, setInflight] = useState(0); 17 | const writeMessage = useMutation(api.messages.writeMessage); 18 | const startTyping = useMutation(api.messages.startTyping); 19 | const currentlyTyping = useQuery(api.messages.currentlyTyping, { 20 | conversationId: conversation._id, 21 | }); 22 | 23 | const onKeyDown = async (e: KeyboardEvent) => { 24 | e.stopPropagation(); 25 | // Send the current message. 26 | if (e.key === 'Enter') { 27 | e.preventDefault(); 28 | if (!inputRef.current) { 29 | return; 30 | } 31 | const text = inputRef.current.innerText; 32 | inputRef.current.innerText = ''; 33 | await writeMessage({ 34 | playerId: humanPlayer._id, 35 | conversationId: conversation._id, 36 | text, 37 | }); 38 | return; 39 | } 40 | // Try to set a typing indicator. 41 | else { 42 | if (currentlyTyping || inflight > 0) { 43 | return; 44 | } 45 | setInflight((i) => i + 1); 46 | try { 47 | // Don't show a toast on error. 48 | startTyping({ 49 | playerId: humanPlayer._id, 50 | conversationId: conversation._id, 51 | }); 52 | } finally { 53 | setInflight((i) => i - 1); 54 | } 55 | } 56 | }; 57 | return ( 58 |
59 |
60 | {humanPlayer.name} 61 |
62 |
63 |

onKeyDown(e)} 71 | /> 72 |

73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/Messages.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { Doc, Id } from '../../convex/_generated/dataModel'; 3 | import { useQuery } from 'convex/react'; 4 | import { api } from '../../convex/_generated/api'; 5 | import { MessageInput } from './MessageInput'; 6 | 7 | export function Messages({ 8 | worldId, 9 | conversation, 10 | inConversationWithMe, 11 | humanPlayer, 12 | }: { 13 | worldId: Id<'worlds'>; 14 | conversation: Doc<'conversations'>; 15 | inConversationWithMe: boolean; 16 | humanPlayer?: Doc<'players'>; 17 | }) { 18 | const humanPlayerId = humanPlayer?._id; 19 | const messages = useQuery(api.messages.listMessages, { conversationId: conversation._id }); 20 | const currentlyTyping = useQuery(api.messages.currentlyTyping, { 21 | conversationId: conversation._id, 22 | }); 23 | const members = useQuery(api.world.conversationMembers, { conversationId: conversation._id }); 24 | 25 | if (messages === undefined || currentlyTyping === undefined || members === undefined) { 26 | return null; 27 | } 28 | if (messages.length === 0 && !inConversationWithMe) { 29 | return null; 30 | } 31 | const messageNodes: { time: number; node: React.ReactNode }[] = messages.map((m) => { 32 | const node = ( 33 |
34 |
35 | {m.authorName} 36 | 39 |
40 |
41 |

{m.text}

42 |
43 |
44 | ); 45 | return { node, time: m._creationTime }; 46 | }); 47 | const lastMessageTs = messages.map((m) => m._creationTime).reduce((a, b) => Math.max(a, b), 0); 48 | 49 | const membershipNodes: typeof messageNodes = members.flatMap((m) => { 50 | let started; 51 | if (m.status.kind === 'participating' || m.status.kind === 'left') { 52 | started = m.status.started; 53 | } 54 | const ended = m.status.kind === 'left' ? m.status.ended : undefined; 55 | const out = []; 56 | if (started) { 57 | out.push({ 58 | node: ( 59 |
60 |

{m.playerName} joined the conversation.

61 |
62 | ), 63 | time: started, 64 | }); 65 | } 66 | if (ended) { 67 | out.push({ 68 | node: ( 69 |
70 |

{m.playerName} left the conversation.

71 |
72 | ), 73 | // Always sort all "left" messages after the last message. 74 | // TODO: We can remove this once we want to support more than two participants per conversation. 75 | time: Math.max(lastMessageTs + 1, ended), 76 | }); 77 | } 78 | return out; 79 | }); 80 | const nodes = [...messageNodes, ...membershipNodes]; 81 | nodes.sort((a, b) => a.time - b.time); 82 | return ( 83 |
84 |
85 | {nodes.length > 0 && nodes.map((n) => n.node)} 86 | {currentlyTyping && currentlyTyping.playerId !== humanPlayerId && ( 87 |
88 |
89 | {currentlyTyping.playerName} 90 | 93 |
94 |
95 |

96 | typing... 97 |

98 |
99 |
100 | )} 101 | {humanPlayer && inConversationWithMe && !conversation.finished && ( 102 | 103 | )} 104 |
105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/components/PixiGame.tsx: -------------------------------------------------------------------------------- 1 | import { useApp, useTick } from '@pixi/react'; 2 | import { Player, SelectElement } from './Player.tsx'; 3 | import { useRef, useState } from 'react'; 4 | import { PixiStaticMap } from './PixiStaticMap.tsx'; 5 | import PixiViewport from './PixiViewport.tsx'; 6 | import { Viewport } from 'pixi-viewport'; 7 | import { Id } from '../../convex/_generated/dataModel'; 8 | import { useQuery } from 'convex/react'; 9 | import { api } from '../../convex/_generated/api.js'; 10 | import { useSendInput } from '../hooks/sendInput.ts'; 11 | import { toastOnError } from '../toasts.ts'; 12 | import { DebugPath } from './DebugPath.tsx'; 13 | import { PositionIndicator } from './PositionIndicator.tsx'; 14 | import { SHOW_DEBUG_UI } from './Game.tsx'; 15 | 16 | export const PixiGame = (props: { 17 | worldId: Id<'worlds'>; 18 | historicalTime: number | undefined; 19 | width: number; 20 | height: number; 21 | setSelectedElement: SelectElement; 22 | }) => { 23 | // PIXI setup. 24 | const pixiApp = useApp(); 25 | const viewportRef = useRef(); 26 | 27 | const world = useQuery(api.world.defaultWorld); 28 | 29 | const humanPlayerId = useQuery(api.world.userStatus, { worldId: props.worldId }) ?? null; 30 | const players = useQuery(api.world.activePlayers, { worldId: props.worldId }) ?? []; 31 | const moveTo = useSendInput(props.worldId, 'moveTo'); 32 | 33 | // Interaction for clicking on the world to navigate. 34 | const dragStart = useRef<{ screenX: number; screenY: number } | null>(null); 35 | const onMapPointerDown = (e: any) => { 36 | // https://pixijs.download/dev/docs/PIXI.FederatedPointerEvent.html 37 | dragStart.current = { screenX: e.screenX, screenY: e.screenY }; 38 | }; 39 | 40 | const [lastDestination, setLastDestination] = useState<{ 41 | x: number; 42 | y: number; 43 | t: number; 44 | } | null>(null); 45 | const onMapPointerUp = async (e: any) => { 46 | if (dragStart.current) { 47 | const { screenX, screenY } = dragStart.current; 48 | dragStart.current = null; 49 | const [dx, dy] = [screenX - e.screenX, screenY - e.screenY]; 50 | const dist = Math.sqrt(dx * dx + dy * dy); 51 | if (dist > 10) { 52 | console.log(`Skipping navigation on drag event (${dist}px)`); 53 | return; 54 | } 55 | } 56 | if (!humanPlayerId) { 57 | return; 58 | } 59 | const viewport = viewportRef.current; 60 | if (!viewport || !world) { 61 | return; 62 | } 63 | const gameSpacePx = viewport.toWorld(e.screenX, e.screenY); 64 | const gameSpaceTiles = { 65 | x: gameSpacePx.x / world.map.tileDim, 66 | y: gameSpacePx.y / world.map.tileDim, 67 | }; 68 | setLastDestination({ t: Date.now(), ...gameSpaceTiles }); 69 | const roundedTiles = { 70 | x: Math.floor(gameSpaceTiles.x), 71 | y: Math.floor(gameSpaceTiles.y), 72 | }; 73 | console.log(`Moving to ${JSON.stringify(roundedTiles)}`); 74 | await toastOnError(moveTo({ playerId: humanPlayerId, destination: roundedTiles })); 75 | }; 76 | if (!world) { 77 | return null; 78 | } 79 | return ( 80 | 88 | 93 | {players.map( 94 | (p) => 95 | // Only show the path for the human player in non-debug mode. 96 | (SHOW_DEBUG_UI || p._id === humanPlayerId) && ( 97 | 98 | ), 99 | )} 100 | {lastDestination && ( 101 | 102 | )} 103 | {players.map((p) => ( 104 | 111 | ))} 112 | 113 | ); 114 | }; 115 | export default PixiGame; 116 | -------------------------------------------------------------------------------- /src/components/PixiStaticMap.tsx: -------------------------------------------------------------------------------- 1 | import { PixiComponent, applyDefaultProps } from '@pixi/react'; 2 | import * as PIXI from 'pixi.js'; 3 | 4 | export const PixiStaticMap = PixiComponent('StaticMap', { 5 | create: (props: any) => { 6 | const map = props.map; 7 | const numytiles = map.tileSetDim / map.tileDim; 8 | const bt = PIXI.BaseTexture.from(map.tileSetUrl, { 9 | scaleMode: PIXI.SCALE_MODES.NEAREST, 10 | }); 11 | 12 | const tiles = []; 13 | for (let x = 0; x < numytiles; x++) { 14 | for (let y = 0; y < numytiles; y++) { 15 | tiles[x + y * numytiles] = new PIXI.Texture( 16 | bt, 17 | new PIXI.Rectangle(x * map.tileDim, y * map.tileDim, map.tileDim, map.tileDim), 18 | ); 19 | } 20 | } 21 | const screenytiles = map.bgTiles[0].length; 22 | const screenxtiles = map.bgTiles[0][0].length; 23 | 24 | const container = new PIXI.Container(); 25 | 26 | // blit bg & object layers of map onto canvas 27 | for (let i = 0; i < screenxtiles * screenytiles; i++) { 28 | const x = i % screenxtiles; 29 | const y = Math.floor(i / screenxtiles); 30 | const xPx = x * map.tileDim; 31 | const yPx = y * map.tileDim; 32 | 33 | // Add all layers of backgrounds. 34 | for (let z = 0; z < map.bgTiles.length; z++) { 35 | const tileIndex = map.bgTiles[z][y][x]; 36 | // Some layers may not have tiles at this location. 37 | if (tileIndex === -1) continue; 38 | const ctile = new PIXI.Sprite(tiles[tileIndex]); 39 | ctile.x = xPx; 40 | ctile.y = yPx; 41 | container.addChild(ctile); 42 | } 43 | const l1tile = map.objectTiles[y][x]; 44 | if (l1tile != -1) { 45 | const ctile = new PIXI.Sprite(tiles[l1tile]); 46 | ctile.x = xPx; 47 | ctile.y = yPx; 48 | container.addChild(ctile); 49 | } 50 | } 51 | 52 | container.x = 0; 53 | container.y = 0; 54 | 55 | // Set the hit area manually to ensure `pointerdown` events are delivered to this container. 56 | container.interactive = true; 57 | container.hitArea = new PIXI.Rectangle( 58 | 0, 59 | 0, 60 | screenxtiles * map.tileDim, 61 | screenytiles * map.tileDim, 62 | ); 63 | 64 | return container; 65 | }, 66 | 67 | applyProps: (instance, oldProps, newProps) => { 68 | applyDefaultProps(instance, oldProps, newProps); 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /src/components/PixiViewport.tsx: -------------------------------------------------------------------------------- 1 | // Based on https://codepen.io/inlet/pen/yLVmPWv. 2 | // Copyright (c) 2018 Patrick Brouwer, distributed under the MIT license. 3 | 4 | import { PixiComponent, useApp } from '@pixi/react'; 5 | import { Viewport } from 'pixi-viewport'; 6 | import { Application } from 'pixi.js'; 7 | import { MutableRefObject, ReactNode } from 'react'; 8 | 9 | export type ViewportProps = { 10 | app: Application; 11 | viewportRef?: MutableRefObject; 12 | 13 | screenWidth: number; 14 | screenHeight: number; 15 | worldWidth: number; 16 | worldHeight: number; 17 | children?: ReactNode; 18 | }; 19 | 20 | // https://davidfig.github.io/pixi-viewport/jsdoc/Viewport.html 21 | export default PixiComponent('Viewport', { 22 | create(props: ViewportProps) { 23 | const { app, children, viewportRef, ...viewportProps } = props; 24 | const viewport = new Viewport({ 25 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 26 | events: app.renderer.events, 27 | passiveWheel: false, 28 | ...viewportProps, 29 | }); 30 | if (viewportRef) { 31 | viewportRef.current = viewport; 32 | } 33 | // Activate plugins 34 | viewport 35 | .drag() 36 | .pinch({}) 37 | .wheel() 38 | .decelerate() 39 | .clamp({ direction: 'all', underflow: 'center' }) 40 | .setZoom(1.5) 41 | .clampZoom({ 42 | minScale: (1.04 * props.screenWidth) / (props.worldWidth / 2), 43 | maxScale: 3.0, 44 | }); 45 | return viewport; 46 | }, 47 | applyProps(viewport, oldProps: any, newProps: any) { 48 | Object.keys(newProps).forEach((p) => { 49 | if (p !== 'app' && p !== 'viewportRef' && p !== 'children' && oldProps[p] !== newProps[p]) { 50 | // @ts-expect-error Ignoring TypeScript here 51 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 52 | viewport[p] = newProps[p]; 53 | } 54 | }); 55 | }, 56 | }); 57 | -------------------------------------------------------------------------------- /src/components/Player.tsx: -------------------------------------------------------------------------------- 1 | import { Doc, Id } from '../../convex/_generated/dataModel'; 2 | import { Character } from './Character.tsx'; 3 | import { orientationDegrees } from '../../convex/util/geometry.ts'; 4 | import { characters } from '../../data/characters.ts'; 5 | import { toast } from 'react-toastify'; 6 | import { useHistoricalValue } from '../hooks/useHistoricalValue.ts'; 7 | import { useQuery } from 'convex/react'; 8 | import { api } from '../../convex/_generated/api'; 9 | import { PlayerMetadata } from '../../convex/world.ts'; 10 | import { DebugPath } from './DebugPath.tsx'; 11 | 12 | export type SelectElement = (element?: { kind: 'player'; id: Id<'players'> }) => void; 13 | 14 | const logged = new Set(); 15 | 16 | export const Player = ({ 17 | isViewer, 18 | player, 19 | onClick, 20 | historicalTime, 21 | }: { 22 | isViewer: boolean; 23 | player: PlayerMetadata; 24 | onClick: SelectElement; 25 | historicalTime?: number; 26 | }) => { 27 | const world = useQuery(api.world.defaultWorld); 28 | const character = characters.find((c) => c.name === player.character); 29 | const location = useHistoricalValue<'locations'>(historicalTime, player.location); 30 | if (!character) { 31 | if (!logged.has(player.character)) { 32 | logged.add(player.character); 33 | toast.error(`Unknown character ${player.character}`); 34 | } 35 | return; 36 | } 37 | if (!world) { 38 | return; 39 | } 40 | if (!location) { 41 | return; 42 | } 43 | const tileDim = world.map.tileDim; 44 | return ( 45 | <> 46 | 0} 51 | isThinking={player.isThinking} 52 | isSpeaking={player.isSpeaking} 53 | isViewer={isViewer} 54 | textureUrl={character.textureUrl} 55 | spritesheetData={character.spritesheetData} 56 | speed={character.speed} 57 | onClick={() => { 58 | onClick({ kind: 'player', id: player._id }); 59 | }} 60 | /> 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/PositionIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { Graphics } from '@pixi/react'; 3 | import { Graphics as PixiGraphics } from 'pixi.js'; 4 | 5 | const ANIMATION_DURATION = 500; 6 | const RADIUS_TILES = 0.25; 7 | 8 | export function PositionIndicator(props: { 9 | destination: { x: number; y: number; t: number }; 10 | tileDim: number; 11 | }) { 12 | const { destination, tileDim } = props; 13 | const draw = (g: PixiGraphics) => { 14 | g.clear(); 15 | const now = Date.now(); 16 | if (destination.t + ANIMATION_DURATION <= now) { 17 | return; 18 | } 19 | const progress = (now - destination.t) / ANIMATION_DURATION; 20 | const x = destination.x * tileDim; 21 | const y = destination.y * tileDim; 22 | g.lineStyle(1.5, { h: 0, s: 50, l: 90 }, 0.5); 23 | g.drawCircle(x, y, RADIUS_TILES * progress * tileDim); 24 | }; 25 | return ; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/buttons/Button.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler, ReactNode } from 'react'; 2 | 3 | export default function Button(props: { 4 | href?: string; 5 | imgUrl: string; 6 | onClick?: MouseEventHandler; 7 | title?: string; 8 | children: ReactNode; 9 | }) { 10 | return ( 11 | 17 |
18 | 19 |
20 | 21 | {props.children} 22 |
23 |
24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/buttons/InteractButton.tsx: -------------------------------------------------------------------------------- 1 | import Button from './Button'; 2 | import interactImg from '../../../assets/interact.svg'; 3 | import { useConvexAuth, useMutation, useQuery } from 'convex/react'; 4 | import { api } from '../../../convex/_generated/api'; 5 | 6 | export default function InteractButton() { 7 | const { isAuthenticated } = useConvexAuth(); 8 | const world = useQuery(api.world.defaultWorld); 9 | const userPlayerId = useQuery(api.world.userStatus, world ? { worldId: world._id } : 'skip'); 10 | const join = useMutation(api.world.joinWorld); 11 | const leave = useMutation(api.world.leaveWorld); 12 | const isPlaying = !!userPlayerId; 13 | 14 | const joinOrLeaveGame = () => { 15 | if (!world || !isAuthenticated || userPlayerId === undefined) { 16 | return; 17 | } 18 | if (isPlaying) { 19 | console.log(`Leaving game for player ${userPlayerId}`); 20 | void leave({ worldId: world._id }); 21 | } else { 22 | console.log(`Joining game`); 23 | void join({ worldId: world._id }); 24 | } 25 | }; 26 | if (!isAuthenticated || userPlayerId === undefined) { 27 | return null; 28 | } 29 | return ( 30 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/buttons/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { SignInButton } from '@clerk/clerk-react'; 2 | 3 | export default function LoginButton() { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/buttons/MusicButton.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import volumeImg from '../../../assets/volume.svg'; 3 | import { sound } from '@pixi/sound'; 4 | import Button from './Button'; 5 | import { useQuery } from 'convex/react'; 6 | import { api } from '../../../convex/_generated/api'; 7 | 8 | export default function MusicButton() { 9 | const musicUrl = useQuery(api.music.getBackgroundMusic); 10 | const [isPlaying, setPlaying] = useState(false); 11 | 12 | useEffect(() => { 13 | if (musicUrl) { 14 | sound.add('background', musicUrl).loop = true; 15 | } 16 | }, [musicUrl]); 17 | 18 | const flipSwitch = async () => { 19 | if (isPlaying) { 20 | sound.stop('background'); 21 | } else { 22 | await sound.play('background'); 23 | } 24 | setPlaying(!isPlaying); 25 | }; 26 | 27 | const handleKeyPress = useCallback( 28 | (event: { key: string }) => { 29 | if (event.key === 'm' || event.key === 'M') { 30 | void flipSwitch(); 31 | } 32 | }, 33 | [flipSwitch], 34 | ); 35 | 36 | useEffect(() => { 37 | window.addEventListener('keydown', handleKeyPress); 38 | return () => window.removeEventListener('keydown', handleKeyPress); 39 | }, [handleKeyPress]); 40 | 41 | return ( 42 | <> 43 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/sendInput.ts: -------------------------------------------------------------------------------- 1 | import { useConvex } from 'convex/react'; 2 | import { InputArgs, InputReturnValue, Inputs } from '../../convex/game/inputs'; 3 | import { api } from '../../convex/_generated/api'; 4 | import { Id } from '../../convex/_generated/dataModel'; 5 | 6 | export function useSendInput( 7 | worldId: Id<'worlds'>, 8 | name: Name, 9 | ): (args: InputArgs) => Promise> { 10 | const convex = useConvex(); 11 | return async (args) => { 12 | const inputId = await convex.mutation(api.world.sendWorldInput, { worldId, name, args }); 13 | const watch = convex.watchQuery(api.game.main.inputStatus, { inputId }); 14 | let result = watch.localQueryResult(); 15 | // The result's undefined if the query's loading and null if the input hasn't 16 | // been processed yet. 17 | if (result === undefined || result === null) { 18 | let dispose: undefined | (() => void); 19 | try { 20 | await new Promise((resolve, reject) => { 21 | dispose = watch.onUpdate(() => { 22 | try { 23 | result = watch.localQueryResult(); 24 | } catch (e: any) { 25 | reject(e); 26 | return; 27 | } 28 | if (result !== undefined && result !== null) { 29 | resolve(); 30 | } 31 | }); 32 | }); 33 | } finally { 34 | if (dispose) { 35 | dispose(); 36 | } 37 | } 38 | } 39 | if (!result) { 40 | throw new Error(`Input ${inputId} was never processed.`); 41 | } 42 | if (result.kind === 'error') { 43 | throw new Error(result.message); 44 | } 45 | return result.value; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/hooks/useHistoricalTime.ts: -------------------------------------------------------------------------------- 1 | import { Doc, Id } from '../../convex/_generated/dataModel'; 2 | import { useQuery } from 'convex/react'; 3 | import { api } from '../../convex/_generated/api'; 4 | import { useEffect, useRef, useState } from 'react'; 5 | 6 | export function useHistoricalTime(worldId?: Id<'worlds'>) { 7 | const engineStatus = useQuery(api.world.engineStatus, worldId ? { worldId } : 'skip'); 8 | const timeManager = useRef(new HistoricalTimeManager()); 9 | const rafRef = useRef(); 10 | const [historicalTime, setHistoricalTime] = useState(undefined); 11 | const [bufferHealth, setBufferHealth] = useState(0); 12 | if (engineStatus) { 13 | timeManager.current.receive(engineStatus); 14 | } 15 | const updateTime = (performanceNow: number) => { 16 | // We don't need sub-millisecond precision for interpolation, so just use `Date.now()`. 17 | const now = Date.now(); 18 | setHistoricalTime(timeManager.current.historicalServerTime(now)); 19 | setBufferHealth(timeManager.current.bufferHealth()); 20 | rafRef.current = requestAnimationFrame(updateTime); 21 | }; 22 | useEffect(() => { 23 | rafRef.current = requestAnimationFrame(updateTime); 24 | return () => cancelAnimationFrame(rafRef.current!); 25 | }, []); 26 | return { historicalTime, timeManager: timeManager.current }; 27 | } 28 | 29 | type ServerTimeInterval = { 30 | startTs: number; 31 | endTs: number; 32 | }; 33 | 34 | export class HistoricalTimeManager { 35 | intervals: Array = []; 36 | prevClientTs?: number; 37 | prevServerTs?: number; 38 | totalDuration: number = 0; 39 | 40 | latestEngineStatus?: Doc<'engines'>; 41 | 42 | receive(engineStatus: Doc<'engines'>) { 43 | this.latestEngineStatus = engineStatus; 44 | if (!engineStatus.currentTime || !engineStatus.lastStepTs) { 45 | return; 46 | } 47 | const latest = this.intervals[this.intervals.length - 1]; 48 | if (latest) { 49 | if (latest.endTs === engineStatus.currentTime) { 50 | return; 51 | } 52 | if (latest.endTs > engineStatus.currentTime) { 53 | throw new Error(`Received out-of-order engine status`); 54 | } 55 | } 56 | const newInterval = { 57 | startTs: engineStatus.lastStepTs, 58 | endTs: engineStatus.currentTime, 59 | }; 60 | this.intervals.push(newInterval); 61 | this.totalDuration += newInterval.endTs - newInterval.startTs; 62 | } 63 | 64 | historicalServerTime(clientNow: number): number | undefined { 65 | if (this.intervals.length == 0) { 66 | return undefined; 67 | } 68 | if (clientNow === this.prevClientTs) { 69 | return this.prevServerTs; 70 | } 71 | // If this is our first time simulating, start at the beginning of the buffer. 72 | const prevClientTs = this.prevClientTs ?? clientNow; 73 | const prevServerTs = this.prevServerTs ?? this.intervals[0].startTs; 74 | const lastServerTs = this.intervals[this.intervals.length - 1].endTs; 75 | 76 | // Simple rate adjustment: run time at 1.2 speed if we're more than 1s behind and 77 | // 0.8 speed if we only have 100ms of buffer left. A more sophisticated approach 78 | // would be to continuously adjust the rate based on the size of the buffer. 79 | const bufferDuration = lastServerTs - prevServerTs; 80 | let rate = 1; 81 | if (bufferDuration < SOFT_MIN_SERVER_BUFFER_AGE) { 82 | rate = 0.8; 83 | } else if (bufferDuration > SOFT_MAX_SERVER_BUFFER_AGE) { 84 | rate = 1.2; 85 | } 86 | let serverTs = Math.max( 87 | prevServerTs + (clientNow - prevClientTs) * rate, 88 | // Jump forward if we're too far behind. 89 | lastServerTs - MAX_SERVER_BUFFER_AGE, 90 | ); 91 | 92 | let chosen = null; 93 | for (let i = 0; i < this.intervals.length; i++) { 94 | const snapshot = this.intervals[i]; 95 | // We're past this snapshot, continue to the next one. 96 | if (snapshot.endTs < serverTs) { 97 | continue; 98 | } 99 | // We're cleanly within this snapshot. 100 | if (serverTs >= snapshot.startTs) { 101 | chosen = i; 102 | break; 103 | } 104 | // We've gone past the desired timestamp, which implies a gap in our server state. 105 | // Jump time forward to the beginning of this snapshot. 106 | if (serverTs < snapshot.startTs) { 107 | serverTs = snapshot.startTs; 108 | chosen = i; 109 | } 110 | } 111 | if (chosen === null) { 112 | serverTs = this.intervals.at(-1)!.endTs; 113 | chosen = this.intervals.length - 1; 114 | } 115 | // Time only moves forward, so we can trim all of the snapshots before our chosen one. 116 | const toTrim = Math.max(chosen - 1, 0); 117 | if (toTrim > 0) { 118 | for (const snapshot of this.intervals.slice(0, toTrim)) { 119 | this.totalDuration -= snapshot.endTs - snapshot.startTs; 120 | } 121 | this.intervals = this.intervals.slice(toTrim); 122 | } 123 | 124 | this.prevClientTs = clientNow; 125 | this.prevServerTs = serverTs; 126 | 127 | return serverTs; 128 | } 129 | 130 | bufferHealth(): number { 131 | if (!this.intervals.length) { 132 | return 0; 133 | } 134 | const lastServerTs = this.prevServerTs ?? this.intervals[0].startTs; 135 | return this.intervals[this.intervals.length - 1].endTs - lastServerTs; 136 | } 137 | 138 | clockSkew(): number { 139 | if (!this.prevClientTs || !this.prevServerTs) { 140 | return 0; 141 | } 142 | return this.prevClientTs - this.prevServerTs; 143 | } 144 | } 145 | 146 | const MAX_SERVER_BUFFER_AGE = 1250; 147 | const SOFT_MAX_SERVER_BUFFER_AGE = 1000; 148 | const SOFT_MIN_SERVER_BUFFER_AGE = 100; 149 | -------------------------------------------------------------------------------- /src/hooks/useHistoricalValue.ts: -------------------------------------------------------------------------------- 1 | import { WithoutSystemFields } from 'convex/server'; 2 | import { Doc, TableNames } from '../../convex/_generated/dataModel'; 3 | import { History, unpackSampleRecord } from '../../convex/engine/historicalTable'; 4 | import { useMemo, useRef } from 'react'; 5 | 6 | export function useHistoricalValue( 7 | historicalTime: number | undefined, 8 | value: Doc | undefined, 9 | ): WithoutSystemFields> | undefined { 10 | const manager = useRef(new HistoryManager()); 11 | const sampleRecord: Record | undefined = useMemo(() => { 12 | if (!value || !value.history) { 13 | return undefined; 14 | } 15 | if (!(value.history instanceof ArrayBuffer)) { 16 | throw new Error(`Expected ArrayBuffer, found ${typeof value.history}`); 17 | } 18 | return unpackSampleRecord(value.history as ArrayBuffer); 19 | }, [value && value.history]); 20 | if (sampleRecord) { 21 | manager.current.receive(sampleRecord); 22 | } 23 | if (value === undefined) { 24 | return undefined; 25 | } 26 | const { _id, _creationTime, history, ...latest } = value; 27 | if (!historicalTime) { 28 | return latest as any; 29 | } 30 | const historicalFields = manager.current.query(historicalTime); 31 | for (const [fieldName, value] of Object.entries(historicalFields)) { 32 | (latest as any)[fieldName] = value; 33 | } 34 | return latest as any; 35 | } 36 | 37 | class HistoryManager { 38 | histories: Record = {}; 39 | 40 | receive(sampleRecord: Record) { 41 | for (const [fieldName, history] of Object.entries(sampleRecord)) { 42 | let histories = this.histories[fieldName]; 43 | if (!histories) { 44 | histories = []; 45 | this.histories[fieldName] = histories; 46 | } 47 | if (histories[histories.length - 1] == history) { 48 | continue; 49 | } 50 | histories.push(history); 51 | } 52 | } 53 | 54 | query(historicalTime: number): Record { 55 | const result: Record = {}; 56 | for (const [fieldName, histories] of Object.entries(this.histories)) { 57 | if (histories.length == 0) { 58 | continue; 59 | } 60 | let foundIndex = null; 61 | let currentValue = histories[0].initialValue; 62 | for (let i = 0; i < histories.length; i++) { 63 | const history = histories[i]; 64 | for (const sample of history.samples) { 65 | if (sample.time > historicalTime) { 66 | foundIndex = i; 67 | break; 68 | } 69 | currentValue = sample.value; 70 | } 71 | if (foundIndex !== null) { 72 | break; 73 | } 74 | } 75 | if (foundIndex !== null) { 76 | this.histories[fieldName] = histories.slice(foundIndex); 77 | } 78 | result[fieldName] = currentValue; 79 | } 80 | return result; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/hooks/useWorldHeartbeat.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from 'convex/react'; 2 | import { Id } from '../../convex/_generated/dataModel'; 3 | import { useEffect } from 'react'; 4 | import { api } from '../../convex/_generated/api'; 5 | import { WORLD_HEARTBEAT_INTERVAL } from '../../convex/constants'; 6 | 7 | export function useWorldHeartbeat(worldId?: Id<'worlds'>) { 8 | // Send a periodic heartbeat to our world to keep it alive. 9 | const heartbeat = useMutation(api.world.heartbeatWorld); 10 | useEffect(() => { 11 | worldId && heartbeat({ worldId }); 12 | const id = setInterval(() => { 13 | worldId && heartbeat({ worldId }); 14 | }, WORLD_HEARTBEAT_INTERVAL); 15 | return () => clearInterval(id); 16 | }, [worldId, heartbeat]); 17 | } 18 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: 'Upheaval Pro'; 7 | src: url(/assets/fonts/upheaval_pro.ttf); 8 | } 9 | 10 | @font-face { 11 | font-family: 'VCR OSD Mono'; 12 | src: url(/assets/fonts/vcr_osd_mono.ttf); 13 | } 14 | 15 | .font-display { 16 | font-family: 'Upheaval Pro', 'sans-serif'; 17 | } 18 | 19 | .font-body { 20 | font-family: 'VCR OSD Mono', 'monospace'; 21 | } 22 | 23 | :root { 24 | --foreground-rgb: 0, 0, 0; 25 | --background-start-rgb: 214, 219, 220; 26 | --background-end-rgb: 255, 255, 255; 27 | } 28 | 29 | @media (prefers-color-scheme: dark) { 30 | :root { 31 | --foreground-rgb: 255, 255, 255; 32 | --background-start-rgb: 0, 0, 0; 33 | --background-end-rgb: 0, 0, 0; 34 | } 35 | } 36 | 37 | body { 38 | color: rgb(var(--foreground-rgb)); 39 | background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) 40 | rgb(var(--background-start-rgb)); 41 | } 42 | 43 | .game-background { 44 | background: linear-gradient(rgba(41, 41, 41, 0.8), rgba(41, 41, 41, 0.8)), 45 | url(../assets/background.webp); 46 | background-blend-mode: hard-light; 47 | background-position: center; 48 | background-repeat: no-repeat; 49 | background-size: cover; 50 | background-attachment: fixed; 51 | } 52 | 53 | .game-title { 54 | background: linear-gradient(to bottom, #fec742, #dd7c42); 55 | background-clip: text; 56 | -webkit-background-clip: text; 57 | -webkit-text-fill-color: transparent; 58 | filter: drop-shadow(0px 0.08em 0px #6e2146); 59 | } 60 | 61 | .game-frame { 62 | border-width: 36px; 63 | border-image-source: url(../assets/ui/frame.svg); 64 | border-image-repeat: stretch; 65 | border-image-slice: 25%; 66 | } 67 | 68 | .game-progress-bar { 69 | border: 5px solid rgb(23, 20, 33); 70 | } 71 | 72 | @keyframes moveStripes { 73 | to { 74 | background-position: calc(100% + 28px) 0; 75 | } 76 | } 77 | 78 | .game-progress-bar-progress { 79 | background: repeating-linear-gradient(135deg, white, white 10px, #dfdfdf 10px, #dfdfdf 20px); 80 | background-size: 200% 100%; 81 | background-position: 100% 0; 82 | animation: moveStripes 0.5s linear infinite; 83 | } 84 | 85 | @media screen and (min-width: 640px) { 86 | .game-frame { 87 | border-width: 48px; 88 | } 89 | } 90 | 91 | .shadow-solid { 92 | text-shadow: 0 0.1em 0 rgba(0, 0, 0, 0.5); 93 | } 94 | 95 | .bubble { 96 | border-width: 30px; 97 | border-image-source: url(../assets/ui/bubble-left.svg); 98 | border-image-repeat: stretch; 99 | border-image-slice: 20%; 100 | } 101 | 102 | .bubble-mine { 103 | border-image-source: url(../assets/ui/bubble-right.svg); 104 | } 105 | 106 | .box { 107 | border-width: 12px; 108 | border-image-source: url(../assets/ui/box.svg); 109 | border-image-repeat: stretch; 110 | border-image-slice: 12.5%; 111 | } 112 | 113 | .desc { 114 | border-width: 56px; 115 | border-image-source: url(../assets/ui/desc.svg); 116 | border-image-repeat: stretch; 117 | border-image-slice: 28%; 118 | } 119 | 120 | .chats { 121 | border-width: 24px; 122 | border-image-source: url(../assets/ui/chats.svg); 123 | border-image-repeat: stretch; 124 | border-image-slice: 40%; 125 | } 126 | 127 | .login-prompt { 128 | border-width: 48px; 129 | border-image-source: url(../assets/ui/jewel_box.svg); 130 | border-image-repeat: stretch; 131 | border-image-slice: 40%; 132 | } 133 | 134 | .button { 135 | border-width: 1em; 136 | border-image-source: url(../assets/ui/button.svg); 137 | border-image-repeat: stretch; 138 | border-image-slice: 25%; 139 | } 140 | 141 | .button span { 142 | display: inline-block; 143 | transform: translateY(-15%); 144 | } 145 | 146 | .button:active { 147 | /* Inlining this image to avoid flashing during loading */ 148 | border-image-source: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='2' width='14' height='13' fill='%23181425'/%3E%3Crect x='2' y='1' width='12' height='15' fill='%23181425'/%3E%3Crect y='3' width='16' height='11' fill='%23181425'/%3E%3Crect x='2' y='14' width='12' height='1' fill='%23262B44'/%3E%3Crect x='1' y='3' width='14' height='11' fill='%233A4466'/%3E%3Crect x='2' y='2' width='12' height='9' fill='%233A4466'/%3E%3Crect x='1' y='13' width='1' height='1' fill='%23262B44'/%3E%3Crect x='14' y='13' width='1' height='1' fill='%23262B44'/%3E%3C/svg%3E%0A"); 149 | } 150 | 151 | .button:active span { 152 | transform: none; 153 | } 154 | 155 | p[contenteditable='true']:empty::before { 156 | content: attr(placeholder); 157 | color: #aaa; 158 | } 159 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import Home from './App.tsx'; 4 | import './index.css'; 5 | import 'uplot/dist/uPlot.min.css'; 6 | import 'react-toastify/dist/ReactToastify.css'; 7 | import ConvexClientProvider from './components/ConvexClientProvider.tsx'; 8 | 9 | ReactDOM.createRoot(document.getElementById('root')!).render( 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /src/toasts.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify'; 2 | 3 | export async function toastOnError(promise: Promise): Promise { 4 | try { 5 | return await promise; 6 | } catch (error: any) { 7 | toast.error(error.message); 8 | throw error; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | fontFamily: { 6 | display: ['var(--font-display)', 'sans-serif'], 7 | body: ['var(--font-body)', 'monospace'], 8 | }, 9 | extend: { 10 | colors: { 11 | brown: { 12 | 100: '#FFFFFF', 13 | 200: '#EAD4AA', 14 | 300: '#E4A672', 15 | 500: '#B86F50', 16 | 700: '#743F39', 17 | 800: '#3F2832', 18 | 900: '#181425', 19 | }, 20 | clay: { 21 | 100: '#C0CBDC', 22 | 300: '#8B9BB4', 23 | 500: '#5A6988', 24 | 700: '#3A4466', 25 | 900: '#181425', 26 | }, 27 | }, 28 | }, 29 | }, 30 | plugins: [require('@tailwindcss/forms')], 31 | }; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "allowImportingTsExtensions": true, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "paths": { 24 | "@/*": ["./src/*"] 25 | } 26 | }, 27 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "postcss.config.js"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "vite", 3 | "rewrites": [ 4 | { 5 | "source": "/ai-town/:match*", 6 | "destination": "/:match*" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: '/ai-town', 7 | plugins: [react()], 8 | }); 9 | --------------------------------------------------------------------------------