Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s

This commit is contained in:
emil28092005
2026-09-12 04:58:59 +03:00
parent 6f497d1977
commit 3afac743a4
32 changed files with 4999 additions and 270 deletions
+60 -5
View File
@@ -11,7 +11,13 @@ import { defaultProject } from "../engine/templates.ts";
import { entity, uid, validateProject } from "../engine/schema.ts";
import { projectArchive, gameArchive, decodeData } from "../engine/archive.ts";
import { createMcp, type EngineService } from "./mcp.ts";
import { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts";
import {
portableModel,
modelComponents,
modelDataUri,
modelProject,
} from "../engine/model-import.ts";
import { imageAsset } from "../engine/image-import.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
@@ -23,6 +29,9 @@ const mime: Record<string, string> = {
".svg": "image/svg+xml",
".wasm": "application/wasm",
".png": "image/png",
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".zip": "application/zip",
};
export function inside(base: string, requested: string) {
@@ -201,6 +210,31 @@ export async function createService(
"Unzip and serve over HTTP. Entry index.html. No editor or MCP required.",
};
},
importImage: async (a: any) => {
if (Boolean(a.base64) === Boolean(a.path))
throw Error("Supply exactly one of base64 or path");
const bytes = a.base64
? Buffer.from(a.base64, "base64")
: await safeRead(projectDir, a.path);
const asset = imageAsset(bytes, a.name),
commands: any[] = [{ op: "asset.upsert", args: { asset } }];
if (a.instantiate)
commands.push({
op: "node.create",
args: {
entity: entity(asset.name, {
sprite: { assetId: asset.id, frame: 0 },
}),
},
});
return store.transaction({
commands,
expectedRevision: a.expectedRevision,
requestId: a.requestId,
label: "Импорт спрайта " + asset.name,
source: "mcp",
});
},
importModel: async (a: any) => {
if (Boolean(a.base64) === Boolean(a.path))
throw Error("Supply exactly one of base64 or path");
@@ -208,13 +242,34 @@ export async function createService(
? Buffer.from(a.base64, "base64")
: Buffer.from(await safeRead(projectDir, a.path));
const sourceName = a.path || a.name;
const model = await portableModel(bytes, sourceName, a.path ? file => safeRead(projectDir, file) : undefined);
const model = await portableModel(
bytes,
sourceName,
a.path ? (file) => safeRead(projectDir, file) : undefined,
);
const id = uid("asset");
const asset = { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model) };
const asset = {
id,
name: model.name,
kind: "model",
metadata: model.metadata,
uri: modelDataUri(model),
};
const commands: any[] = [{ op: "asset.upsert", args: { asset } }];
if (a.instantiate) commands.push({ op: "node.create", args: { entity: entity(model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata)) } });
if (a.instantiate)
commands.push({
op: "node.create",
args: {
entity: entity(
model.name.replace(/\.(glb|gltf)$/i, ""),
modelComponents(id, model.metadata),
),
},
});
return store.transaction({
commands: a.asScene ? [{ op: "project.replace", args: { project: modelProject(model) } }] : commands,
commands: a.asScene
? [{ op: "project.replace", args: { project: modelProject(model) } }]
: commands,
expectedRevision: a.expectedRevision,
requestId: a.requestId,
label: "Импорт " + a.name,
+141 -12
View File
@@ -11,6 +11,7 @@ export interface EngineService {
status: () => any;
save: () => Promise<any>;
exportWeb: () => Promise<any>;
importImage?: (a: any) => Promise<any>;
importModel: (a: any) => Promise<any>;
runtime: (action: string, args?: any) => Promise<any>;
}
@@ -23,7 +24,9 @@ export const commandReference = {
"project.rename": "{name}",
"project.settings":
'{background:"#dedbd2",ambient:0.85,shadows:true,renderScale:1}',
"scene.create": "{id?,name}",
"scene.create": "{id?,name,mode?:2d|3d}",
"scene.configure":
"{sceneId?,mode:2d|3d}; default editor view and fallback game camera",
"scene.activate": "{id}",
"scene.rename": "{sceneId,name}",
"node.create":
@@ -36,7 +39,7 @@ export const commandReference = {
"component.set": "{id,type,value,sceneId?}; replaces component",
"component.remove": "{id,type,sceneId?}",
"asset.upsert":
'{asset:{id,name,kind:"model"|"geometry"|"prefab",uri?,geometry?,entities?,metadata?}}',
'{asset:{id,name,kind:"model"|"geometry"|"prefab"|"image",uri?,geometry?,entities?,metadata?,image?}}',
"asset.delete": "{id}; fails if referenced",
"script.upsert":
'{script:{id,name,source,fields:{speed:{type:"number",default:5,label:"Speed",min:0,max:30}}}}',
@@ -44,6 +47,74 @@ export const commandReference = {
"prefab.instantiate": "{assetId,position?,sceneId?}",
},
components: {
sprite: {
assetId: "image asset or empty for white quad",
frame: 0,
size: "optional [width,height], otherwise frame pixels / PPU",
color: "#ffffff",
alpha: 1,
layer: 0,
order: 0,
flipX: false,
flipY: false,
sortY: false,
lit: false,
},
spriteAnimator: {
autoplay: "idle",
autoStates: false,
clips: [{ name: "idle", frames: [0, 1], fps: 8, loop: true }],
},
tilemap: {
assetId: "image asset",
tileSize: [1, 1],
width: 32,
height: 18,
cells: [{ x: 0, y: 0, frame: 0, solid: true }],
collisions: false,
layer: 0,
order: 0,
},
collider2d: {
shape: "box | circle | capsule | polygon",
size: [1, 1],
radius: 0.5,
height: 1.8,
points: "convex polygon vertices [[x,y],...]",
offset: [0, 0],
sensor: false,
oneWay: false,
membership: 1,
mask: 65535,
},
rigidbody2d: {
type: "fixed | dynamic | kinematic",
mass: 1,
friction: 0.5,
restitution: 0,
gravityScale: 1,
lockRotation: true,
ccd: true,
requires: "collider2d or colliding tilemap",
},
character2d: {
mode: "platformer | topDown",
controls: true,
speed: 5,
jumpSpeed: 8,
gravity: 20,
autostep: 0.2,
requires: "kinematic rigidbody2d + collider2d",
},
joint2d: {
type: "fixed | revolute | rope | spring",
targetId: "another body2d",
anchor: [0, 0],
targetAnchor: [0, 0],
length: 1,
stiffness: 50,
damping: 5,
},
mesh: {
type: "box | sphere | cylinder | icosphere | torus | model | geometry | custom",
size: [1, 1, 1],
@@ -71,8 +142,22 @@ export const commandReference = {
mass: 1,
restitution: 0.1,
},
camera: { mode: "follow | firstPerson", targetId: "subject", offset: [0, 13, -10], fov: 0.72, yaw: 0, pitch: 0 },
character: { gravity: 24, autostep: 0.25, requires: "kinematic rigidbody + capsule collider" },
camera: {
mode: "follow | firstPerson | fixed | imported | 2d",
orthoWidth: 20,
pixelPerfect: false,
pixelsPerUnit: 100,
targetId: "subject",
offset: [0, 13, -10],
fov: 0.72,
yaw: 0,
pitch: 0,
},
character: {
gravity: 24,
autostep: 0.25,
requires: "kinematic rigidbody + capsule collider",
},
sign: { text: "Text in the scene", color: "#d7f34b", width: 5 },
light: { color: "#fff1da", intensity: 2 },
animator: {
@@ -95,19 +180,28 @@ export const scriptReference = {
api: {
state: "Persistent per-instance mutable data during one play run",
params: "Field defaults + component.script.params",
input: "{x,z,attack,pointer,aim,jump,dash,sprint,jumpPressed,dashPressed,resetPressed,yaw,pitch}; yaw=0 faces -Z in first person",
input:
"{x,y,z,attack,pointer,aim,jump,dash,sprint,jumpPressed,dashPressed,resetPressed,yaw,pitch}; yaw=0 faces -Z in first person",
get: "api.get(id?) -> clone of entity or null",
entities: "api.entities() -> clones of entity states",
position: "api.position(id?) -> local coordinates",
move: "api.move([dx,dy,dz]); real collisions for kinematic body",
physics: "api.physics(id?) -> {grounded,velocity:{x,y,z},contacts:[{entityId,normal:[x,y,z]}]}",
velocity: "api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires character component; gravity runs at 60 Hz",
teleport: "api.teleport([x,y,z],yaw?); clears velocity and contacts for character respawn",
physics:
"api.physics(id?) -> {grounded,velocity:{x,y,z},contacts:[{entityId,normal:[x,y,z]}]}",
velocity:
"api.velocity({x?,y?,z?,gravityScale?}); persistent m/s, requires 3D character or any body2d; 2D uses x/y. Gravity runs at 60 Hz",
teleport:
"api.teleport([x,y,z],yaw?); clears velocity and contacts for character respawn",
emit: "api.emit(name,data?); delivers a presentation event to runtime callbacks",
rotate: "api.rotate(yRadians)",
rotate2D: "api.rotate2D(zRadians)",
impulse2D: "api.impulse2D([x,y]); applies impulse to dynamic body2d",
collision2d:
"Optional behavior hook collision2d(api,event): {entityId,otherId,started,sensor}. Runs before update.",
patch:
"api.patch(id,patch); updates state/transform/enabled. Structural mesh/collider edits take effect next Play.",
animate: "api.animate(clipOrState,loop=true); use false for a one-shot animation",
animate:
"api.animate(clipOrState,loop=true); use false for a one-shot animation",
effect: 'api.effect("swing"|"hit",id?)',
spawn:
"api.spawn(prefabAssetId,position); behaviors start on spawned instances",
@@ -314,14 +408,23 @@ export function createMcp(s: EngineService) {
tool(
"scene_create",
"Create and activate a scene.",
{ ...revision, name: z.string(), id: z.string().optional() },
{
...revision,
name: z.string(),
id: z.string().optional(),
mode: z.enum(["2d", "3d"]).optional(),
},
(a) =>
tx(
a,
[
{
op: "scene.create",
args: { name: a.name, ...(a.id ? { id: a.id } : {}) },
args: {
name: a.name,
...(a.id ? { id: a.id } : {}),
...(a.mode ? { mode: a.mode } : {}),
},
},
],
"Создать сцену",
@@ -471,6 +574,21 @@ export function createMcp(s: EngineService) {
);
},
);
tool(
"asset_import_image",
"Import PNG, JPEG or WebP as an image asset and optional XY sprite. Configure asset.image with asset.upsert for slicing, PPU, filter and pivots.",
{
...revision,
name: z.string().regex(/\.(png|jpe?g|webp)$/i),
base64: z.string().max(36_000_000).optional(),
path: z.string().optional(),
instantiate: z.boolean().default(true),
},
(a) => {
if (!s.importImage) throw Error("Image import unavailable");
return s.importImage(a);
},
);
tool(
"asset_import_glb",
"Import GLB, glTF with companion files, or a ZIP containing one model and its textures. Use base64 bytes OR a path inside the project folder. External files resolve only within that folder; network URLs are not fetched.",
@@ -480,7 +598,12 @@ export function createMcp(s: EngineService) {
base64: z.string().max(36_000_000).optional(),
path: z.string().optional(),
instantiate: z.boolean().default(true),
asScene: z.boolean().default(false).describe("Replace the project with the complete imported scene, using its active/first camera and lights. Undoable."),
asScene: z
.boolean()
.default(false)
.describe(
"Replace the project with the complete imported scene, using its active/first camera and lights. Undoable.",
),
},
(a) => s.importModel(a),
);
@@ -585,6 +708,12 @@ export function createMcp(s: EngineService) {
"Send timed movement, first-person view, jump, dash or attack to the running game and return observed state.",
{
x: z.number().min(-1).max(1).default(0),
y: z
.number()
.min(-1)
.max(1)
.optional()
.describe("Vertical XY input for 2D"),
z: z.number().min(-1).max(1).default(0),
attack: z.boolean().default(false),
jump: z.boolean().default(false),