# Directory Structure
```
├── .github
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .npmrc
├── cli.mjs
├── eslint.config.mjs
├── extension
│ ├── images
│ │ ├── icon128.png
│ │ ├── icon16.png
│ │ └── icon48.png
│ ├── isolated.js
│ ├── main.js
│ ├── manifest.json
│ ├── popup.css
│ ├── popup.html
│ └── popup.js
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── release.sh
├── src
│ ├── server.ts
│ ├── tools
│ │ ├── asset.ts
│ │ ├── assets
│ │ │ ├── material.ts
│ │ │ └── script.ts
│ │ ├── entity.ts
│ │ ├── scene.ts
│ │ ├── schema
│ │ │ ├── asset.ts
│ │ │ ├── common.ts
│ │ │ ├── entity.ts
│ │ │ └── scene-settings.ts
│ │ └── store.ts
│ └── wss.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
```
registry=https://registry.npmjs.org/
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
```
--------------------------------------------------------------------------------
/extension/popup.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html>
<head>
<title>PlayCanvas Editor MCP Extension</title>
<link rel="stylesheet" href="popup.css" />
<script src="popup.js" type="module"></script>
</head>
<body></body>
</html>
```
--------------------------------------------------------------------------------
/extension/isolated.js:
--------------------------------------------------------------------------------
```javascript
window.addEventListener('message', (event) => {
if (event.data?.ctx !== 'main') {
return;
}
const { name, args } = event.data;
chrome.runtime.sendMessage({ name, args });
});
chrome.runtime.onMessage.addListener((data) => {
const { name, args } = data;
window.postMessage({ name, args, ctx: 'isolated' });
});
```
--------------------------------------------------------------------------------
/release.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash -e
TYPE=$1
if [ -z "$TYPE" ]; then
echo "Usage: $0 <type>"
echo "type: major, minor, patch"
exit 1
fi
# Confirm release
read -p "Are you sure you want to release a new version? (y/N): " -r
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Release cancelled."
exit 1
fi
# Tag release
npm version $TYPE
# Publish to npm
npm publish
# Push to GitHub
git push origin main
git push --tags
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
lint:
name: Lint JS
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Node.js 18.20.0
uses: actions/[email protected]
with:
node-version: 18.20.0
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
```
--------------------------------------------------------------------------------
/src/tools/assets/material.ts:
--------------------------------------------------------------------------------
```typescript
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { type WSS } from '../../wss';
import { AssetIdSchema, RgbSchema } from '../schema/common';
export const register = (mcp: McpServer, wss: WSS) => {
mcp.tool(
'set_material_diffuse',
'Set diffuse property on a material',
{
assetId: AssetIdSchema,
color: RgbSchema
},
({ assetId, color }) => {
return wss.call('assets:property:set', assetId, 'diffuse', color);
}
);
};
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"types": ["node", "chrome"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}
```
--------------------------------------------------------------------------------
/src/tools/scene.ts:
--------------------------------------------------------------------------------
```typescript
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { type WSS } from '../wss';
import { SceneSettingsSchema } from './schema/scene-settings';
export const register = (mcp: McpServer, wss: WSS) => {
mcp.tool(
'modify_scene_settings',
'Modify the scene settings',
{
settings: SceneSettingsSchema
},
({ settings }) => {
return wss.call('scene:settings:modify', settings);
}
);
mcp.tool(
'query_scene_settings',
'Query the scene settings',
{},
() => {
return wss.call('scene:settings:modify', {});
}
);
};
```
--------------------------------------------------------------------------------
/src/tools/assets/script.ts:
--------------------------------------------------------------------------------
```typescript
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { type WSS } from '../../wss';
export const register = (mcp: McpServer, wss: WSS) => {
mcp.tool(
'set_script_text',
'Set script text',
{
assetId: z.number(),
text: z.string()
},
({ assetId, text }) => {
return wss.call('assets:script:text:set', assetId, text);
}
);
mcp.tool(
'script_parse',
'Parse the script after modification',
{
assetId: z.number()
},
({ assetId }) => {
return wss.call('assets:script:parse', assetId);
}
);
};
```
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
```
import playcanvasConfig from '@playcanvas/eslint-config';
import typescriptParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
...playcanvasConfig,
{
files: ['**/*.ts', '**/*.mjs', '**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: typescriptParser,
parserOptions: {
requireConfigFile: false
},
globals: {
...globals.browser,
...globals.mocha,
...globals.node,
...globals.webextensions
}
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json'
}
}
}
}
];
```
--------------------------------------------------------------------------------
/extension/manifest.json:
--------------------------------------------------------------------------------
```json
{
"manifest_version": 3,
"name": "PlayCanvas Editor MCP Extension",
"description": "This extension allows the MCP Server to communicate with the PlayCanvas Editor.",
"icons": {
"16": "images/icon16.png",
"48": "images/icon48.png",
"128": "images/icon128.png"
},
"version": "1.0",
"permissions": ["activeTab"],
"action": {
"default_popup": "popup.html"
},
"host_permissions": ["http://playcanvas.com/*", "https://playcanvas.com/*"],
"content_scripts": [
{
"matches": ["http://playcanvas.com/editor*", "https://playcanvas.com/editor*"],
"js": ["main.js"],
"world": "MAIN"
},
{
"matches": ["http://playcanvas.com/editor*", "https://playcanvas.com/editor*"],
"js": ["isolated.js"],
"world": "ISOLATED"
}
]
}
```
--------------------------------------------------------------------------------
/src/tools/schema/common.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
export const RgbSchema = z.tuple([
z.number().min(0).max(1).describe('Red'),
z.number().min(0).max(1).describe('Green'),
z.number().min(0).max(1).describe('Blue')
]).describe('A 3-channel RGB color');
export const RgbaSchema = z.tuple([
z.number().min(0).max(1).describe('Red'),
z.number().min(0).max(1).describe('Green'),
z.number().min(0).max(1).describe('Blue'),
z.number().min(0).max(1).describe('Alpha')
]).describe('A 4-channel RGBA color');
export const Vec2Schema = z.tuple([
z.number().describe('X'),
z.number().describe('Y')
]).describe('A 2D vector');
export const Vec3Schema = z.tuple([
z.number().describe('X'),
z.number().describe('Y'),
z.number().describe('Z')
]).describe('A 3D vector');
export const Vec4Schema = z.tuple([
z.number().describe('X'),
z.number().describe('Y'),
z.number().describe('Z'),
z.number().describe('W')
]).describe('A 4D vector');
export const AssetIdSchema = z.number().int().nullable().describe('An asset ID.');
export const EntityIdSchema = z.string().uuid().describe('An entity ID.');
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@playcanvas/editor-mcp-server",
"version": "0.0.2",
"author": "PlayCanvas <[email protected]>",
"homepage": "https://github.com/playcanvas/editor-mcp-server#readme",
"description": "The PlayCanvas Editor MCP Server",
"keywords": [
"playcanvas",
"editor",
"mcp",
"cli"
],
"license": "MIT",
"type": "module",
"bugs": {
"url": "https://github.com/playcanvas/editor-mcp-server/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/playcanvas/editor-mcp-server.git"
},
"files": [
"src"
],
"bin": {
"mcp-server": "cli.mjs"
},
"scripts": {
"start": "tsx src/server.ts",
"watch": "tsx watch src/server.ts",
"debug": "npx @modelcontextprotocol/inspector tsx src/server.ts",
"lint": "eslint src"
},
"devDependencies": {
"@playcanvas/eslint-config": "^2.1.0",
"@types/chrome": "^0.1.12",
"@types/command-line-args": "^5.2.3",
"@types/command-line-usage": "^5.0.4",
"@types/ws": "^8.18.1",
"@typescript-eslint/parser": "^8.44.1",
"eslint": "^9.36.0",
"eslint-import-resolver-typescript": "^4.4.4",
"globals": "^16.4.0",
"typescript": "^5.9.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.18.1",
"command-line-args": "^6.0.1",
"command-line-usage": "^7.0.3",
"tsx": "^4.20.5",
"ws": "^8.18.3",
"zod": "^4.1.11"
}
}
```
--------------------------------------------------------------------------------
/cli.mjs:
--------------------------------------------------------------------------------
```
#!/usr/bin/env node
import { execSync } from 'child_process';
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import commandLineArgs from 'command-line-args';
import commandLineUsage from 'command-line-usage';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
const options = [
{
name: 'help',
alias: 'h',
type: Boolean,
defaultValue: false
},
{
name: 'version',
alias: 'v',
type: Boolean,
defaultValue: false
},
{
name: 'port',
alias: 'p',
type: Number,
defaultValue: 52000
}
];
const help = commandLineUsage([{
header: 'Usage',
content: `npx ${pkg.name} [options]`
}, {
header: 'Options',
optionList: options
}]);
const main = (argv) => {
const args = commandLineArgs(options, { argv });
if (args.help) {
console.log(help);
return;
}
if (args.version) {
console.log(`v${pkg.version}`);
return;
}
process.env.PORT = args.port.toString();
try {
execSync(`npx tsx ${resolve(__dirname, 'src', 'server.ts')}`, {
stdio: 'inherit',
env: process.env
});
} catch (error) {
console.error('[CLI ERROR]', error.message);
process.exit(1);
}
};
main(process.argv.slice(2));
```
--------------------------------------------------------------------------------
/src/tools/store.ts:
--------------------------------------------------------------------------------
```typescript
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { type WSS } from '../wss';
const orderEnum = {
'asc': 1,
'desc': -1
};
export const register = (mcp: McpServer, wss: WSS) => {
mcp.tool(
'store_search',
'Search for an asset in the store',
{
// store: z.enum(['playcanvas', 'sketchfab']).optional(),
search: z.string(),
order: z.enum(['asc', 'desc']).optional(),
skip: z.number().optional(),
limit: z.number().optional()
},
({ search, order, skip, limit }) => {
return wss.call('store:playcanvas:list', {
search,
order: order ? orderEnum[order] : undefined,
skip,
limit
});
}
);
mcp.tool(
'store_get',
'Get an asset from the store',
{
// store: z.enum(['playcanvas', 'sketchfab']).optional(),
id: z.string()
},
({ id }) => {
return wss.call('store:playcanvas:get', id);
}
);
mcp.tool(
'store_download',
'Download an asset from the store',
{
// store: z.enum(['playcanvas', 'sketchfab']).optional(),
id: z.string(),
name: z.string(),
license: z.object({
author: z.string(),
authorUrl: z.string().url(),
license: z.string()
})
},
({ id, name, license }) => {
return wss.call('store:playcanvas:clone', id, name, license);
}
);
};
```
--------------------------------------------------------------------------------
/extension/popup.css:
--------------------------------------------------------------------------------
```css
:root {
--color-primary: #364346;
--color-secondary: #2c393c;
--color-text: #b1b8ba;
--color-hover: rgba(255,102,0,.3);
--color-red: #e74c3c;
--color-orange: #f1c40f;
--color-green: #2ecc71;
}
* {
font-family: 'Proxima Nova Regular", "Helvetica Neue", Arial, Helvetica, sans-serif;
}
html, body {
margin: 0;
width: 300px;
height: 100%;
}
#root {
background-color: var(--color-primary);
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.header {
text-transform: uppercase;
font-size: 12px;
color: white;
font-weight: bold;
background-color: var(--color-secondary);
padding: 10px;
}
.body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.input {
background-color: var(--color-secondary);
border: 1px solid #293538;
border-radius: 2px;
box-sizing: border-box;
color: var(--color-text);
font-family: inconsolatamedium, Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
font-size: 12px;
padding: 6px;
outline: none;
width: 100%;
}
.input:hover {
box-shadow: 0 0 2px 1px var(--color-hover);
}
.input.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.input.disabled:hover {
box-shadow: none;
}
.button {
background-color: var(--color-secondary);
border: 1px solid #20292b;
border-radius: 2px;
color: var(--color-text);
cursor: pointer;
font-size: 12px;
padding: 5px;
}
.button:hover {
color: white;
box-shadow: 0 0 2px 1px var(--color-hover);
}
.button.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.button.disabled:hover {
color: var(--color-text);
box-shadow: none;
}
.group {
display: flex;
gap: 10px;
}
.indicator {
background-color: var(--color-red);
border-radius: 50%;
width: 14px;
height: 14px;
}
.indicator.connecting {
background-color: var(--color-orange);
}
.indicator.connected {
background-color: var(--color-green);
}
.label {
color: var(--color-text);
font-size: 12px;
line-height: 14px;
user-select: none;
}
```
--------------------------------------------------------------------------------
/src/tools/asset.ts:
--------------------------------------------------------------------------------
```typescript
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { type WSS } from '../wss';
import { CssCreateSchema, FolderCreateSchema, HtmlCreateSchema, MaterialCreateSchema, ScriptCreateSchema, ShaderCreateSchema, TemplateCreateSchema, TextCreateSchema } from './schema/asset';
import { AssetIdSchema } from './schema/common';
export const register = (mcp: McpServer, wss: WSS) => {
mcp.tool(
'create_assets',
'Create one or more assets',
{
assets: z.array(
z.union([
CssCreateSchema,
FolderCreateSchema,
HtmlCreateSchema,
MaterialCreateSchema,
ScriptCreateSchema,
ShaderCreateSchema,
TemplateCreateSchema,
TextCreateSchema
])
).nonempty().describe('Array of assets to create.')
},
({ assets }) => {
return wss.call('assets:create', assets);
}
);
mcp.tool(
'list_assets',
'List all assets with the option to filter by type',
{
type: z.enum(['css', 'cubemap', 'folder', 'font', 'html', 'json', 'material', 'render', 'script', 'shader', 'template', 'text', 'texture']).optional().describe('The type of assets to list. If not specified, all assets will be listed.')
},
({ type }) => {
return wss.call('assets:list', type);
}
);
mcp.tool(
'delete_assets',
'Delete one or more assets',
{
ids: z.array(AssetIdSchema).nonempty().describe('The asset IDs of the assets to delete')
},
({ ids }) => {
return wss.call('assets:delete', ids);
}
);
mcp.tool(
'instantiate_template_assets',
'Instantiate one or more template assets',
{
ids: z.array(AssetIdSchema).nonempty().describe('The asset IDs of the template assets to instantiate')
},
({ ids }) => {
return wss.call('assets:instantiate', ids);
}
);
};
```
--------------------------------------------------------------------------------
/src/wss.ts:
--------------------------------------------------------------------------------
```typescript
import { WebSocketServer, WebSocket } from 'ws';
const PING_DELAY = 1000;
class WSS {
private _server: WebSocketServer;
private _socket?: WebSocket;
private _callbacks = new Map();
private _id = 0;
private _pingInterval: ReturnType<typeof setInterval> | null = null;
constructor(port: number) {
this._server = new WebSocketServer({ port });
console.error('[WSS] Listening on port', port);
this._waitForSocket();
}
private _waitForSocket() {
this._server.on('connection', (ws) => {
if (this._socket) {
return;
}
console.error('[WSS] Connected');
ws.on('message', (data) => {
try {
const { id, res } = JSON.parse(data.toString());
if (this._callbacks.has(id)) {
this._callbacks.get(id)(res);
this._callbacks.delete(id);
}
} catch (e) {
console.error('[WSS]', e);
}
});
ws.on('close', (_code, reason) => {
console.error('[WSS] Disconnected');
this._socket = undefined;
if (reason.toString() !== 'FORCE') {
this._waitForSocket();
}
});
this._socket = ws;
if (this._pingInterval) {
clearInterval(this._pingInterval);
}
this._pingInterval = setInterval(() => {
this.call('ping').then(() => console.error('[WSS] Ping'));
}, PING_DELAY);
});
}
private _send(name: string, ...args: any[]) {
return new Promise<{ data?: any, error?: string }>((resolve, reject) => {
const id = this._id++;
this._callbacks.set(id, resolve);
if (!this._socket) {
reject(new Error('No socket'));
return;
}
if (this._socket.readyState !== WebSocket.OPEN) {
reject(new Error('Socket not open'));
return;
}
this._socket.send(JSON.stringify({ id, name, args }));
});
}
async call(name: string, ...args: any[]): Promise<{ content: any[], isError?: boolean }> {
try {
const { data, error } = await this._send(name, ...args);
if (error) {
throw new Error(error);
}
return {
content: [{
type: 'text',
text: JSON.stringify(data)
}]
};
} catch (err: any) {
return {
content: [{
type: 'text',
text: err.message
}],
isError: true
};
}
}
close() {
if (this._pingInterval) {
clearInterval(this._pingInterval);
}
if (this._socket) {
this._socket.close(1000, 'FORCE');
}
this._server.close();
console.error('[WSS] Closed');
}
}
export { WSS };
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
import { execSync } from 'child_process';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { ListPromptsRequestSchema, ListResourcesRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { register as registerAsset } from './tools/asset';
import { register as registerAssetMaterial } from './tools/assets/material';
import { register as registerAssetScript } from './tools/assets/script';
import { register as registerEntity } from './tools/entity';
import { register as registerScene } from './tools/scene';
import { register as registerStore } from './tools/store';
import { WSS } from './wss';
const PORT = parseInt(process.env.PORT || '52000', 10);
const poll = (cond: () => boolean, rate: number = 1000) => {
return new Promise<void>((resolve) => {
const id = setInterval(() => {
if (cond()) {
clearInterval(id);
resolve();
}
}, rate);
});
};
const findPid = (port: number) => {
if (process.platform === 'win32') {
try {
return execSync(`netstat -ano | findstr 0.0.0.0:${PORT}`).toString().trim().split(' ').pop();
} catch (e) {
return '';
}
}
return execSync(`lsof -i :${port} | grep LISTEN | awk '{print $2}'`).toString().trim();
};
const kill = (pid: string) => {
if (process.platform === 'win32') {
try {
execSync(`taskkill /F /PID ${pid}`);
} catch (e) {
// Ignore
}
return;
}
execSync(`kill -9 ${pid}`);
};
// Kill the existing server
const pid = findPid(PORT);
if (pid) {
kill(pid);
}
// Wait for the server to stop
await poll(() => !findPid(PORT));
// Create a WebSocket server
const wss = new WSS(PORT);
// Create an MCP server
const mcp = new McpServer({
name: 'PlayCanvas',
version: '1.0.0'
}, {
capabilities: {
tools: {},
resources: {},
prompts: {}
}
});
mcp.server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
mcp.server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
// Tools
registerEntity(mcp, wss);
registerAsset(mcp, wss);
registerAssetMaterial(mcp, wss);
registerAssetScript(mcp, wss);
registerScene(mcp, wss);
registerStore(mcp, wss);
// Start receiving messages on stdin and sending messages on stdout
const transport = new StdioServerTransport();
mcp.connect(transport).then(() => {
console.error('[MCP] Listening');
}).catch((e) => {
console.error('[MCP] Error', e);
process.exit(1);
});
const close = () => {
mcp.close().finally(() => {
console.error('[MCP] Closed');
wss.close();
process.exit(0);
});
};
// Handle uncaught exceptions and unhandled rejections
process.on('uncaughtException', (err) => {
console.error('[process] Uncaught exception', err);
});
process.on('unhandledRejection', (reason) => {
console.error('[process] Unhandled rejection', reason);
});
// Clean up on exit
process.stdin.on('close', () => {
console.error('[process] stdin closed');
close();
});
process.on('SIGINT', () => {
console.error('[process] SIGINT');
close();
});
process.on('SIGTERM', () => {
console.error('[process] SIGTERM');
close();
});
process.on('SIGQUIT', () => {
console.error('[process] SIGQUIT');
close();
});
```
--------------------------------------------------------------------------------
/src/tools/entity.ts:
--------------------------------------------------------------------------------
```typescript
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { type WSS } from '../wss';
import { EntityIdSchema } from './schema/common';
import { ComponentsSchema, ComponentNameSchema, EntitySchema } from './schema/entity';
export const register = (mcp: McpServer, wss: WSS) => {
mcp.tool(
'create_entities',
'Create one or more entities',
{
entities: z.array(z.object({
entity: EntitySchema,
parent: EntityIdSchema.optional().describe('The parent entity to create the entity under. If not provided, the root entity will be used.')
})).nonempty().describe('Array of entity hierarchies to create.')
},
({ entities }) => {
return wss.call('entities:create', entities);
}
);
mcp.tool(
'modify_entities',
'Modify one or more entity\'s properties',
{
edits: z.array(z.object({
id: EntityIdSchema,
path: z.string().describe('The path to the property to modify. Use dot notation to access nested properties.'),
value: z.any().describe('The value to set the property to.')
})).nonempty().describe('An array of objects containing the ID of the entity to modify, the path to the property to modify, and the value to set the property to.')
},
({ edits }) => {
return wss.call('entities:modify', edits);
}
);
mcp.tool(
'duplicate_entities',
'Duplicate one or more entities',
{
ids: z.array(EntityIdSchema).nonempty().describe('Array of entity IDs to duplicate. The root entity cannot be duplicated.'),
rename: z.boolean().optional()
},
({ ids, rename }) => {
return wss.call('entities:duplicate', ids, { rename });
}
);
mcp.tool(
'reparent_entity',
'Reparent an entity',
{
id: EntityIdSchema,
parent: EntityIdSchema,
index: z.number().optional(),
preserveTransform: z.boolean().optional()
},
(options) => {
return wss.call('entities:reparent', options);
}
);
mcp.tool(
'delete_entities',
'Delete one or more entities. The root entity cannot be deleted.',
{
ids: z.array(EntityIdSchema).nonempty().describe('Array of entity IDs to delete. The root entity cannot be deleted.')
},
({ ids }) => {
return wss.call('entities:delete', ids);
}
);
mcp.tool(
'list_entities',
'List all entities',
{},
() => {
return wss.call('entities:list');
}
);
mcp.tool(
'add_components',
'Add components to an entity',
{
id: EntityIdSchema,
components: ComponentsSchema
},
({ id, components }) => {
return wss.call('entities:components:add', id, components);
}
);
mcp.tool(
'remove_components',
'Remove components from an entity',
{
id: EntityIdSchema,
components: z.array(ComponentNameSchema).nonempty().describe('Array of component names to remove from the entity.')
},
({ id, components }) => {
return wss.call('entities:components:remove', id, components);
}
);
mcp.tool(
'add_script_component_script',
'Add a script to a script component',
{
id: EntityIdSchema,
scriptName: z.string()
},
({ id, scriptName }) => {
return wss.call('entities:components:script:add', id, scriptName);
}
);
};
```
--------------------------------------------------------------------------------
/extension/popup.js:
--------------------------------------------------------------------------------
```javascript
const DEFAULT_PORT = 52000;
// UI
const root = document.createElement('div');
root.id = 'root';
document.body.appendChild(root);
const header = document.createElement('div');
header.classList.add('header');
header.textContent = 'PlayCanvas Editor MCP Extension';
root.appendChild(header);
const body = document.createElement('div');
body.classList.add('body');
root.appendChild(body);
const statusGroup = document.createElement('div');
statusGroup.classList.add('group');
body.appendChild(statusGroup);
const statusInd = document.createElement('div');
statusInd.classList.add('indicator');
statusGroup.appendChild(statusInd);
const statusLabel = document.createElement('label');
statusLabel.classList.add('label');
statusLabel.textContent = 'Disconnected';
statusGroup.appendChild(statusLabel);
const portInput = document.createElement('input');
portInput.classList.add('input');
portInput.type = 'text';
portInput.placeholder = 'Enter port number';
portInput.value = DEFAULT_PORT;
body.appendChild(portInput);
const connectBtn = document.createElement('button');
connectBtn.classList.add('button');
connectBtn.textContent = 'CONNECT';
body.appendChild(connectBtn);
/**
* Creates a state management hook.
*
* @param {string} defaultState - The default state.
* @returns {[function(): string, function(string): void]} The state getter and setter.
*/
const useState = (defaultState) => {
let state;
const get = () => state;
const set = (value) => {
state = value;
switch (state) {
case 'disconnected': {
statusInd.classList.remove('connecting', 'connected');
statusInd.classList.add('disconnected');
statusLabel.textContent = 'Disconnected';
portInput.disabled = false;
portInput.classList.remove('disabled');
connectBtn.textContent = 'CONNECT';
break;
}
case 'connecting': {
statusInd.classList.remove('connected', 'disconnected');
statusInd.classList.add('connecting');
statusLabel.textContent = 'Connecting';
portInput.disabled = true;
portInput.classList.add('disabled');
connectBtn.textContent = 'CANCEL';
break;
}
case 'connected': {
statusInd.classList.remove('disconnected', 'connecting');
statusInd.classList.add('connected');
statusLabel.textContent = 'Connected';
portInput.disabled = true;
portInput.classList.add('disabled');
connectBtn.textContent = 'DISCONNECT';
break;
}
}
};
set(defaultState);
return [get, set];
};
const [getState, setState] = useState('disconnected');
/**
* Event handler
*/
class EventHandler {
_handlers = new Map();
/**
* @param {string} name - The name of the event to add.
* @param {(...args: any[]) => void} fn - The function to call when the event is triggered.
*/
on(name, fn) {
if (!this._handlers.has(name)) {
this._handlers.set(name, []);
}
this._handlers.get(name).push(fn);
}
/**
* @param {string} name - The name of the event to remove.
* @param {(...args: any[]) => void} fn - The function to remove.
*/
off(name, fn) {
if (!this._handlers.has(name)) {
return;
}
const methods = this._handlers.get(name);
const index = methods.indexOf(fn);
if (index !== -1) {
methods.splice(index, 1);
}
if (methods.length === 0) {
this._handlers.delete(name);
}
}
/**
* @param {string} name - The name of the event to trigger.
* @param {...*} args - The arguments to pass to the event.
*/
fire(name, ...args) {
if (!this._handlers.has(name)) {
return;
}
const handlers = this._handlers.get(name);
for (let i = 0; i < handlers.length; i++) {
handlers[i](...args);
}
}
}
// Listen for messages from the content script
const listener = new EventHandler();
listener.on('status', (status) => {
setState(status);
});
chrome.runtime.onMessage.addListener((data) => {
const { name, args } = data;
listener.fire(name, ...args);
});
/**
* Sends a message to the content script.
*
* @param {string} name - The name of the message to send.
* @param {...*} args - The arguments to pass to the message.
* @returns {Promise<boolean>} - A promise that resolves to true if the message was sent successfully, false otherwise.
*/
const send = async (name, ...args) => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) {
return false;
}
if (!/playcanvas\.com\/editor/.test(tab.url)) {
return false;
}
chrome.tabs.sendMessage(tab.id, { name, args });
return true;
};
connectBtn.addEventListener('click', () => {
if (getState() === 'disconnected') {
setState('connecting');
send('connect', {
port: portInput.value
}).catch((e) => {
console.error('SEND ERROR:', e);
});
} else {
send('disconnect').catch((e) => {
console.error('SEND ERROR:', e);
});
}
});
send('sync').then((success) => {
if (!success) {
portInput.disabled = true;
portInput.classList.add('disabled');
connectBtn.disabled = true;
connectBtn.classList.add('disabled');
}
}).catch((e) => {
console.error('SEND ERROR:', e);
});
```
--------------------------------------------------------------------------------
/src/tools/schema/scene-settings.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
import { AssetIdSchema, RgbSchema, Vec3Schema } from './common';
const PhysicsSchema = z.object({
gravity: Vec3Schema.optional().describe('An array of 3 numbers that represents the gravity force. Default: [0, -9.8, 0]')
}).describe('Physics related settings for the scene.');
const RenderSchema = z.object({
fog: z.enum(['none', 'linear', 'exp', 'exp2']).optional().describe('The type of fog used in the scene. Can be one of `pc.FOG_NONE`, `pc.FOG_LINEAR`, `pc.FOG_EXP`, `pc.FOG_EXP2`. Default: `none`.'),
fog_start: z.number().min(0).optional().describe('The distance from the viewpoint where linear fog begins. This property is only valid if the fog property is set to `pc.FOG_LINEAR`. Default: 1.0.'),
fog_end: z.number().min(0).optional().describe('The distance from the viewpoint where linear fog reaches its maximum. This property is only valid if the fog property is set to `pc.FOG_LINEAR`. Default: 1000.0.'),
fog_density: z.number().min(0).optional().describe('The density of the fog. This property is only valid if the fog property is set to `pc.FOG_EXP` or `pc.FOG_EXP2`. Default: 0.01.'),
fog_color: RgbSchema.optional().describe('An array of 3 numbers representing the color of the fog. Default: [0.0, 0.0, 0.0].'),
global_ambient: RgbSchema.optional().describe('An array of 3 numbers representing the color of the scene\'s ambient light. Default: [0.2, 0.2, 0.2].'),
gamma_correction: z.union([
z.literal(0).describe('GAMMA_NONE'),
z.literal(1).describe('GAMMA_SRGB')
]).optional().describe('The gamma correction to apply when rendering the scene. Default: 1 (GAMMA_SRGB).'),
lightmapSizeMultiplier: z.number().optional().describe('The lightmap resolution multiplier. Default: 16.'),
lightmapMaxResolution: z.number().optional().describe('The maximum lightmap resolution. Default: 2048.'),
lightmapMode: z.union([
z.literal(0).describe('BAKE_COLOR'),
z.literal(1).describe('BAKE_COLORDIR')
]).optional().describe('The lightmap baking mode. Default: 1 (BAKE_COLORDIR).'),
tonemapping: z.number().optional().describe('The tonemapping transform to apply when writing fragments to the frame buffer. Default: 0.'),
exposure: z.number().optional().describe('The exposure value tweaks the overall brightness of the scene. Default: 1.0.'),
skybox: AssetIdSchema.optional().describe('The `id` of the cubemap texture to be used as the scene\'s skybox. Default: null.'),
skyType: z.enum(['infinite', 'box', 'dome']).optional().describe('Type of skybox projection. Default: `infinite`.'),
skyMeshPosition: Vec3Schema.optional().describe('An array of 3 numbers representing the position of the sky mesh. Default: [0.0, 0.0, 0.0].'),
skyMeshRotation: Vec3Schema.optional().describe('An array of 3 numbers representing the rotation of the sky mesh. Default: [0.0, 0.0, 0.0].'),
skyMeshScale: Vec3Schema.optional().describe('An array of 3 numbers representing the scale of the sky mesh. Default: [100.0, 100.0, 100.0].'),
skyCenter: Vec3Schema.optional().describe('An array of 3 numbers representing the center of the sky mesh. Default: [0.0, 0.1, 0.0].'),
skyboxIntensity: z.number().optional().describe('Multiplier for skybox intensity. Default: 1.'),
skyboxMip: z.number().int().min(0).max(5).optional().describe('The mip level of the skybox to be displayed. Only valid for prefiltered cubemap skyboxes. Default: 0.'),
skyboxRotation: Vec3Schema.optional().describe('An array of 3 numbers representing the rotation of the skybox. Default: [0, 0, 0].'),
lightmapFilterEnabled: z.boolean().optional().describe('Enable filtering of lightmaps. Default: false.'),
lightmapFilterRange: z.number().optional().describe('A range parameter of the bilateral filter. Default: 10.'),
lightmapFilterSmoothness: z.number().optional().describe('A spatial parameter of the bilateral filter. Default: 0.2.'),
ambientBake: z.boolean().optional().describe('Enable baking the ambient lighting into lightmaps. Default: false.'),
ambientBakeNumSamples: z.number().optional().describe('Number of samples to use when baking ambient. Default: 1.'),
ambientBakeSpherePart: z.number().optional().describe('How much of the sphere to include when baking ambient. Default: 0.4.'),
ambientBakeOcclusionBrightness: z.number().optional().describe('Specifies the ambient occlusion brightness. Typical range is -1 to 1. Default: 0.'),
ambientBakeOcclusionContrast: z.number().optional().describe('Specifies the ambient occlusion contrast. Typical range is -1 to 1. Default: 0.'),
clusteredLightingEnabled: z.boolean().optional().describe('Enable the clustered lighting. Default: true.'),
lightingCells: Vec3Schema.optional().describe('Number of cells along each world-space axis the space containing lights is subdivided into. Default: [10, 3, 10].'),
lightingMaxLightsPerCell: z.number().optional().describe('Maximum number of lights a cell can store. Default: 255.'),
lightingCookieAtlasResolution: z.number().optional().describe('Resolution of the atlas texture storing all non-directional cookie textures. Default: 2048.'),
lightingShadowAtlasResolution: z.number().optional().describe('Resolution of the atlas texture storing all non-directional shadow textures. Default: 2048.'),
lightingShadowType: z.union([
z.literal(0).describe('SHADOW_PCF3_32F'),
z.literal(4).describe('SHADOW_PCF5_32F'),
z.literal(5).describe('SHADOW_PCF1_32F')
]).optional().describe('The type of shadow filtering used by all shadows. Default: 0 (SHADOW_PCF3_32F).'),
lightingCookiesEnabled: z.boolean().optional().describe('Cluster lights support cookies. Default: false.'),
lightingAreaLightsEnabled: z.boolean().optional().describe('Cluster lights support area lights. Default: false.'),
lightingShadowsEnabled: z.boolean().optional().describe('Cluster lights support shadows. Default: true.')
}).describe('Render related settings for the scene.');
const SceneSettingsSchema = z.object({
physics: PhysicsSchema.optional(),
render: RenderSchema.optional()
}).describe('Scene settings.');
export { SceneSettingsSchema };
```
--------------------------------------------------------------------------------
/src/tools/schema/asset.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
import { AssetIdSchema, EntityIdSchema, RgbSchema, Vec2Schema, Vec3Schema } from './common';
const MaterialSchema = z.object({
name: z.string().optional(),
ambient: RgbSchema.optional(),
aoMap: AssetIdSchema.optional(),
aoMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
aoMapUv: z.number().int().min(0).max(7).optional(),
aoMapTiling: Vec2Schema.optional(),
aoMapOffset: Vec2Schema.optional(),
aoMapRotation: z.number().optional(),
aoVertexColor: z.boolean().optional(),
aoVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
aoIntensity: z.number().optional(),
diffuse: RgbSchema.optional(),
diffuseMap: AssetIdSchema.optional(),
diffuseMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
diffuseMapUv: z.number().int().min(0).max(7).optional(),
diffuseMapTiling: Vec2Schema.optional(),
diffuseMapOffset: Vec2Schema.optional(),
diffuseMapRotation: z.number().optional(),
diffuseVertexColor: z.boolean().optional(),
diffuseVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
specular: RgbSchema.optional(),
specularMap: AssetIdSchema.optional(),
specularMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
specularMapUv: z.number().int().min(0).max(7).optional(),
specularMapTiling: Vec2Schema.optional(),
specularMapOffset: Vec2Schema.optional(),
specularMapRotation: z.number().optional(),
specularAntialias: z.boolean().optional(),
specularTint: z.boolean().optional(),
specularVertexColor: z.boolean().optional(),
specularVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
occludeSpecular: z.number().int().min(0).max(2).optional(),
specularityFactor: z.number().optional(),
specularityFactorMap: AssetIdSchema.optional(),
specularityFactorMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
specularityFactorMapUv: z.number().int().min(0).max(7).optional(),
specularityFactorMapTiling: Vec2Schema.optional(),
specularityFactorMapOffset: Vec2Schema.optional(),
specularityFactorMapRotation: z.number().optional(),
specularityFactorTint: z.boolean().optional(),
specularityFactorVertexColor: z.boolean().optional(),
specularityFactorVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
enableGGXSpecular: z.boolean().optional(),
anisotropy: z.number().min(-1).max(1).optional(),
useMetalness: z.boolean().optional(),
metalness: z.number().min(0).max(1).optional(),
metalnessMap: AssetIdSchema.optional(),
metalnessMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
metalnessMapUv: z.number().int().min(0).max(7).optional(),
metalnessMapTiling: Vec2Schema.optional(),
metalnessMapOffset: Vec2Schema.optional(),
metalnessMapRotation: z.number().optional(),
metalnessVertexColor: z.boolean().optional(),
metalnessVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
useMetalnessSpecularColor: z.boolean().optional(),
conserveEnergy: z.boolean().optional(),
shininess: z.number().min(0).max(100).optional(),
glossMap: AssetIdSchema.optional(),
glossMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
glossMapUv: z.number().int().min(0).max(7).optional(),
glossMapTiling: Vec2Schema.optional(),
glossMapOffset: Vec2Schema.optional(),
glossMapRotation: z.number().optional(),
glossVertexColor: z.boolean().optional(),
glossVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
glossInvert: z.boolean().optional(),
clearCoat: z.number().min(0).max(1).optional(),
clearCoatMap: AssetIdSchema.optional(),
clearCoatMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
clearCoatMapUv: z.number().int().min(0).max(7).optional(),
clearCoatMapTiling: Vec2Schema.optional(),
clearCoatMapOffset: Vec2Schema.optional(),
clearCoatMapRotation: z.number().optional(),
clearCoatVertexColor: z.boolean().optional(),
clearCoatVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
clearCoatGloss: z.number().min(0).max(1).optional(),
clearCoatGlossMap: AssetIdSchema.optional(),
clearCoatGlossMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
clearCoatGlossMapUv: z.number().int().min(0).max(7).optional(),
clearCoatGlossMapTiling: Vec2Schema.optional(),
clearCoatGlossMapOffset: Vec2Schema.optional(),
clearCoatGlossMapRotation: z.number().optional(),
clearCoatGlossVertexColor: z.boolean().optional(),
clearCoatGlossVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
clearCoatGlossInvert: z.boolean().optional(),
clearCoatNormalMap: AssetIdSchema.optional(),
clearCoatNormalMapUv: z.number().int().min(0).max(7).optional(),
clearCoatNormalMapTiling: Vec2Schema.optional(),
clearCoatNormalMapOffset: Vec2Schema.optional(),
clearCoatNormalMapRotation: z.number().optional(),
useSheen: z.boolean().optional(),
sheen: RgbSchema.optional(),
sheenMap: AssetIdSchema.optional(),
sheenMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
sheenMapUv: z.number().int().min(0).max(7).optional(),
sheenMapTiling: Vec2Schema.optional(),
sheenMapOffset: Vec2Schema.optional(),
sheenMapRotation: z.number().optional(),
sheenVertexColor: z.boolean().optional(),
sheenVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
sheenGloss: z.number().optional(),
sheenGlossMap: AssetIdSchema.optional(),
sheenGlossMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
sheenGlossMapUv: z.number().int().min(0).max(7).optional(),
sheenGlossMapTiling: Vec2Schema.optional(),
sheenGlossMapOffset: Vec2Schema.optional(),
sheenGlossMapRotation: z.number().optional(),
sheenGlossVertexColor: z.boolean().optional(),
sheenGlossVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
sheenGlossInvert: z.boolean().optional(),
emissive: RgbSchema.optional(),
emissiveMap: AssetIdSchema.optional(),
emissiveMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
emissiveMapUv: z.number().int().min(0).max(7).optional(),
emissiveMapTiling: Vec2Schema.optional(),
emissiveMapOffset: Vec2Schema.optional(),
emissiveMapRotation: z.number().optional(),
emissiveIntensity: z.number().min(0).max(10).optional(),
emissiveVertexColor: z.boolean().optional(),
emissiveVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
normalMap: AssetIdSchema.optional(),
normalMapUv: z.number().int().min(0).max(7).optional(),
normalMapTiling: Vec2Schema.optional(),
normalMapOffset: Vec2Schema.optional(),
normalMapRotation: z.number().optional(),
bumpMapFactor: z.number().optional(),
useDynamicRefraction: z.boolean().optional(),
refraction: z.number().min(0).max(1).optional(),
refractionMap: AssetIdSchema.optional(),
refractionMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
refractionMapUv: z.number().int().min(0).max(7).optional(),
refractionMapTiling: Vec2Schema.optional(),
refractionMapOffset: Vec2Schema.optional(),
refractionMapRotation: z.number().optional(),
refractionVertexColor: z.boolean().optional(),
refractionVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
refractionIndex: z.number().min(0).max(1).optional(),
dispersion: z.number().min(0).max(10).optional(),
thickness: z.number().optional(),
thicknessMap: AssetIdSchema.optional(),
thicknessMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
thicknessMapUv: z.number().int().min(0).max(7).optional(),
thicknessMapTiling: Vec2Schema.optional(),
thicknessMapOffset: Vec2Schema.optional(),
thicknessMapRotation: z.number().optional(),
thicknessVertexColor: z.boolean().optional(),
thicknessVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
attenuation: z.array(z.number()).length(3).optional(),
attenuationDistance: z.number().optional(),
useIridescence: z.boolean().optional(),
iridescence: z.number().optional(),
iridescenceMap: AssetIdSchema.optional(),
iridescenceMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
iridescenceMapUv: z.number().int().min(0).max(7).optional(),
iridescenceMapTiling: Vec2Schema.optional(),
iridescenceMapOffset: Vec2Schema.optional(),
iridescenceMapRotation: z.number().optional(),
iridescenceRefractionIndex: z.number().optional(),
iridescenceThicknessMap: AssetIdSchema.optional(),
iridescenceThicknessMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
iridescenceThicknessMapUv: z.number().int().min(0).max(7).optional(),
iridescenceThicknessMapTiling: Vec2Schema.optional(),
iridescenceThicknessMapOffset: Vec2Schema.optional(),
iridescenceThicknessMapRotation: z.number().optional(),
iridescenceThicknessMin: z.number().optional(),
iridescenceThicknessMax: z.number().optional(),
heightMap: AssetIdSchema.optional(),
heightMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
heightMapUv: z.number().int().min(0).max(7).optional(),
heightMapTiling: Vec2Schema.optional(),
heightMapOffset: Vec2Schema.optional(),
heightMapRotation: z.number().optional(),
heightMapFactor: z.number().min(0).max(2).optional(),
alphaToCoverage: z.boolean().optional(),
alphaTest: z.number().min(0).max(1).optional(),
alphaFade: z.number().min(0).max(1).optional(),
opacity: z.number().min(0).max(1).optional(),
opacityMap: AssetIdSchema.optional(),
opacityMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
opacityMapUv: z.number().int().min(0).max(7).optional(),
opacityMapTiling: Vec2Schema.optional(),
opacityMapOffset: Vec2Schema.optional(),
opacityMapRotation: z.number().optional(),
opacityVertexColor: z.boolean().optional(),
opacityVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
opacityFadesSpecular: z.boolean().optional(),
opacityDither: z.enum(['none', 'bayer8', 'bluenoise', 'ignnoise']).optional(),
opacityShadowDither: z.enum(['none', 'bayer8', 'bluenoise', 'ignnoise']).optional(),
reflectivity: z.number().min(0).max(1).optional(),
sphereMap: AssetIdSchema.optional(),
cubeMap: AssetIdSchema.optional(),
cubeMapProjection: z.number().int().min(0).max(1).optional(),
cubeMapProjectionBox: z.object({
center: Vec3Schema,
halfExtents: Vec3Schema
}).optional(),
lightMap: AssetIdSchema.optional(),
lightMapChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
lightMapUv: z.number().int().min(0).max(7).optional(),
lightMapTiling: Vec2Schema.optional(),
lightMapOffset: Vec2Schema.optional(),
lightMapRotation: z.number().optional(),
lightVertexColor: z.boolean().optional(),
lightVertexColorChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional(),
depthTest: z.boolean().optional(),
depthWrite: z.boolean().optional(),
depthBias: z.number().optional(),
slopeDepthBias: z.number().optional(),
cull: z.number().int().min(0).max(3).optional(),
blendType: z.number().int().min(0).max(10).optional(),
useFog: z.boolean().optional(),
useLighting: z.boolean().optional(),
useSkybox: z.boolean().optional(),
useTonemap: z.boolean().optional(),
twoSidedLighting: z.boolean().optional()
});
export const CssCreateSchema = z.object({
type: z.literal('css'),
options: z.object({
folder: AssetIdSchema.optional(),
name: z.string().optional(),
preload: z.boolean().optional(),
text: z.string().optional()
}).optional()
}).describe('CSS asset creation options.');
export const HtmlCreateSchema = z.object({
type: z.literal('html'),
options: z.object({
folder: AssetIdSchema.optional(),
name: z.string().optional(),
preload: z.boolean().optional(),
text: z.string().optional()
}).optional()
}).describe('HTML asset creation options.');
export const FolderCreateSchema = z.object({
type: z.literal('folder'),
options: z.object({
folder: AssetIdSchema.optional(),
name: z.string().optional()
}).optional()
}).describe('Folder asset creation options.');
export const MaterialCreateSchema = z.object({
type: z.literal('material'),
options: z.object({
data: MaterialSchema.optional(),
folder: AssetIdSchema.optional(),
name: z.string().optional(),
preload: z.boolean().optional()
}).optional()
}).describe('Material asset creation options.');
export const ScriptCreateSchema = z.object({
type: z.literal('script'),
options: z.object({
filename: z.string().optional(),
folder: AssetIdSchema.optional(),
preload: z.boolean().optional(),
text: z.string().optional()
}).optional()
}).describe('Script asset creation options.');
export const ShaderCreateSchema = z.object({
type: z.literal('shader'),
options: z.object({
folder: AssetIdSchema.optional(),
name: z.string().optional(),
preload: z.boolean().optional(),
text: z.string().optional()
}).optional()
}).describe('Shader asset creation options.');
export const TemplateCreateSchema = z.object({
type: z.literal('template'),
options: z.object({
entity: EntityIdSchema,
folder: AssetIdSchema.optional(),
name: z.string().optional(),
preload: z.boolean().optional()
})
}).describe('Template asset creation options.');
export const TextCreateSchema = z.object({
type: z.literal('text'),
options: z.object({
folder: AssetIdSchema.optional(),
name: z.string().optional(),
preload: z.boolean().optional(),
text: z.string().optional()
}).optional()
}).describe('Text asset creation options.');
```
--------------------------------------------------------------------------------
/extension/main.js:
--------------------------------------------------------------------------------
```javascript
(() => {
if (!window.editor) {
throw new Error('PlayCanvas Editor not found');
}
/**
* @param {string} msg - The message to log.
*/
const log = (msg) => {
console.log(`%c[WSC] ${msg}`, 'color:#f60');
};
/**
* @param {string} msg - The message to log.
*/
const error = (msg) => {
console.error(`%c[WSC] ${msg}`, 'color:#f60');
};
/**
* PlayCanvas Editor API observer package.
*/
const observer = window.editor.observer;
/**
* PlayCanvas Editor API wrapper.
*/
const api = window.editor.api.globals;
/**
* PlayCanvas REST API wrapper.
*
* @param {'GET' | 'POST' | 'PUT' | 'DELETE'} method - The HTTP method to use.
* @param {string} path - The path to the API endpoint.
* @param {FormData | Object} data - The data to send.
* @param {boolean} auth - Whether to use authentication.
* @returns {Promise<Object>} The response data.
*/
const rest = (method, path, data, auth = false) => {
const init = {
method,
headers: {
Authorization: auth ? `Bearer ${api.accessToken}` : undefined
}
};
if (data instanceof FormData) {
init.body = data;
} else {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(data);
}
return fetch(`/api/${path}`, init).then((res) => res.json());
};
/**
* @param {Object} obj - The object to iterate.
* @param {Function} callback - The callback to call for each key-value pair.
* @param {string} currentPath - The current path of the object.
*/
const iterateObject = (obj, callback, currentPath = '') => {
Object.entries(obj).forEach(([key, value]) => {
const path = currentPath ? `${currentPath}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
iterateObject(value, callback, path);
} else {
callback(path, value);
}
});
};
class WSC extends observer.Events {
static STATUS_CONNECTING = 'connecting';
static STATUS_CONNECTED = 'connected';
static STATUS_DISCONNECTED = 'disconnected';
/**
* @type {WebSocket}
* @private
*/
_ws;
/**
* @type {Map<string, Function}
* @private
*/
_methods = new Map();
/**
* @type {ReturnType<typeof setTimeout> | null}
* @private
*/
_connectTimeout = null;
/**
* @type {WSC.STATUS_CONNECTING | WSC.STATUS_CONNECTED | WSC.STATUS_DISCONNECTED}
* @private
*/
_status = WSC.STATUS_DISCONNECTED;
/**
* @type {WSC.STATUS_CONNECTING | WSC.STATUS_CONNECTED | WSC.STATUS_DISCONNECTED}
*/
get status() {
return this._status;
}
/**
* @param {string} address - The address to connect to.
* @param {Function} resolve - The function to call when the connection is established.
* @param {number} [retryTimeout] - The timeout to retry the connection.
*/
connect(address, retryTimeout = 1000) {
this._status = WSC.STATUS_CONNECTING;
this.emit('status', this._status);
log(`Connecting to ${address}`);
if (this._connectTimeout) {
clearTimeout(this._connectTimeout);
}
this._connect(address, retryTimeout, () => {
this._ws.onclose = (evt) => {
if (evt.reason === 'FORCE') {
return;
}
this._status = WSC.STATUS_DISCONNECTED;
this.emit('status', this._status);
log('Disconnected');
};
this._status = WSC.STATUS_CONNECTED;
this.emit('status', this._status);
log('Connected');
});
}
/**
* @param {string} address - The address to connect to.
* @param {number} retryTimeout - The timeout to retry the connection.
* @param {Function} resolve - The function to call when the connection is established
* @private
*/
_connect(address, retryTimeout, resolve) {
this._ws = new WebSocket(address);
this._ws.onopen = () => {
resolve();
};
this._ws.onmessage = async (event) => {
try {
const { id, name, args } = JSON.parse(event.data);
const res = await this.call(name, ...args);
this._ws.send(JSON.stringify({ id, res }));
} catch (e) {
error(e);
}
};
this._ws.onclose = () => {
this._connectTimeout = setTimeout(() => {
this._connectTimeout = null;
this._connect(address, retryTimeout, resolve);
}, retryTimeout);
};
}
disconnect() {
if (this._connectTimeout) {
clearTimeout(this._connectTimeout);
}
if (this._ws) {
this._ws.close(1000, 'FORCE');
this._ws = null;
}
this._status = WSC.STATUS_DISCONNECTED;
this.emit('status', this._status);
log('Disconnected');
}
/**
* @param {string} name - The name of the method to add.
* @param {(...args: any[]) => { data?: any, error?: string }} fn - The function to call when the method is called.
*/
method(name, fn) {
if (this._methods.get(name)) {
error(`Method already exists: ${name}`);
return;
}
this._methods.set(name, fn);
}
/**
* @param {string} name - The name of the method to call.
* @param {...*} args - The arguments to pass to the method.
* @returns {{ data?: any, error?: string }} The response data.
*/
call(name, ...args) {
return this._methods.get(name)?.(...args);
}
}
class Messenger extends observer.Events {
constructor() {
super();
window.addEventListener('message', (event) => {
if (event.data?.ctx !== 'isolated') {
return;
}
const { name, args } = event.data;
this.emit(name, ...args);
});
}
send(name, ...args) {
window.postMessage({ name, args, ctx: 'main' });
}
}
const wsc = new WSC();
const messenger = new Messenger('main');
// sync
messenger.on('sync', () => {
messenger.send('status', wsc.status);
});
messenger.on('connect', ({ port = 52000 }) => {
wsc.connect(`ws://localhost:${port}`);
});
messenger.on('disconnect', () => {
wsc.disconnect();
});
wsc.on('status', (status) => {
messenger.send('status', status);
});
// general
wsc.method('ping', () => 'pong');
// entities
wsc.method('entities:create', (entityDataArray) => {
const entities = [];
entityDataArray.forEach((entityData) => {
if (Object.hasOwn(entityData, 'parent')) {
const parent = api.entities.get(entityData.parent);
if (!parent) {
return { error: `Parent entity not found: ${entityData.parent}` };
}
entityData.entity.parent = parent;
}
const entity = api.entities.create(entityData.entity);
if (!entity) {
return { error: 'Failed to create entity' };
}
entities.push(entity);
log(`Created entity(${entity.get('resource_id')})`);
});
return { data: entities.map((entity) => entity.json()) };
});
wsc.method('entities:modify', (edits) => {
edits.forEach(({ id, path, value }) => {
const entity = api.entities.get(id);
if (!entity) {
return { error: 'Entity not found' };
}
entity.set(path, value);
log(`Set property(${path}) of entity(${id}) to: ${JSON.stringify(value)}`);
});
return { data: true };
});
wsc.method('entities:duplicate', async (ids, options = {}) => {
const entities = ids.map((id) => api.entities.get(id));
if (!entities.length) {
return { error: 'Entities not found' };
}
const res = await api.entities.duplicate(entities, options);
log(`Duplicated entities: ${res.map((entity) => entity.get('resource_id')).join(', ')}`);
return { data: res.map((entity) => entity.json()) };
});
wsc.method('entities:reparent', (options) => {
const entity = api.entities.get(options.id);
if (!entity) {
return { error: 'Entity not found' };
}
const parent = api.entities.get(options.parent);
if (!parent) {
return { error: 'Parent entity not found' };
}
entity.reparent(parent, options.index, {
preserveTransform: options.preserveTransform
});
log(`Reparented entity(${options.id}) to entity(${options.parent})`);
return { data: entity.json() };
});
wsc.method('entities:delete', async (ids) => {
const entities = ids.map((id) => api.entities.get(id)).filter((entity) => entity !== api.entities.root);
if (!entities.length) {
return { error: 'No entities to delete' };
}
await api.entities.delete(entities);
log(`Deleted entities: ${ids.join(', ')}`);
return { data: true };
});
wsc.method('entities:list', () => {
const entities = api.entities.list();
if (!entities.length) {
return { error: 'No entities found' };
}
log('Listed entities');
return { data: entities.map((entity) => entity.json()) };
});
wsc.method('entities:components:add', (id, components) => {
const entity = api.entities.get(id);
if (!entity) {
return { error: 'Entity not found' };
}
Object.entries(components).forEach(([name, data]) => {
entity.addComponent(name, data);
});
log(`Added components(${Object.keys(components).join(', ')}) to entity(${id})`);
return { data: entity.json() };
});
wsc.method('entities:components:remove', (id, components) => {
const entity = api.entities.get(id);
if (!entity) {
return { error: 'Entity not found' };
}
components.forEach((component) => {
entity.removeComponent(component);
});
log(`Removed components(${components.join(', ')}) from entity(${id})`);
return { data: entity.json() };
});
wsc.method('entities:components:script:add', (id, scriptName) => {
const entity = api.entities.get(id);
if (!entity) {
return { error: 'Entity not found' };
}
if (!entity.get('components.script')) {
return { error: 'Script component not found' };
}
entity.addScript(scriptName);
log(`Added script(${scriptName}) to component(script) of entity(${id})`);
return { data: entity.get('components.script') };
});
// assets
wsc.method('assets:create', async (assets) => {
try {
// Map each asset definition to a promise that handles its creation
const assetCreationPromises = assets.map(async ({ type, options }) => {
if (options?.folder) {
options.folder = api.assets.get(options.folder);
}
let createPromise;
// Determine the correct API call based on the asset type
switch (type) {
case 'css':
createPromise = api.assets.createCss(options);
break;
case 'folder':
createPromise = api.assets.createFolder(options);
break;
case 'html':
createPromise = api.assets.createHtml(options);
break;
case 'material':
if (options?.data?.name) {
options.name = options.data.name;
}
createPromise = api.assets.createMaterial(options);
break;
case 'script':
createPromise = api.assets.createScript(options);
break;
case 'shader':
createPromise = api.assets.createShader(options);
break;
case 'template':
if (options?.entity) {
options.entity = api.entities.get(options.entity);
}
createPromise = api.assets.createTemplate(options);
break;
case 'text':
createPromise = api.assets.createText(options);
break;
default:
// Throw an error for this specific promise if type is invalid
throw new Error(`Invalid asset type: ${type}`);
}
// Await the specific asset creation promise
const asset = await createPromise;
// Check for creation failure and throw an error
if (!asset) {
throw new Error(`Failed to create asset of type ${type}`);
}
// Log success and return the asset data for this promise
log(`Created asset(${asset.get('id')}) - Type: ${type}`);
return asset.json();
});
// Wait for all creation promises to resolve concurrently
const createdAssetsData = await Promise.all(assetCreationPromises);
// Return the collected data if all promises succeeded
return { data: createdAssetsData };
} catch (error) {
// Catch any error thrown during the mapping or from Promise.all
const errorMessage =
error instanceof Error ? error.message : 'An unknown error occurred during asset creation.';
log(`Error creating assets: ${errorMessage}`);
return { error: errorMessage };
}
});
wsc.method('assets:delete', (ids) => {
const assets = ids.map((id) => api.assets.get(id));
if (!assets.length) {
return { error: 'Assets not found' };
}
api.assets.delete(assets);
log(`Deleted assets: ${ids.join(', ')}`);
return { data: true };
});
wsc.method('assets:list', (type) => {
let assets = api.assets.list();
if (type) {
assets = assets.filter((asset) => asset.get('type') === type);
}
log('Listed assets');
return { data: assets.map((asset) => asset.json()) };
});
wsc.method('assets:instantiate', async (ids) => {
const assets = ids.map((id) => api.assets.get(id));
if (!assets.length) {
return { error: 'Assets not found' };
}
if (assets.some((asset) => asset.get('type') !== 'template')) {
return { error: 'Invalid template asset' };
}
const entities = await api.assets.instantiateTemplates(assets);
log(`Instantiated assets: ${ids.join(', ')}`);
return { data: entities.map((entity) => entity.json()) };
});
wsc.method('assets:property:set', (id, prop, value) => {
const asset = api.assets.get(id);
if (!asset) {
return { error: 'Asset not found' };
}
asset.set(`data.${prop}`, value);
log(`Set asset(${id}) property(${prop}) to: ${JSON.stringify(value)}`);
return { data: asset.json() };
});
wsc.method('assets:script:text:set', async (id, text) => {
const asset = api.assets.get(id);
if (!asset) {
return { error: 'Asset not found' };
}
const form = new FormData();
form.append('filename', asset.get('file.filename'));
form.append('file', new Blob([text], { type: 'text/plain' }), asset.get('file.filename'));
form.append('branchId', window.config.self.branch.id);
try {
const data = await rest('PUT', `assets/${id}`, form, true);
if (data.error) {
return { error: data.error };
}
log(`Set asset(${id}) script text`);
return { data };
} catch (e) {
return { error: e.message };
}
});
wsc.method('assets:script:parse', async (id) => {
const asset = api.assets.get(id);
if (!asset) {
return { error: 'Asset not found' };
}
// FIXME: This is a hacky way to get the parsed script data. Expose a proper API for this.
const [error, data] = await new Promise((resolve) => {
window.editor.call('scripts:parse', asset.observer, (...data) => resolve(data));
});
if (error) {
return { error };
}
if (Object.keys(data.scripts).length === 0) {
return { error: 'Failed to parse script' };
}
log(`Parsed asset(${id}) script`);
return { data };
});
// scenes
wsc.method('scene:settings:modify', (settings) => {
const scene = api.settings.scene;
iterateObject(settings, (path, value) => {
scene.set(path, value);
});
log('Modified scene settings');
return { data: scene.json() };
});
// store
// playcanvas
wsc.method('store:playcanvas:list', async (options = {}) => {
const params = [];
if (options.search) {
params.push(`search=${options.search}`);
}
params.push('regexp=true');
if (options.order) {
params.push(`order=${options.order}`);
}
if (options.skip) {
params.push(`skip=${options.skip}`);
}
if (options.limit) {
params.push(`limit=${options.limit}`);
}
try {
const data = await rest('GET', `store?${params.join('&')}`);
if (data.error) {
return { error: data.error };
}
log(`Searched store: ${JSON.stringify(options)}`);
return { data };
} catch (e) {
return { error: e.message };
}
});
wsc.method('store:playcanvas:get', async (id) => {
try {
const data = await rest('GET', `store/${id}`);
if (data.error) {
return { error: data.error };
}
log(`Got store item(${id})`);
return { data };
} catch (e) {
return { error: e.message };
}
});
wsc.method('store:playcanvas:clone', async (id, name, license) => {
try {
const data = await rest('POST', `store/${id}/clone`, {
scope: {
type: 'project',
id: window.config.project.id
},
name,
store: 'playcanvas',
targetFolderId: null,
license
});
if (data.error) {
return { error: data.error };
}
log(`Cloned store item(${id})`);
return { data };
} catch (e) {
return { error: e.message };
}
});
// sketchfab
wsc.method('store:sketchfab:list', async (options = {}) => {
const params = ['restricted=0', 'type=models', 'downloadable=true'];
if (options.search) {
params.push(`q=${options.search}`);
}
if (options.order) {
params.push(`sort_by=${options.order}`);
}
if (options.skip) {
params.push(`cursor=${options.skip}`);
}
if (options.limit) {
params.push(`count=${Math.min(options.limit ?? 0, 24)}`);
}
try {
const res = await fetch(`https://api.sketchfab.com/v3/search?${params.join('&')}`);
const data = await res.json();
if (data.error) {
return { error: data.error };
}
log(`Searched Sketchfab: ${JSON.stringify(options)}`);
return { data };
} catch (e) {
return { error: e.message };
}
});
wsc.method('store:sketchfab:get', async (uid) => {
try {
const res = await fetch(`https://api.sketchfab.com/v3/models/${uid}`);
const data = await res.json();
if (data.error) {
return { error: data.error };
}
log(`Got Sketchfab model(${uid})`);
return { data };
} catch (e) {
return { error: e.message };
}
});
wsc.method('store:sketchfab:clone', async (uid, name, license) => {
try {
const data = await rest('POST', `store/${uid}/clone`, {
scope: {
type: 'project',
id: window.config.project.id
},
name,
store: 'sketchfab',
targetFolderId: null,
license
});
if (data.error) {
return { error: data.error };
}
log(`Cloned sketchfab item(${uid})`);
return { data };
} catch (e) {
return { error: e.message };
}
});
})();
```
--------------------------------------------------------------------------------
/src/tools/schema/entity.ts:
--------------------------------------------------------------------------------
```typescript
import { z, type ZodTypeAny } from 'zod';
import { AssetIdSchema, RgbSchema, RgbaSchema, Vec2Schema, Vec3Schema, Vec4Schema } from './common';
const AudioListenerSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true')
}).describe('The data for the audio listener component.');
const CameraSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
clearColorBuffer: z.boolean().optional().describe('If true, the camera will explicitly clear its render target to the chosen clear color before rendering the scene. Default: true'),
clearColor: RgbaSchema.optional().describe('The color used to clear the camera\'s render target. Default: [0.118, 0.118, 0.118, 1]'),
clearDepthBuffer: z.boolean().optional().describe('If true, the camera will explicitly clear the depth buffer of its render target before rendering the scene. Default: true'),
renderSceneDepthMap: z.boolean().optional().describe('If true, the camera will render the scene depth map. Default: false'),
renderSceneColorMap: z.boolean().optional().describe('If true, the camera will render the scene color map. Default: false'),
projection: z.union([
z.literal(0).describe('PROJECTION_PERSPECTIVE'),
z.literal(1).describe('PROJECTION_ORTHOGRAPHIC')
]).optional().describe('The projection type of the camera. Default: 0 (PROJECTION_PERSPECTIVE)'),
fov: z.number().optional().describe('The angle (in degrees) between top and bottom clip planes of a perspective camera. Default: 45'),
frustumCulling: z.boolean().optional().describe('Controls the culling of mesh instances against the camera frustum. If true, culling is enabled. If false, all mesh instances in the scene are rendered by the camera, regardless of visibility. Default: true'),
orthoHeight: z.number().optional().describe('The distance in world units between the top and bottom clip planes of an orthographic camera. Default: 4'),
nearClip: z.number().min(0).optional().describe('The distance in camera space from the camera\'s eye point to the near plane. Default: 0.1'),
farClip: z.number().min(0).optional().describe('The distance in camera space from the camera\'s eye point to the far plane. Default: 1000'),
priority: z.number().optional().describe('A number that defines the order in which camera views are rendered by the engine. Smaller numbers are rendered first. Default: 0'),
rect: Vec4Schema.optional().describe('An array of 4 numbers that represents the rectangle that specifies the viewport onto the camera\'s attached render target. This allows you to implement features like split-screen or picture-in-picture. It is defined by normalized coordinates (0 to 1) in the following format: [The lower left x coordinate, The lower left y coordinate, The width of the rectangle, The height of the rectangle]. Default: [0, 0, 1, 1]'),
layers: z.array(z.number().int().min(0)).optional().describe('An array of layer id\'s that this camera will render. Default: [0, 1, 2, 3, 4]'),
toneMapping: z.union([
z.literal(0).describe('TONEMAP_LINEAR'),
z.literal(1).describe('TONEMAP_FILMIC'),
z.literal(2).describe('TONEMAP_HEJL'),
z.literal(3).describe('TONEMAP_ACES'),
z.literal(4).describe('TONEMAP_ACES2'),
z.literal(5).describe('TONEMAP_NEUTRAL'),
z.literal(6).describe('TONEMAP_NONE')
]).optional().describe('The tonemapping transform to apply to the final color of the camera. Default: 0 (TONEMAP_LINEAR)'),
gammaCorrection: z.union([
z.literal(0).describe('GAMMA_NONE'),
z.literal(1).describe('GAMMA_SRGB')
]).optional().describe('The gamma correction to apply to the final color of the camera. Default: 1 (GAMMA_SRGB)')
}).describe('The data for the camera component.');
const CollisionSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
type: z.enum(['box', 'sphere', 'capsule', 'cylinder', 'mesh']).optional().describe('The type of collision primitive. Can be: box, sphere, capsule, cylinder, mesh. Default: "box"'),
halfExtents: Vec3Schema.optional().describe('The half-extents of the collision box. This is an array of 3 numbers: local space half-width, half-height, and half-depth. Default: [0.5, 0.5, 0.5]'),
radius: z.number().min(0).optional().describe('The radius of the capsule/cylinder body. Default: 0.5'),
axis: z.union([
z.literal(0).describe('X'),
z.literal(1).describe('Y'),
z.literal(2).describe('Z')
]).optional().describe('Aligns the capsule/cylinder with the local-space X, Y or Z axis of the entity. Default: 1'),
height: z.number().min(0).optional().describe('The tip-to-tip height of the capsule/cylinder. Default: 2'),
convexHull: z.boolean().optional().describe('If true, the collision shape will be a convex hull. Default: false'),
asset: AssetIdSchema.optional().describe('The `id` of the model asset that will be used as a source for the triangle-based collision mesh. Default: null'),
renderAsset: AssetIdSchema.optional().describe('The `id` of the render asset that will be used as a source for the triangle-based collision mesh. Default: null'),
linearOffset: Vec3Schema.optional().describe('The positional offset of the collision shape from the Entity position along the local axes. Default: [0, 0, 0]'),
angularOffset: Vec3Schema.optional().describe('The rotational offset of the collision shape from the Entity rotation in local space. Default: [0, 0, 0]')
}).describe('The data for the collision component.');
const ElementSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
type: z.enum(['text', 'image', 'group']).optional().describe('The type of the element. Default: "text"'),
anchor: Vec4Schema.optional().describe('An array of 4 numbers controlling the left, bottom, right and top anchors of the element. Default: [0.5, 0.5, 0.5, 0.5]'),
pivot: Vec2Schema.optional().describe('An array of 2 numbers controlling the origin of the element. Default: [0.5, 0.5]'),
text: z.string().optional().describe('The text content of the element. Default: ""'),
key: z.string().nullable().optional().describe('The localization key of the element. Default: null'),
fontAsset: AssetIdSchema.optional().describe('The `id` of the font asset used by the element. Default: null'),
fontSize: z.number().optional().describe('The size of the font used by the element. Default: 32'),
minFontSize: z.number().optional().describe('The minimum size of the font when using `autoFitWidth` or `autoFitHeight`. Default: 8'),
maxFontSize: z.number().optional().describe('The maximum size of the font when using `autoFitWidth` or `autoFitHeight`. Default: 32'),
autoFitWidth: z.boolean().optional().describe('Automatically scale the font size to fit the element\'s width. Default: false'),
autoFitHeight: z.boolean().optional().describe('Automatically scale the font size to fit the element\'s height. Default: false'),
maxLines: z.number().nullable().optional().describe('The maximum number of lines that this element can display. Default: null'),
lineHeight: z.number().optional().describe('The height of each line of text. Default: 32'),
wrapLines: z.boolean().optional().describe('Automatically wrap lines based on the element width. Default: true'),
spacing: z.number().optional().describe('The spacing between each letter of the text. Default: 1'),
color: RgbSchema.optional().describe('The RGB color of the element. Default: [1, 1, 1]'),
opacity: z.number().min(0).max(1).optional().describe('The opacity of the element. Default: 1'),
textureAsset: AssetIdSchema.optional().describe('The `id` of the texture asset to be used by the element. Default: null'),
spriteAsset: AssetIdSchema.optional().describe('The `id` of the sprite asset to be used by the element. Default: null'),
spriteFrame: z.number().optional().describe('The frame from the sprite asset to render. Default: 0'),
pixelsPerUnit: z.number().nullable().optional().describe('Number of pixels per PlayCanvas unit (used for 9-sliced sprites). Default: null'),
width: z.number().optional().describe('The width of the element. Default: 32'),
height: z.number().optional().describe('The height of the element. Default: 32'),
margin: Vec4Schema.optional().describe('Spacing between each edge of the element and the respective anchor. Default: [-16, -16, -16, -16]'),
alignment: Vec2Schema.optional().describe('Horizontal and vertical alignment of the text relative to its element transform. Default: [0.5, 0.5]'),
outlineColor: RgbaSchema.optional().describe('Text outline effect color and opacity. Default: [0, 0, 0, 1]'),
outlineThickness: z.number().optional().describe('Text outline effect width (0–1). Default: 0'),
shadowColor: RgbaSchema.optional().describe('Text shadow color and opacity. Default: [0, 0, 0, 1]'),
shadowOffset: Vec2Schema.optional().describe('Horizontal and vertical offset of the text shadow. Default: [0.0, 0.0]'),
rect: Vec4Schema.optional().describe('Texture rect for the image element (u, v, width, height). Default: [0, 0, 1, 1]'),
materialAsset: AssetIdSchema.optional().describe('The `id` of the material asset used by this element. Default: null'),
autoWidth: z.boolean().optional().describe('Automatically size width to match text content. Default: false'),
autoHeight: z.boolean().optional().describe('Automatically size height to match text content. Default: false'),
fitMode: z.enum(['stretch', 'contain', 'cover']).optional().describe('Set how the content should be fitted and preserve the aspect ratio. Default: "stretch"'),
useInput: z.boolean().optional().describe('Enable this to make the element respond to input events. Default: false'),
batchGroupId: z.number().nullable().optional().describe('The batch group id that this element belongs to. Default: null'),
mask: z.boolean().optional().describe('If true, this element acts as a mask for its children. Default: false'),
layers: z.array(z.number()).optional().describe('An array of layer ids that this element belongs to. Default: [4]'),
enableMarkup: z.boolean().optional().describe('Enable markup processing (only for text elements). Default: false')
}).describe('The data for the element component.');
const LightSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
type: z.enum(['directional', 'spot', 'omni']).optional().describe('The type of light. Can be: directional, spot, omni. Default: "directional"'),
bake: z.boolean().optional().describe('If true the light will be rendered into lightmaps. Default: false'),
bakeArea: z.number().min(0).max(180).optional().describe('If bake is enabled, specifies the directional light penumbra angle in degrees, allowing soft shadows. Default: 0'),
bakeNumSamples: z.number().int().min(1).max(255).optional().describe('If bake is enabled, specifies the number of samples used to bake this light into the lightmap. Default: 1'),
bakeDir: z.boolean().optional().describe('If true and `bake` is true, the light\'s direction will contribute to directional lightmaps. Default: true'),
affectDynamic: z.boolean().optional().describe('If true the light will affect non-lightmapped objects. Default: true'),
affectLightmapped: z.boolean().optional().describe('If true the light will affect lightmapped objects. Default: false'),
affectSpecularity: z.boolean().optional().describe('If true the light will affect material specularity. For directional light only. Default: true.'),
color: RgbSchema.optional().describe('An array of 3 numbers that represents the color of the emitted light. Default: [1, 1, 1]'),
intensity: z.number().min(0).max(32).optional().describe('The intensity of the light, this acts as a scalar value for the light\'s color. This value can exceed 1. Default: 1'),
castShadows: z.boolean().optional().describe('If true, the light will cause shadow casting models to cast shadows. Default: false'),
shadowUpdateMode: z.union([
z.literal(1).describe('SHADOWUPDATE_THISFRAME'),
z.literal(2).describe('SHADOWUPDATE_REALTIME')
]).optional().describe('Tells the renderer how often shadows must be updated for this light. Default: 2 (SHADOWUPDATE_REALTIME)'),
shadowType: z.union([
z.literal(0).describe('SHADOW_PCF3_32F'),
z.literal(2).describe('SHADOW_VSM_16F'),
z.literal(3).describe('SHADOW_VSM_32F'),
z.literal(4).describe('SHADOW_PCF5_32F'),
z.literal(5).describe('SHADOW_PCF1_32F'),
z.literal(6).describe('SHADOW_PCSS_32F'),
z.literal(7).describe('SHADOW_PCF1_16F'),
z.literal(8).describe('SHADOW_PCF3_16F'),
z.literal(9).describe('SHADOW_PCF5_16F')
]).optional().describe('Type of shadows being rendered by this light. Default: 0 (SHADOW_PCF3_32F)'),
vsmBlurMode: z.union([
z.literal(0).describe('BLUR_BOX'),
z.literal(1).describe('BLUR_GAUSSIAN')
]).optional().describe('Blurring mode for variance shadow maps. Default: 1 (BLUR_GAUSSIAN)'),
vsmBlurSize: z.number().int().min(1).max(25).optional().describe('Number of samples used for blurring a variance shadow map. Only uneven numbers work, even are incremented. Minimum value is 1, maximum is 25. Default: 11'),
vsmBias: z.number().min(0).max(1).optional().describe('Constant depth offset applied to a shadow map to eliminate rendering artifacts like shadow acne. Default: 0.01'),
shadowDistance: z.number().min(0).optional().describe('The shadow distance is the maximum distance from the camera beyond which shadows from Directional Lights are no longer visible. Default: 16'),
shadowIntensity: z.number().min(0).optional().describe('The intensity of the shadow darkening, 1 being shadows are entirely black. Default: 1'),
shadowResolution: z.union([
z.literal(16).describe('16x16'),
z.literal(32).describe('32x32'),
z.literal(64).describe('64x64'),
z.literal(128).describe('128x128'),
z.literal(256).describe('256x256'),
z.literal(512).describe('512x512'),
z.literal(1024).describe('1024x1024'),
z.literal(2048).describe('2048x2048'),
z.literal(4096).describe('4096x4096')
]).optional().describe('The size of the texture used for the shadow map (power of 2). Default: 1024'),
numCascades: z.number().int().min(1).max(4).optional().describe('Number of shadow cascades. Default: 1'),
cascadeDistribution: z.number().optional().describe('The distribution of subdivision of the camera frustum for individual shadow cascades. Default: 0.5'),
shadowBias: z.number().min(0).max(1).optional().describe('Constant depth offset applied to a shadow map to eliminate artifacts. Default: 0.2'),
normalOffsetBias: z.number().min(0).max(1).optional().describe('Normal offset depth bias. Default: 0.05'),
range: z.number().min(0).optional().describe('The distance from the spotlight source at which its contribution falls to zero. Default: 8'),
falloffMode: z.union([
z.literal(0).describe('LIGHTFALLOFF_LINEAR'),
z.literal(1).describe('LIGHTFALLOFF_INVERSESQUARED')
]).optional().describe('Controls the rate at which a light attenuates from its position. Default: 0 (LIGHTFALLOFF_LINEAR)'),
innerConeAngle: z.number().min(0).max(45).optional().describe('The angle at which the spotlight cone starts to fade off (degrees). Affects spot lights only. Default: 40'),
outerConeAngle: z.number().min(0).max(90).optional().describe('The angle at which the spotlight cone has faded to nothing (degrees). Affects spot lights only. Default: 45'),
shape: z.union([
z.literal(0).describe('LIGHTSHAPE_PUNCTUAL'),
z.literal(1).describe('LIGHTSHAPE_RECT'),
z.literal(2).describe('LIGHTSHAPE_DISK'),
z.literal(3).describe('LIGHTSHAPE_SPHERE')
]).optional().describe('The shape of the light source. Default: 0 (LIGHTSHAPE_PUNCTUAL)'),
cookieAsset: AssetIdSchema.optional().describe('The id of a texture asset that represents that light cookie. Default: null'),
cookieIntensity: z.number().min(0).max(1).optional().describe('Projection texture intensity. Default: 1.0'),
cookieFalloff: z.boolean().optional().describe('Toggle normal spotlight falloff when projection texture is used. Default: true'),
cookieChannel: z.enum(['r', 'g', 'b', 'a', 'rgb']).optional().describe('Color channels of the projection texture to use. Can be "r", "g", "b", "a", "rgb" or any swizzled combination. Default: "rgb"'),
cookieAngle: z.number().optional().describe('Angle for spotlight cookie rotation. Default: 0.0'),
cookieScale: Vec2Schema.optional().describe('Spotlight cookie scale. Default: [1.0, 1.0]'),
cookieOffset: Vec2Schema.optional().describe('Spotlight cookie position offset. Default: [0.0, 0.0]'),
isStatic: z.boolean().optional().describe('Mark light as non-movable (optimization). Default: false'),
layers: z.array(z.number().int().min(0)).optional().describe('An array of layer id\'s that this light will affect. Default: [0]')
}).describe('The data for the light component.');
const RenderSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
type: z.enum(['asset', 'box', 'capsule', 'sphere', 'cylinder', 'cone', 'plane']).optional().describe('The type of the render component. Can be: asset, box, capsule, cone, cylinder, plane, sphere. Default: "asset"'),
asset: z.number().int().nullable().optional().describe('The `id` of the render asset for the render component (only applies to type "asset"). Default: null'),
materialAssets: z.array(z.number().int().nullable()).optional().describe('An array of material asset `id`s that will be used to render the meshes. Each material corresponds to the respective mesh instance. Default: []'),
layers: z.array(z.number().int().min(0)).optional().describe('An array of layer id\'s to which the meshes should belong. Default: [0]'),
batchGroupId: z.number().int().nullable().optional().describe('The batch group id that the meshes should belong to. Default: null'),
castShadows: z.boolean().optional().describe('If true, attached meshes will cast shadows for lights that have shadow casting enabled. Default: true'),
castShadowsLightmap: z.boolean().optional().describe('If true, the meshes will cast shadows when rendering lightmaps. Default: true'),
receiveShadows: z.boolean().optional().describe('If true, shadows will be cast on attached meshes. Default: true'),
lightmapped: z.boolean().optional().describe('If true, the meshes will be lightmapped after using lightmapper.bake(). Default: false'),
lightmapSizeMultiplier: z.number().optional().describe('Lightmap resolution multiplier. Default: 1.0'),
isStatic: z.boolean().optional().describe('Mark meshes as non-movable (optimization). Default: false'),
rootBone: z.string().nullable().optional().describe('The `resource_id` of the entity to be used as the root bone for any skinned meshes that are rendered by this component. Default: null'),
aabbCenter: Vec3Schema.optional().describe('An array of 3 numbers controlling the center of the AABB to be used. Default: [0, 0, 0]'),
aabbHalfExtents: Vec3Schema.optional().describe('An array of 3 numbers controlling the half extents of the AABB to be used. Default: [0.5, 0.5, 0.5]')
}).describe('The data for the render component.');
const RigidBodySchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
type: z.enum(['static', 'dynamic', 'kinematic']).optional().describe('The type of RigidBody determines how it is simulated. Can be one of: static, dynamic, kinematic. Default: "static"'),
mass: z.number().min(0).optional().describe('The mass of the body. Default: 1'),
linearDamping: z.number().min(0).max(1).optional().describe('Controls the rate at which a body loses linear velocity over time. Default: 0'),
angularDamping: z.number().min(0).max(1).optional().describe('Controls the rate at which a body loses angular velocity over time. Default: 0'),
linearFactor: Vec3Schema.optional().describe('An array of 3 numbers that represents the scaling factor for linear movement of the body in each axis. Default: [1, 1, 1]'),
angularFactor: Vec3Schema.optional().describe('An array of 3 numbers that represents the scaling factor for angular movement of the body in each axis. Default: [1, 1, 1]'),
friction: z.number().min(0).max(1).optional().describe('The friction value used when contacts occur between two bodies. Default: 0.5'),
restitution: z.number().min(0).max(1).optional().describe('The amount of energy lost when two objects collide, this determines the bounciness of the object. Default: 0.5')
}).describe('The data for the rigidbody component.');
const ScreenSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
screenSpace: z.boolean().optional().describe('If true then the screen will display its child Elements in 2D. Set this to false to make this a 3D screen. Default: true'),
scaleMode: z.enum(['none', 'blend']).optional().describe('Controls how a screen-space screen is resized when the window size changes. Can be: `pc.SCALEMODE_BLEND`: Use it to have the screen adjust between the difference of the window resolution and the screen\'s reference resolution. `pc.SCALEMODE_NONE`: Use it to make the screen always have a size equal to its resolution. Default: "blend"'),
scaleBlend: z.number().min(0).max(1).optional().describe('Set this to 0 to only adjust to changes between the width of the window and the x of the reference resolution. Set this to 1 to only adjust to changes between the window height and the y of the reference resolution. A value in the middle will try to adjust to both. Default: 0.5'),
resolution: Vec2Schema.optional().describe('An array of 2 numbers that represents the resolution of the screen. Default: [1280, 720]'),
referenceResolution: Vec2Schema.optional().describe('An array of 2 numbers that represents the reference resolution of the screen. If the window size changes the screen will adjust its size based on `scaleMode` using the reference resolution. Default: [1280, 720]'),
priority: z.number().int().min(0).max(127).optional().describe('Determines the order in which Screen components in the same layer are rendered (higher priority is rendered on top). Number must be an integer between 0 and 127. Default: 0')
}).describe('The data for the screen component.');
const ScriptAttributeSchema = z.any().describe('A dictionary that holds the values of each attribute. The keys in the dictionary are the attribute names.');
const ScriptInstanceSchema = z.object({
enabled: z.boolean().optional().describe('Whether the script instance is enabled. Default: true'),
attributes: z.record(ScriptAttributeSchema).optional().describe('A dictionary that holds the values of each attribute. The keys in the dictionary are the attribute names. Default: {}')
});
const ScriptSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
order: z.array(z.string()).optional().describe('An array of script names in the order in which they should be executed at runtime. Default: []'),
scripts: z.record(ScriptInstanceSchema).optional().describe('A dictionary that contains all the scripts attached to this script component. Each key in the dictionary is the script name. Default: {}')
}).describe('The data for the script component.');
const SoundSlotSchema = z.object({
name: z.string().optional().describe('The name of the sound slot. Default: "Slot 1"'),
volume: z.number().min(0).max(1).optional().describe('The volume modifier to play the audio with. Default: 1'),
pitch: z.number().min(0).optional().describe('The pitch to playback the audio at. A value of 1 means the audio is played back at the original pitch. Default: 1'),
asset: AssetIdSchema.optional().describe('The `id` of the audio asset that can be played from this sound slot. Default: null'),
startTime: z.number().optional().describe('The start time from which the sound will start playing. Default: 0'),
duration: z.number().nullable().optional().describe('The duration of the sound that the slot will play starting from startTime. Default: null'),
loop: z.boolean().optional().describe('If true, the slot will loop playback continuously. Otherwise, it will be played once to completion. Default: false'),
autoPlay: z.boolean().optional().describe('If true, the slot will be played on load. Otherwise, sound slots will need to be played by scripts. Default: false'),
overlap: z.boolean().optional().describe('If true then sounds played from slot will be played independently of each other. Otherwise the slot will first stop the current sound before starting the new one. Default: false')
}).partial();
const SoundSchema = z.object({
enabled: z.boolean().optional().describe('Whether the component is enabled. Default: true'),
volume: z.number().min(0).max(1).optional().describe('The volume modifier to play the audio with. The volume of each slot is multiplied with this value. Default: 1'),
pitch: z.number().min(0).optional().describe('The pitch to playback the audio at. A value of 1 means the audio is played back at the original pitch. The pitch of each slot is multiplied with this value. Default: 1'),
positional: z.boolean().optional().describe('If true, the component will play back audio assets as if played from the location of the entity in 3D space. Default: true'),
refDistance: z.number().min(0).optional().describe('The reference distance for reducing volume as the sound source moves further from the listener. Default: 1'),
maxDistance: z.number().min(0).optional().describe('The maximum distance from the listener at which audio falloff stops. Note the volume of the audio is not 0 after this distance, but just doesn\'t fall off anymore. Default: 10000'),
rollOffFactor: z.number().min(0).optional().describe('The rate at which volume fall-off occurs. Default: 1'),
distanceModel: z.enum(['linear', 'inverse', 'exponential']).optional().describe('Determines which algorithm to use to reduce the volume of the audio as it moves away from the listener. Can be one of: "inverse", "linear", "exponential". Default: "linear"'),
slots: z.record(SoundSlotSchema).default({
'1': {
name: 'Slot 1',
loop: false,
autoPlay: false,
overlap: false,
asset: null,
startTime: 0,
duration: null,
volume: 1,
pitch: 1
}
}).describe('A dictionary of sound slots. Each sound slot controls playback of an audio asset. Each key in the dictionary is a number representing the index of each sound slot.')
}).describe('The data for the sound component.');
export const ComponentsSchema = z.object({
audiolistener: AudioListenerSchema.optional(),
camera: CameraSchema.optional(),
collision: CollisionSchema.optional(),
element: ElementSchema.optional(),
light: LightSchema.optional(),
render: RenderSchema.optional(),
rigidbody: RigidBodySchema.optional(),
screen: ScreenSchema.optional(),
script: ScriptSchema.optional(),
sound: SoundSchema.optional()
}).describe('A dictionary that contains the components of the entity and their data.');
export const ComponentNameSchema = z.enum([
'anim',
'animation',
'audiolistener',
'button',
'camera',
'collision',
'element',
'layoutchild',
'layoutgroup',
'light',
'model',
'particlesystem',
'render',
'rigidbody',
'screen',
'script',
'scrollbar',
'scrollview',
'sound',
'sprite'
]);
export const EntitySchema: z.ZodOptional<ZodTypeAny> = z.lazy(() => z.object({
name: z.string().optional().describe('The name of the entity. Default: "Untitled"'),
enabled: z.boolean().optional().describe('Whether the entity is enabled. Default: true'),
tags: z.array(z.string()).optional().describe('The tags of the entity. Default: []'),
children: z.array(EntitySchema).optional().describe('An array that contains the child entities. Default: []'),
position: Vec3Schema.optional().describe('The position of the entity in local space (x, y, z). Default: [0, 0, 0]'),
rotation: Vec3Schema.optional().describe('The rotation of the entity in local space (rx, ry, rz euler angles in degrees). Default: [0, 0, 0]'),
scale: Vec3Schema.optional().describe('The scale of the entity in local space (sx, sy, sz). Default: [1, 1, 1]'),
components: ComponentsSchema.optional().describe('The components of the entity and their data. Default: {}')
})).optional();
```