Publish Forma Engine 0.3.0 source with documentation and CI

This commit is contained in:
emil28092005
2026-09-09 15:49:08 +03:00
commit e52bc0e33b
70 changed files with 19610 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
import { type Project, clone, validateProject } from "./schema.ts";
export const mimeFor = (n: string) =>
n.toLowerCase().endsWith(".gltf") ? "model/gltf+json" : "model/gltf-binary";
export function base64(a: Uint8Array) {
let s = "";
for (let i = 0; i < a.length; i += 16384)
s += String.fromCharCode(...a.subarray(i, i + 16384));
return btoa(s);
}
export function decodeData(uri: string) {
const i = uri.indexOf(",");
if (i < 0 || !uri.slice(0, i).endsWith(";base64"))
throw Error("Ожидался data URI base64");
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
}
export async function readBytes(uri: string) {
if (uri.startsWith("data:")) return decodeData(uri);
const r = await fetch(uri);
if (!r.ok) throw Error("Не удалось загрузить " + uri);
return new Uint8Array(await r.arrayBuffer());
}
async function pack(
project: Project,
read: (uri: string) => Promise<Uint8Array>,
) {
validateProject(project);
const p = clone(project),
files: Record<string, Uint8Array> = {};
for (const a of p.assets)
if (a.uri) {
const file =
"assets/" +
a.id +
(a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb");
files[file] = await read(a.uri);
a.uri = file;
}
return { p, files };
}
export async function projectArchive(
project: Project,
read: (uri: string) => Promise<Uint8Array> = readBytes,
) {
const { p, files } = await pack(project, read);
files["project.forma.json"] = strToU8(JSON.stringify(p, null, 2));
for (const s of p.scripts)
files["scripts/" + s.id + ".js"] = strToU8(s.source);
files["README.txt"] = strToU8(
"Open this .forma archive in Forma. project.forma.json is authoritative. scripts/ contains readable copies.\n",
);
return zipSync(files, { level: 6 });
}
export async function gameArchive(
project: Project,
read: (uri: string) => Promise<Uint8Array> = readBytes,
) {
const { p, files } = await pack(project, read);
files["project.forma.json"] = strToU8(JSON.stringify(p));
files["player.js"] = await read("/engine/player.js");
files["player.css"] = await read("/engine/player.css");
files["index.html"] = strToU8(
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>' +
p.name.replace(/[<>&"]/g, "") +
'</title><link rel="stylesheet" href="./player.css"></head><body><canvas id="game"></canvas><div id="game-ui"></div><script type="module" src="./player.js"></script></body></html>',
);
files["README.txt"] = strToU8(
"Serve this directory with an HTTP server, e.g. python3 -m http.server 8080. Open http://localhost:8080. No editor or MCP required.\n",
);
return zipSync(files, { level: 6 });
}
export function unpackProject(bytes: Uint8Array): Project {
let p: Project;
if (bytes.length > 80 * 1024 * 1024) throw Error("Проект превышает 80 МБ");
if (strFromU8(bytes.subarray(0, 20)).trimStart().startsWith("{"))
p = JSON.parse(strFromU8(bytes));
else {
let total = 0;
const files = unzipSync(bytes, {
filter: (f) => {
total += f.originalSize;
if (total > 200 * 1024 * 1024 || f.originalSize > 60 * 1024 * 1024)
throw Error("Распакованный проект превышает лимит");
if (
f.name.includes("..") ||
f.name.startsWith("/") ||
f.name.includes("\\")
)
throw Error("Небезопасный путь в архиве");
return true;
},
});
if (!files["project.forma.json"]) throw Error("Нет project.forma.json");
p = JSON.parse(strFromU8(files["project.forma.json"]));
validateProject(p);
for (const a of p.assets)
if (a.uri && !a.uri.startsWith("data:")) {
const uri = a.uri.replace(/^\.\//, "").replace(/^\//, "");
if (!files[uri]) throw Error("Ресурс отсутствует: " + uri);
a.uri = "data:" + mimeFor(a.name) + ";base64," + base64(files[uri]);
}
}
validateProject(p);
return p;
}
export function download(
bytes: Uint8Array,
name: string,
mime = "application/octet-stream",
) {
const url = URL.createObjectURL(
new Blob([bytes as BlobPart], { type: mime }),
);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 3000);
}
+34
View File
@@ -0,0 +1,34 @@
import { zipSync, unzipSync, strToU8 } from "fflate";
import { gameArchive, readBytes } from "./archive.ts";
import type { Project } from "./schema.ts";
import { normalizeOptions } from "../native/options.mjs";
export async function buildKit(
project: Project,
raw: unknown,
read = readBytes,
) {
const options = normalizeOptions(raw as any);
const game = unzipSync(await gameArchive(project, read));
const files: Record<string, Uint8Array> = {};
for (const [name, data] of Object.entries(game))
files["native/game/" + name] = data;
const manifest = JSON.parse(
new TextDecoder().decode(await read("/build-targets/manifest.json")),
) as string[];
for (const name of manifest) {
if (
!/^[a-zA-Z0-9_./-]+$/.test(name) ||
name.includes("..") ||
name.startsWith("/")
)
throw Error("Invalid build template path");
files["native/" + name] = await read("/build-targets/" + name);
}
files["native/build-config.json"] = strToU8(JSON.stringify(options, null, 2));
files["README.txt"] = strToU8(
"Forma Engine 0.2 — application build kit\n\nThis archive contains your game and a real build toolchain configuration. It is NOT an executable application yet.\n\nLinux / Windows desktop:\n npm ci --prefix native\n node native/build.mjs\n\nAndroid (Linux build host, JDK 17 + curl + unzip required):\n node native/setup-android.mjs\n node native/build.mjs\n\nCheck prerequisites: node native/build.mjs --doctor\nBuild output: native/output/<timestamp>/artifacts/\n\nFirst setup/build needs Internet to download toolchains. Finished games are offline.\nFor release APK set FORMA_KEYSTORE, FORMA_KEYSTORE_PASSWORD, FORMA_KEY_ALIAS, FORMA_KEY_PASSWORD in the build environment. Keep the signing key for future updates. Never put secrets in build-config.json.\nWindows EXE is unsigned. Linux AppImage requires working Chromium sandbox/user namespaces.\n\nProject revision: " +
project.revision +
"\n",
);
return zipSync(files, { level: 6 });
}
+54
View File
@@ -0,0 +1,54 @@
/** Velocity-based kinematic motion. Rapier remains the collision authority. */
export class CharacterMotor {
velocity = { x: 0, y: 0, z: 0 };
actualVelocity = { x: 0, y: 0, z: 0 };
grounded = false;
gravityScale = 1;
contacts: { handle: number; normal: number[] }[] = [];
constructor(public body: any, public collider: any, public controller: any,
public config: any, private rapier: any) {}
set(value: any) {
for (const axis of ["x", "y", "z"] as const)
if (value[axis] !== undefined) {
if (!Number.isFinite(value[axis])) throw Error("Invalid character velocity");
this.velocity[axis] = Math.max(-100, Math.min(100, value[axis]));
}
if (value.gravityScale !== undefined) {
if (!Number.isFinite(value.gravityScale)) throw Error("Invalid gravity scale");
this.gravityScale = Math.max(0, Math.min(5, value.gravityScale));
}
}
teleport(position: number[]) {
if (position.length !== 3 || !position.every(Number.isFinite)) throw Error("Invalid teleport");
const p = { x: position[0], y: position[1], z: position[2] };
this.body.setTranslation(p, true);
this.body.setNextKinematicTranslation(p);
this.velocity = { x: 0, y: 0, z: 0 };
this.actualVelocity = { x: 0, y: 0, z: 0 };
this.gravityScale = 1;
this.grounded = false;
this.contacts = [];
}
step(dt: number, extra = [0, 0, 0]) {
this.velocity.y = Math.max(-45, this.velocity.y - (this.config.gravity ?? 24) * this.gravityScale * dt);
// Fixed-step gravity already maintains ground contact. Rapier 0.20 snap-down
// plus small vertical gravity steps can accumulate penetration on flat floors.
this.controller.disableSnapToGround();
this.controller.computeColliderMovement(this.collider, {
x: this.velocity.x * dt + extra[0],
y: this.velocity.y * dt + extra[1],
z: this.velocity.z * dt + extra[2],
}, this.rapier.QueryFilterFlags.EXCLUDE_SENSORS);
const movement = this.controller.computedMovement(), p = this.body.translation();
this.actualVelocity = { x: movement.x / dt, y: movement.y / dt, z: movement.z / dt };
this.body.setNextKinematicTranslation({ x: p.x + movement.x, y: p.y + movement.y, z: p.z + movement.z });
this.grounded = this.controller.computedGrounded();
this.contacts = [];
for (let i = 0; i < this.controller.numComputedCollisions(); i++) {
const hit = this.controller.computedCollision(i);
if (hit?.collider) this.contacts.push({ handle: hit.collider.handle, normal: [hit.normal1.x, hit.normal1.y, hit.normal1.z] });
}
if (this.grounded && this.velocity.y < 0) this.velocity.y = 0;
if (this.velocity.y > 0 && this.contacts.some(c => c.normal[1] < -0.6)) this.velocity.y = 0;
}
}
+252
View File
@@ -0,0 +1,252 @@
import {
entity,
uid,
type Vec3,
type Geometry,
validateGeometry,
} from "./schema.ts";
const cross = (a: number[], b: number[], c: number[]) =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
export function extrude(profile: number[][], depth = 1): Geometry {
if (
!Array.isArray(profile) ||
profile.length < 3 ||
profile.length > 256 ||
!Number.isFinite(depth) ||
depth <= 0 ||
!profile.every((p) => p.length === 2 && p.every(Number.isFinite))
)
throw Error("Нужен простой замкнутый профиль из 3–256 точек");
const points = profile.map((p) => [...p]);
let area = points.reduce(
(s, p, i) =>
s +
p[0] * points[(i + 1) % points.length][1] -
points[(i + 1) % points.length][0] * p[1],
0,
);
if (Math.abs(area) < 1e-8) throw Error("Нулевая площадь");
if (area < 0) points.reverse();
const n = points.length;
for (let i = 0; i < n; i++)
for (let j = i + 2; j < n; j++) {
if (i === 0 && j === n - 1) continue;
const a = points[i],
b = points[(i + 1) % n],
c = points[j],
d = points[(j + 1) % n];
if (
cross(a, b, c) * cross(a, b, d) < 0 &&
cross(c, d, a) * cross(c, d, b) < 0
)
throw Error("Профиль пересекает себя");
}
const indices: number[] = [],
left = points.map((_, i) => i);
let guard = 0;
while (left.length > 3) {
let clipped = false;
for (let j = 0; j < left.length; j++) {
const a = left[(j + left.length - 1) % left.length],
b = left[j],
c = left[(j + 1) % left.length];
if (cross(points[a], points[b], points[c]) <= 1e-8) continue;
if (
left.some(
(k) =>
k !== a &&
k !== b &&
k !== c &&
cross(points[a], points[b], points[k]) >= 0 &&
cross(points[b], points[c], points[k]) >= 0 &&
cross(points[c], points[a], points[k]) >= 0,
)
)
continue;
indices.push(a, b, c, a + n, c + n, b + n);
left.splice(j, 1);
clipped = true;
break;
}
if (!clipped || guard++ > 256)
throw Error("Невозможно триангулировать профиль");
}
indices.push(
left[0],
left[1],
left[2],
left[0] + n,
left[2] + n,
left[1] + n,
);
const positions: number[] = [];
for (const y of [0, depth])
for (const [x, z] of points) positions.push(x, y, z);
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
indices.push(i, i + n, j + n, i, j + n, j);
}
const g = { positions, indices };
validateGeometry(g);
return g;
}
export function lathe(profile: number[][], segments = 24): Geometry {
if (
!Number.isInteger(segments) ||
segments < 3 ||
segments > 128 ||
!Array.isArray(profile) ||
profile.length < 2 ||
profile.length > 256 ||
!profile.every(
(p) => p.length === 2 && p.every(Number.isFinite) && p[0] >= 0,
)
)
throw Error("Нужен профиль [радиус, высота] и 3–128 сегментов");
const positions: number[] = [],
indices: number[] = [];
for (const [r, y] of profile)
for (let j = 0; j < segments; j++) {
const a = (j / segments) * Math.PI * 2;
positions.push(Math.cos(a) * r, y, Math.sin(a) * r);
}
for (let i = 0; i < profile.length - 1; i++)
for (let j = 0; j < segments; j++) {
const a = i * segments + j,
b = i * segments + ((j + 1) % segments),
c = a + segments,
d = b + segments;
indices.push(a, c, b, b, c, d);
}
const g = { positions, indices };
validateGeometry(g);
return g;
}
export function transformed(
g: Geometry,
offset: Vec3 = [0, 0, 0],
scale: Vec3 = [1, 1, 1],
): Geometry {
const positions = g.positions.map((v, i) => v * scale[i % 3] + offset[i % 3]),
indices = [...g.indices];
if (scale[0] * scale[1] * scale[2] < 0)
for (let i = 0; i < indices.length; i += 3)
[indices[i + 1], indices[i + 2]] = [indices[i + 2], indices[i + 1]];
return { positions, indices };
}
export function arena({
width = 22,
depth = 18,
seed = 42,
obstacles = 8,
}: any = {}) {
width = Math.max(8, Math.min(80, width));
depth = Math.max(8, Math.min(80, depth));
obstacles = Math.max(0, Math.min(80, Math.round(obstacles)));
let v = seed | 0;
const random = () => {
v = (Math.imul(v, 1664525) + 1013904223) | 0;
return (v >>> 0) / 4294967296;
};
const root = entity("Сад · уровень"),
nodes = [root];
const add = (
name: string,
pos: Vec3,
size: Vec3,
color: string,
type = "box",
collision = true,
) => {
const n = entity(
name,
{
mesh: { type, size },
material: { color, roughness: 0.92 },
...(collision
? { collider: { shape: "box", size }, rigidbody: { type: "fixed" } }
: {}),
},
pos,
);
n.parentId = root.id;
nodes.push(n);
return n;
};
add("Каменное основание", [0, -0.5, 0], [width, 1, depth], "#b6aa91");
add(
"Светлый песок",
[0, 0.025, 0],
[width - 0.5, 0.05, depth - 0.5],
"#d1c9ae",
"box",
false,
);
add("Северная стена", [0, 0.6, depth / 2], [width, 1.2, 0.45], "#b0a48a");
add("Южная стена", [0, 0.6, -depth / 2], [width, 1.2, 0.45], "#b0a48a");
add("Западная стена", [-width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
add("Восточная стена", [width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
for (let z = -Math.floor(depth / 2) + 1; z < depth / 2; z += 1.1)
add(
"Плитка тропы",
[0, 0.07, z],
[1.7, 0.09, 0.9],
"#b6b49c",
"box",
false,
);
for (const x of [-width / 2 + 1.2, width / 2 - 1.2])
for (const z of [-depth / 2 + 1.2, depth / 2 - 1.2]) {
add("Основание колонны", [x, 0.15, z], [1.4, 0.3, 1.4], "#a9a187");
add("Колонна", [x, 1.6, z], [0.7, 2.7, 0.7], "#c5bca2", "cylinder");
add("Капитель", [x, 3, z], [1.2, 0.3, 1.2], "#cfc5ab");
}
for (let i = 0; i < obstacles; i++) {
const x = (i % 2 ? -1 : 1) * (2 + random() * (width / 2 - 4)),
z = (random() - 0.5) * (depth - 5);
if (i % 3 === 0) {
add("Ствол", [x, 0.7, z], [0.25, 1.4, 0.25], "#80765c", "cylinder");
add("Крона", [x, 1.7, z], [2, 1.7, 2], "#859887", "icosphere", false);
} else {
const n = add(
"Обломок",
[x, 0.35, z],
[0.9 + random(), 0.7, 0.8 + random()],
"#999b84",
);
n.transform.rotation[1] = random() * 2;
}
}
return nodes;
}
export function character({
height = 1.8,
color = "#7d9b8a",
name = "Персонаж · модель",
}: any = {}) {
const root = entity(name),
nodes = [root],
scale = height / 1.8;
const add = (name: string, p: Vec3, size: Vec3, c: string, type = "box") => {
const n = entity(
name,
{
mesh: { type, size: size.map((v) => v * scale) },
material: { color: c, roughness: 0.8 },
},
p.map((v) => v * scale) as Vec3,
);
n.parentId = root.id;
nodes.push(n);
};
add("Торс", [0, 1.1, 0], [0.6, 0.65, 0.35], color);
add("Голова", [0, 1.65, 0], [0.4, 0.4, 0.4], "#d5b994", "sphere");
add("Капюшон", [0, 1.8, -0.04], [0.5, 0.25, 0.48], color, "icosphere");
for (const sign of [-1, 1]) {
add("Нога", [sign * 0.16, 0.37, 0], [0.24, 0.7, 0.25], "#61685f");
add("Сапог", [sign * 0.16, 0.09, 0.09], [0.28, 0.18, 0.42], "#55594f");
add("Рука", [sign * 0.42, 1.05, 0], [0.18, 0.65, 0.2], color);
}
add("Клинок", [0.53, 0.98, 0.28], [0.08, 0.85, 0.12], "#c4cac0");
return nodes;
}
+52
View File
@@ -0,0 +1,52 @@
/** Validate portable model containers before allowing renderer-side resource loads. */
export function inspectModel(bytes: Uint8Array, name: string) {
if (bytes.byteLength < 12 || bytes.byteLength > 25 * 1024 * 1024)
throw Error("Размер модели: 12 байт — 25 МБ");
let gltf: any;
if (name.toLowerCase().endsWith(".gltf"))
gltf = JSON.parse(new TextDecoder().decode(bytes));
else {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (
view.getUint32(0, true) !== 0x46546c67 ||
view.getUint32(4, true) !== 2 ||
view.getUint32(8, true) !== bytes.byteLength
)
throw Error("Некорректный GLB v2");
if (bytes.byteLength < 20 || view.getUint32(16, true) !== 0x4e4f534a)
throw Error("GLB не содержит JSON");
const length = view.getUint32(12, true);
if (length > bytes.byteLength - 20) throw Error("Повреждён JSON GLB");
gltf = JSON.parse(
new TextDecoder()
.decode(bytes.subarray(20, 20 + length))
.replace(/\0+$/, ""),
);
}
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
if (
[...(gltf.buffers || []), ...(gltf.images || [])].some(
(r: any) => r.uri && !r.uri.startsWith("data:"),
)
)
throw Error("Экспортируй GLB со встроенными текстурами и буферами");
const compressed = [
"KHR_draco_mesh_compression",
"EXT_meshopt_compression",
"KHR_texture_basisu",
];
if (
[...(gltf.extensionsUsed || []), ...(gltf.extensionsRequired || [])].some(
(x) => compressed.includes(x),
)
)
throw Error(
"Для автономного экспорта 0.1 используй GLB без Draco, Meshopt и KTX2",
);
return {
clips: (gltf.animations || []).map(
(a: any, i: number) => a.name || "Animation " + i,
),
skeletons: (gltf.skins || []).length,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { FormaRuntime } from "./runtime.ts";
import { validateProject, type Project } from "./schema.ts";
import "./player.css";
const canvas = document.getElementById("game") as HTMLCanvasElement;
const ui = document.getElementById("game-ui")!;
ui.innerHTML = '<div class="loading">Загрузка проекта…</div>';
async function boot() {
const response = await fetch("./project.forma.json");
if (!response.ok) throw Error(`Project: HTTP ${response.status}`);
const project: Project = await response.json();
validateProject(project);
ui.innerHTML = `<div class="hud"><div><strong id="title"></strong><small id="hint"></small></div><nav><button id="pause">Пауза</button><button id="restart">Перезапустить</button></nav></div><div class="touch"><div id="stick" aria-label="Джойстик"><i></i></div><div class="actions"><button id="jump" aria-label="Прыжок">↑</button><button id="action" aria-label="Действие">A</button></div></div><div id="error" hidden></div>`;
document.getElementById("title")!.textContent = project.name;
let firstPerson = false;
const runtime = new FormaRuntime(canvas, {
stats: (stats) => {
firstPerson = stats.firstPerson;
document.getElementById("hint")!.textContent = firstPerson
? "Клик · захват мыши / Esc · отпустить / WASD · ввод движения"
: "WASD и кнопки действий передаются скриптам проекта";
},
log: (level, message) => {
if (level !== "error") return;
const error = document.getElementById("error")!;
error.hidden = false;
error.textContent = message;
},
});
const pause = document.getElementById("pause")!;
const setPaused = (value: boolean) => {
runtime.paused = value;
runtime.releaseInput();
pause.textContent = value ? "Продолжить" : "Пауза";
if (value && document.pointerLockElement === canvas)
document.exitPointerLock();
};
pause.onclick = () => setPaused(!runtime.paused);
const restart = document.getElementById("restart") as HTMLButtonElement;
restart.onclick = async () => {
restart.disabled = true;
try {
await runtime.stop(project);
await runtime.play(project);
setPaused(false);
document.getElementById("error")!.hidden = true;
} catch (error) {
const panel = document.getElementById("error")!;
panel.hidden = false;
panel.textContent = String(error);
} finally {
restart.disabled = false;
}
};
const stick = document.getElementById("stick")!;
const knob = stick.querySelector("i")!;
let stickId: number | null = null;
let look: { id: number; x: number; y: number } | null = null;
const move = (event: PointerEvent) => {
if (event.pointerId !== stickId || runtime.paused) return;
const bounds = stick.getBoundingClientRect();
const x = (event.clientX - bounds.left - bounds.width / 2) / 35;
const y = (event.clientY - bounds.top - bounds.height / 2) / 35;
const length = Math.max(1, Math.hypot(x, y));
runtime.touch.x = x / length;
runtime.touch.z = -y / length;
knob.style.transform = `translate(${(x / length) * 28}px,${(y / length) * 28}px)`;
};
stick.onpointerdown = (event) => {
stickId = event.pointerId;
stick.setPointerCapture(event.pointerId);
move(event);
};
stick.onpointermove = move;
stick.onpointerup = stick.onpointercancel = () => {
stickId = null;
runtime.touch.x = runtime.touch.z = 0;
knob.style.transform = "";
};
for (const [id, input] of [
["jump", "jump"],
["action", "attack"],
] as const) {
const button = document.getElementById(id)!;
button.onpointerdown = (event) => {
if (runtime.paused) return;
button.setPointerCapture(event.pointerId);
runtime.touch[input] = true;
if (input === "jump") runtime.requestAction("jump");
};
button.onpointerup = button.onpointercancel = () => {
runtime.touch[input] = false;
};
}
canvas.addEventListener("pointerdown", (event) => {
if (!firstPerson || runtime.paused) return;
if (event.pointerType === "mouse") {
const lock = canvas.requestPointerLock?.();
if (lock && typeof lock.catch === "function") lock.catch(() => {});
} else {
look = { id: event.pointerId, x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
}
});
canvas.addEventListener("pointermove", (event) => {
if (!look || look.id !== event.pointerId || runtime.paused) return;
runtime.lookBy(
(event.clientX - look.x) * 0.004,
(look.y - event.clientY) * 0.004,
);
look.x = event.clientX;
look.y = event.clientY;
});
const release = () => {
stickId = null;
look = null;
runtime.releaseInput();
knob.style.transform = "";
};
canvas.addEventListener("pointerup", () => {
look = null;
});
canvas.addEventListener("pointercancel", () => {
look = null;
});
window.addEventListener("blur", release);
document.addEventListener("visibilitychange", () => {
release();
if (document.hidden) setPaused(true);
});
window.addEventListener("pagehide", () => runtime.dispose(), { once: true });
await runtime.play(project);
}
boot().catch((error) => {
ui.textContent = "Не удалось открыть проект: " + String(error);
});
+157
View File
@@ -0,0 +1,157 @@
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #dcd9d0;
font-family: system-ui, sans-serif;
}
#game {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#game-ui {
position: fixed;
inset: 0;
pointer-events: none;
color: #f5f4df;
}
.loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #6b7859;
}
button {
pointer-events: auto;
border: 1px solid #f4f4dc66;
border-radius: 6px;
padding: 10px 15px;
background: #eff1e4de;
color: #556646;
cursor: pointer;
font: 12px system-ui;
}
.hud {
position: absolute;
left: 24px;
right: 24px;
top: 24px;
display: flex;
justify-content: space-between;
align-items: start;
}
.hud > div {
display: flex;
flex-direction: column;
gap: 6px;
background: #3e5235d9;
border: 1px solid #a0b08466;
border-radius: 7px;
padding: 15px 19px;
}
.hud strong {
font-size: 19px;
font-weight: 500;
}
.hud small {
font-size: 10px;
color: #c6d2ad;
}
.touch {
display: none;
position: absolute;
left: 30px;
right: 30px;
bottom: max(28px, env(safe-area-inset-bottom));
align-items: center;
justify-content: space-between;
}
#stick {
width: 112px;
height: 112px;
pointer-events: auto;
touch-action: none;
border: 2px solid #fff6;
border-radius: 50%;
background: #334b3355;
display: flex;
align-items: center;
justify-content: center;
}
#stick i {
width: 48px;
height: 48px;
border-radius: 50%;
background: #fffa;
}
.actions button {
touch-action: none;
width: 85px;
height: 85px;
border: 2px solid #fff6;
border-radius: 50%;
background: #a6775cc4;
color: white;
font-size: 24px;
padding: 0;
}
#error {
position: absolute;
left: 20px;
right: 20px;
bottom: 10px;
max-height: 150px;
overflow: auto;
background: #80553deb;
padding: 13px;
border-radius: 6px;
font: 11px monospace;
}
#error[hidden] {
display: none;
}
@media (pointer: coarse), (max-width: 760px) {
.touch {
display: flex;
}
.hud {
top: 15px;
left: 15px;
right: 15px;
}
.hud strong {
font-size: 16px;
}
.hud small {
display: none;
}
.hud > div {
padding: 12px;
}
.hud button {
font-size: 10px;
padding: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}
.hud nav,
.actions {
display: flex;
gap: 10px;
}
.hud nav {
pointer-events: auto;
}
+1276
View File
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
export type Vec3 = [number, number, number];
export type Component = Record<string, any>;
export interface Transform {
position: Vec3;
rotation: Vec3;
scale: Vec3;
}
export interface Entity {
id: string;
name: string;
parentId: string | null;
enabled: boolean;
transform: Transform;
components: Record<string, Component>;
}
export interface Geometry {
positions: number[];
indices: number[];
normals?: number[];
uvs?: number[];
}
export interface Asset {
id: string;
name: string;
kind: "model" | "geometry" | "prefab";
uri?: string;
geometry?: Geometry;
entities?: Entity[];
metadata?: any;
}
export interface ScriptAsset {
id: string;
name: string;
source: string;
fields: Record<
string,
{
type: "number" | "boolean" | "string" | "entity";
default: any;
label?: string;
min?: number;
max?: number;
}
>;
}
export interface Project {
format: "forma";
version: 1;
id: string;
name: string;
revision: number;
activeSceneId: string;
scenes: { id: string; name: string; entities: Entity[] }[];
assets: Asset[];
scripts: ScriptAsset[];
settings: {
background: string;
ambient: number;
shadows: boolean;
renderScale: number;
};
}
export interface Command {
op: string;
args: any;
}
export interface Transaction {
commands: Command[];
expectedRevision?: number;
requestId?: string;
label?: string;
source?: string;
}
export const clone = <T>(v: T): T => structuredClone(v);
export const uid = (p = "obj") =>
p + "_" + crypto.randomUUID().replaceAll("-", "").slice(0, 12);
export const transform = (): Transform => ({
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
});
export const entity = (
name: string,
components: Entity["components"] = {},
position: Vec3 = [0, 0, 0],
id = uid(),
): Entity => ({
id,
name,
parentId: null,
enabled: true,
transform: { ...transform(), position },
components,
});
export function emptyProject(name = "Без названия"): Project {
const id = uid("scene");
return {
format: "forma",
version: 1,
id: uid("project"),
name,
revision: 0,
activeSceneId: id,
scenes: [{ id, name: "Основная сцена", entities: [] }],
assets: [],
scripts: [],
settings: {
background: "#dedbd2",
ambient: 0.85,
shadows: true,
renderScale: 1,
},
};
}
export const activeScene = (p: Project) =>
p.scenes.find((s) => s.id === p.activeSceneId)!;
const forbidden = new Set(["__proto__", "constructor", "prototype"]);
export function assertJson(v: any, depth = 0) {
if (depth > 28) throw Error("Слишком глубокая структура");
if (
v === undefined ||
typeof v === "function" ||
typeof v === "bigint" ||
(typeof v === "number" && !Number.isFinite(v))
)
throw Error("Требуются конечные JSON-данные");
if (v && typeof v === "object")
for (const [k, n] of Object.entries(v)) {
if (forbidden.has(k)) throw Error("Недопустимое имя свойства");
assertJson(n, depth + 1);
}
}
export function deepMerge(a: any, b: any) {
for (const [k, v] of Object.entries(b)) {
if (forbidden.has(k)) throw Error("Недопустимое имя свойства");
if (v && typeof v === "object" && !Array.isArray(v))
a[k] = deepMerge(
a[k] && typeof a[k] === "object" && !Array.isArray(a[k]) ? a[k] : {},
v,
);
else a[k] = clone(v);
}
return a;
}
export function validateGeometry(g: Geometry) {
if (
!g ||
!Array.isArray(g.positions) ||
!Array.isArray(g.indices) ||
g.positions.length < 9 ||
g.positions.length % 3 ||
g.indices.length < 3 ||
g.indices.length % 3
)
throw Error("Геометрия: positions и indices должны описывать треугольники");
if (g.positions.length > 900000 || g.indices.length > 1800000)
throw Error("Лимит геометрии: 300 000 вершин, 600 000 треугольников");
if (
!g.positions.every(Number.isFinite) ||
!g.indices.every(
(i) => Number.isInteger(i) && i >= 0 && i < g.positions.length / 3,
)
)
throw Error("Некорректные вершины или индексы");
if (
g.normals &&
(g.normals.length !== g.positions.length ||
!g.normals.every(Number.isFinite))
)
throw Error("Некорректные нормали");
if (
g.uvs &&
(g.uvs.length !== (g.positions.length / 3) * 2 ||
!g.uvs.every(Number.isFinite))
)
throw Error("Некорректные UV");
}
const identifier = (id: any) =>
typeof id === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(id);
const color = (s: any) => typeof s === "string" && /^#[\da-fA-F]{6}$/.test(s);
const vector = (v: any) =>
Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
export function validateProject(p: Project) {
assertJson(p);
if (p?.format !== "forma" || p.version !== 1)
throw Error("Поддерживается формат Forma v1");
if (
!identifier(p.id) ||
typeof p.name !== "string" ||
p.name.length > 200 ||
!Number.isInteger(p.revision) ||
p.revision < 0
)
throw Error("Некорректные метаданные");
if (
!Array.isArray(p.scenes) ||
!p.scenes.length ||
!p.scenes.some((s) => s.id === p.activeSceneId) ||
!Array.isArray(p.assets) ||
!Array.isArray(p.scripts)
)
throw Error("Неполный проект");
if (
!p.settings ||
!color(p.settings.background) ||
!(p.settings.ambient >= 0 && p.settings.ambient <= 10) ||
!(p.settings.renderScale >= 0.4 && p.settings.renderScale <= 1.5) ||
typeof p.settings.shadows !== "boolean"
)
throw Error("Некорректные настройки сцены");
const unique = (list: any[]) => {
const ids = new Set();
for (const v of list) {
if (!identifier(v.id) || ids.has(v.id))
throw Error("Требуется уникальный ID");
ids.add(v.id);
}
};
unique(p.scenes);
unique(p.assets);
unique(p.scripts);
const nodes = (list: Entity[]) => {
if (!Array.isArray(list) || list.length > 3000)
throw Error("Лимит: 3000 объектов");
unique(list);
const map = new Map(list.map((n) => [n.id, n]));
for (const n of list) {
if (
typeof n.name !== "string" ||
n.name.length > 200 ||
typeof n.enabled !== "boolean" ||
!n.components ||
Array.isArray(n.components) ||
!(n.parentId === null || identifier(n.parentId))
)
throw Error("Некорректный объект");
if (
!vector(n.transform?.position) ||
!vector(n.transform?.rotation) ||
!vector(n.transform?.scale) ||
n.transform.scale.some((v) => Math.abs(v) < 0.0001)
)
throw Error("Некорректная трансформация");
for (const c of Object.values(n.components))
if (!c || typeof c !== "object" || Array.isArray(c))
throw Error("Компонент должен быть объектом");
const c = n.components,
m = c.mesh;
if (m?.type === "custom") validateGeometry(m.geometry);
if (
m?.assetId &&
!p.assets.some((a) => a.id === m.assetId && a.kind !== "prefab")
)
throw Error("Не найден ресурс " + m.assetId);
if (
c.script?.scriptId &&
!p.scripts.some((s) => s.id === c.script.scriptId)
)
throw Error("Не найден скрипт " + c.script.scriptId);
if (c.material?.color && !color(c.material.color))
throw Error("Цвет должен быть #RRGGBB");
if (m?.size && (!vector(m.size) || m.size.some((v: number) => v <= 0)))
throw Error("Размеры должны быть положительными");
if (
c.collider?.size &&
(!vector(c.collider.size) ||
c.collider.size.some((v: number) => v <= 0))
)
throw Error("Размер коллайдера должен быть положительным");
if (c.collider?.radius !== undefined && c.collider.radius <= 0)
throw Error("Радиус должен быть положительным");
let cur = n;
const seen = new Set([n.id]);
while (cur.parentId) {
const parent = map.get(cur.parentId);
if (!parent) throw Error("Родитель не найден");
if (seen.has(parent.id)) throw Error("Цикл в иерархии");
seen.add(parent.id);
cur = parent;
}
}
};
for (const s of p.scenes) nodes(s.entities);
for (const a of p.assets) {
if (
typeof a.name !== "string" ||
!["model", "geometry", "prefab"].includes(a.kind)
)
throw Error("Некорректный ресурс");
if (a.kind === "geometry") validateGeometry(a.geometry!);
if (a.kind === "prefab") {
nodes(a.entities!);
if (a.entities!.filter((n) => !n.parentId).length !== 1)
throw Error("Префабу нужен один корневой объект");
}
if (a.uri && !a.uri.startsWith("data:")) {
if (!/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf)$/i.test(a.uri))
throw Error("Импортируйте ресурс в assets/, внешние пути запрещены");
}
}
for (const s of p.scripts) {
if (
typeof s.source !== "string" ||
s.source.length > 250000 ||
!s.fields ||
typeof s.fields !== "object"
)
throw Error("Некорректный скрипт");
for (const f of Object.values(s.fields)) {
if (
!f ||
!["number", "boolean", "string", "entity"].includes(f.type) ||
typeof f.default !== (f.type === "entity" ? "string" : f.type)
)
throw Error("Некорректные поля скрипта");
}
}
}
export function remapEntityReferences(
n: Entity,
ids: Map<string, string>,
scripts: ScriptAsset[],
) {
if (ids.has(n.components.camera?.targetId))
n.components.camera.targetId = ids.get(n.components.camera.targetId);
const binding = n.components.script,
script = scripts.find((s) => s.id === binding?.scriptId);
if (binding && script)
for (const [k, f] of Object.entries(script.fields))
if (f.type === "entity") {
const value = binding.params?.[k] ?? f.default;
if (ids.has(value)) {
binding.params ??= {};
binding.params[k] = ids.get(value);
}
}
return n;
}
export const componentDefaults: Record<string, Component> = {
mesh: { type: "box", size: [1, 1, 1] },
material: { color: "#91a697", roughness: 0.8, metallic: 0 },
collider: { shape: "box", size: [1, 1, 1], radius: 0.4 },
rigidbody: { type: "fixed", mass: 1, restitution: 0.1 },
character: { gravity: 24, autostep: 0.25 },
camera: { targetId: "", offset: [0, 13, -10], fov: 0.72 },
light: { color: "#fff1da", intensity: 2 },
animator: {
idle: "Idle",
run: "Run",
attack: "Attack",
death: "Death",
speed: 1,
},
data: {},
};
+1
View File
@@ -0,0 +1 @@
export const workerSource="let entities = [],\n scripts = [],\n instances = [],\n input = {},\n physics = {},\n commands = [],\n frame = 0;\nconst merge = (a, b) => {\n for (const [k, v] of Object.entries(b)) {\n if ([\"__proto__\", \"constructor\", \"prototype\"].includes(k)) continue;\n a[k] =\n v && typeof v === \"object\" && !Array.isArray(v)\n ? merge(a[k] || {}, v)\n : v;\n }\n return a;\n};\nfunction api(i) {\n return {\n state: i.state,\n params: i.params,\n get input() {\n return input;\n },\n physics: (id = i.id) => structuredClone(physics[id] || { grounded: false, velocity: { x: 0, y: 0, z: 0 }, contacts: [] }),\n velocity(value) { commands.push({ type: \"velocity\", id: i.id, value }); },\n teleport(position, yaw) { commands.push({ type: \"teleport\", id: i.id, position, yaw }); },\n emit(name, data = {}) { commands.push({ type: \"event\", id: i.id, name, data }); },\n get: (id = i.id) =>\n structuredClone(entities.find((n) => n.id === id) || null),\n entities: () => structuredClone(entities),\n position: (id = i.id) => [\n ...(entities.find((n) => n.id === id)?.transform.position || [0, 0, 0]),\n ],\n patch(id, patch) {\n const n = entities.find((n) => n.id === id);\n if (n) {\n merge(n, structuredClone(patch));\n commands.push({ type: \"patch\", id, patch });\n }\n },\n move(delta) {\n commands.push({ type: \"move\", id: i.id, delta });\n },\n rotate(y) {\n this.patch(i.id, { transform: { rotation: [0, y, 0] } });\n },\n animate(name, loop = true) {\n commands.push({ type: \"animate\", id: i.id, name, loop });\n },\n effect(name, id = i.id) {\n commands.push({ type: \"effect\", id, name });\n },\n log(message) {\n commands.push({ type: \"log\", id: i.id, message: String(message) });\n },\n destroy(id = i.id) {\n this.patch(id, { enabled: false });\n },\n spawn(template, position) {\n commands.push({ type: \"spawn\", template, position });\n },\n scene(sceneId) {\n commands.push({ type: \"scene\", sceneId });\n },\n };\n}\nfunction fail(i, e) {\n i.failed = true;\n commands.push({\n type: \"error\",\n id: i.id,\n scriptId: i.scriptId,\n message: String(e?.stack || e),\n });\n}\nfunction reconcile() {\n instances = instances.filter((i) =>\n entities.some(\n (n) => n.id === i.id && n.components.script?.scriptId === i.scriptId,\n ),\n );\n for (const n of entities) {\n if (!n.enabled || instances.some((i) => i.id === n.id)) continue;\n const binding = n.components.script,\n def = scripts.find((s) => s.id === binding?.scriptId);\n if (!def) continue;\n const i = {\n id: n.id,\n scriptId: def.id,\n state: {},\n params: {\n ...Object.fromEntries(\n Object.entries(def.fields).map(([k, v]) => [k, v.default]),\n ),\n ...binding.params,\n },\n behavior: null,\n failed: false,\n };\n instances.push(i);\n try {\n i.behavior = new Function(\"return (\" + def.source + \");\")();\n if (!i.behavior || typeof i.behavior !== \"object\")\n throw Error(\"Скрипт должен вернуть {start, update}\");\n i.behavior.start?.(api(i));\n } catch (e) {\n fail(i, e);\n }\n }\n}\nself.onmessage = (e) => {\n const m = e.data;\n commands = [];\n entities = m.entities;\n physics = m.physics || {};\n if (m.type === \"init\") {\n scripts = m.scripts;\n instances = [];\n reconcile();\n postMessage({ type: \"ready\", commands });\n } else {\n input = m.input;\n reconcile();\n for (const i of instances) {\n if (i.failed || !entities.some((n) => n.id === i.id && n.enabled))\n continue;\n try {\n i.behavior.update?.(api(i), m.dt);\n } catch (e) {\n fail(i, e);\n }\n }\n postMessage({\n type: \"frame\",\n frame: ++frame,\n commands:\n commands.length < 5000\n ? commands\n : [{ type: \"error\", message: \"Лимит 5000 команд за кадр\" }],\n });\n }\n};\n";
+143
View File
@@ -0,0 +1,143 @@
let entities = [],
scripts = [],
instances = [],
input = {},
physics = {},
commands = [],
frame = 0;
const merge = (a, b) => {
for (const [k, v] of Object.entries(b)) {
if (["__proto__", "constructor", "prototype"].includes(k)) continue;
a[k] =
v && typeof v === "object" && !Array.isArray(v)
? merge(a[k] || {}, v)
: v;
}
return a;
};
function api(i) {
return {
state: i.state,
params: i.params,
get input() {
return input;
},
physics: (id = i.id) => structuredClone(physics[id] || { grounded: false, velocity: { x: 0, y: 0, z: 0 }, contacts: [] }),
velocity(value) { commands.push({ type: "velocity", id: i.id, value }); },
teleport(position, yaw) { commands.push({ type: "teleport", id: i.id, position, yaw }); },
emit(name, data = {}) { commands.push({ type: "event", id: i.id, name, data }); },
get: (id = i.id) =>
structuredClone(entities.find((n) => n.id === id) || null),
entities: () => structuredClone(entities),
position: (id = i.id) => [
...(entities.find((n) => n.id === id)?.transform.position || [0, 0, 0]),
],
patch(id, patch) {
const n = entities.find((n) => n.id === id);
if (n) {
merge(n, structuredClone(patch));
commands.push({ type: "patch", id, patch });
}
},
move(delta) {
commands.push({ type: "move", id: i.id, delta });
},
rotate(y) {
this.patch(i.id, { transform: { rotation: [0, y, 0] } });
},
animate(name, loop = true) {
commands.push({ type: "animate", id: i.id, name, loop });
},
effect(name, id = i.id) {
commands.push({ type: "effect", id, name });
},
log(message) {
commands.push({ type: "log", id: i.id, message: String(message) });
},
destroy(id = i.id) {
this.patch(id, { enabled: false });
},
spawn(template, position) {
commands.push({ type: "spawn", template, position });
},
scene(sceneId) {
commands.push({ type: "scene", sceneId });
},
};
}
function fail(i, e) {
i.failed = true;
commands.push({
type: "error",
id: i.id,
scriptId: i.scriptId,
message: String(e?.stack || e),
});
}
function reconcile() {
instances = instances.filter((i) =>
entities.some(
(n) => n.id === i.id && n.components.script?.scriptId === i.scriptId,
),
);
for (const n of entities) {
if (!n.enabled || instances.some((i) => i.id === n.id)) continue;
const binding = n.components.script,
def = scripts.find((s) => s.id === binding?.scriptId);
if (!def) continue;
const i = {
id: n.id,
scriptId: def.id,
state: {},
params: {
...Object.fromEntries(
Object.entries(def.fields).map(([k, v]) => [k, v.default]),
),
...binding.params,
},
behavior: null,
failed: false,
};
instances.push(i);
try {
i.behavior = new Function("return (" + def.source + ");")();
if (!i.behavior || typeof i.behavior !== "object")
throw Error("Скрипт должен вернуть {start, update}");
i.behavior.start?.(api(i));
} catch (e) {
fail(i, e);
}
}
}
self.onmessage = (e) => {
const m = e.data;
commands = [];
entities = m.entities;
physics = m.physics || {};
if (m.type === "init") {
scripts = m.scripts;
instances = [];
reconcile();
postMessage({ type: "ready", commands });
} else {
input = m.input;
reconcile();
for (const i of instances) {
if (i.failed || !entities.some((n) => n.id === i.id && n.enabled))
continue;
try {
i.behavior.update?.(api(i), m.dt);
} catch (e) {
fail(i, e);
}
}
postMessage({
type: "frame",
frame: ++frame,
commands:
commands.length < 5000
? commands
: [{ type: "error", message: "Лимит 5000 команд за кадр" }],
});
}
};
+278
View File
@@ -0,0 +1,278 @@
import {
type Project,
type Transaction,
type Command,
clone,
validateProject,
deepMerge,
entity,
uid,
remapEntityReferences,
} from "./schema.ts";
export class ProjectStore {
project: Project;
history: any[] = [];
private past: Project[] = [];
private future: Project[] = [];
private receipts = new Map<string, any>();
private listeners = new Set<() => void>();
constructor(p: Project) {
validateProject(p);
this.project = clone(p);
}
get canUndo() {
return !!this.past.length;
}
get canRedo() {
return !!this.future.length;
}
get snapshot() {
return this.project;
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
};
private emit() {
for (const fn of this.listeners) fn();
}
transaction(tx: Transaction) {
if (tx.requestId && this.receipts.has(tx.requestId))
return clone(this.receipts.get(tx.requestId));
if (
tx.expectedRevision !== undefined &&
tx.expectedRevision !== this.project.revision
)
throw Error("REVISION_CONFLICT: current " + this.project.revision);
if (
!Array.isArray(tx.commands) ||
!tx.commands.length ||
tx.commands.length > 1000
)
throw Error("Требуется 1–1000 команд");
let next = clone(this.project);
const results = [];
for (const c of tx.commands) {
if (c.op === "project.replace") {
next = clone(c.args.project);
results.push({ id: next.id });
} else results.push(this.apply(next, c));
}
next.revision = this.project.revision + 1;
validateProject(next);
this.past.push(this.project);
if (this.past.length > 30) this.past.shift();
this.future = [];
this.project = next;
this.history.unshift({
revision: next.revision,
label: tx.label || tx.commands[0].op,
source: tx.source || "editor",
time: Date.now(),
});
this.history = this.history.slice(0, 100);
const result = { revision: next.revision, results };
if (tx.requestId) {
this.receipts.set(tx.requestId, result);
if (this.receipts.size > 200)
this.receipts.delete(this.receipts.keys().next().value!);
}
this.emit();
return result;
}
command(op: string, args: any, label?: string) {
return this.transaction({ commands: [{ op, args }], label });
}
undo() {
if (!this.past.length) return;
this.future.push(this.project);
this.project = { ...this.past.pop()!, revision: this.project.revision + 1 };
this.recordHistory("Отмена");
}
redo() {
if (!this.future.length) return;
this.past.push(this.project);
this.project = {
...this.future.pop()!,
revision: this.project.revision + 1,
};
this.recordHistory("Повтор");
}
private recordHistory(label: string) {
this.history.unshift({
revision: this.project.revision,
label,
source: "editor",
time: Date.now(),
});
this.history = this.history.slice(0, 100);
this.emit();
}
synchronize(p: Project) {
validateProject(p);
this.project = clone(p);
this.past = [];
this.future = [];
this.emit();
}
private apply(p: Project, { op, args: a }: Command): any {
const scene = p.scenes.find(
(s) => s.id === (a?.sceneId || p.activeSceneId),
);
const find = () => {
const n = scene?.entities.find((n) => n.id === a.id);
if (!n) throw Error("Объект не найден: " + a.id);
return n;
};
const subtree = (id: string) => {
const ids = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const n of scene!.entities)
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
ids.add(n.id);
changed = true;
}
}
return scene!.entities.filter((n) => ids.has(n.id));
};
switch (op) {
case "project.rename":
p.name = a.name;
return { name: a.name };
case "project.settings":
deepMerge(p.settings, a);
return p.settings;
case "scene.create": {
const s = {
id: a.id || uid("scene"),
name: a.name || "Сцена",
entities: [],
};
p.scenes.push(s);
p.activeSceneId = s.id;
return { id: s.id };
}
case "scene.activate":
if (!p.scenes.some((s) => s.id === a.id))
throw Error("Сцена не найдена");
p.activeSceneId = a.id;
return { id: a.id };
case "scene.rename":
if (!scene) throw Error("Сцена не найдена");
scene.name = a.name;
return { id: scene.id };
case "node.create": {
if (!scene) throw Error("Сцена не найдена");
const n = a.entity
? clone(a.entity)
: entity(
a.name || "Объект",
a.components || {},
a.position || [0, 0, 0],
a.id,
);
if (a.parentId) n.parentId = a.parentId;
scene.entities.push(n);
return { id: n.id };
}
case "node.patch": {
const n = find();
if ("id" in a.patch || "parentId" in a.patch)
throw Error("Используйте node.reparent для иерархии");
deepMerge(n, a.patch);
return { id: n.id };
}
case "node.reparent": {
const n = find();
n.parentId = a.parentId || null;
if (a.transform) n.transform = clone(a.transform);
return { id: n.id };
}
case "node.delete": {
find();
const ids = new Set(subtree(a.id).map((n) => n.id));
scene!.entities = scene!.entities.filter((n) => !ids.has(n.id));
return { deleted: [...ids] };
}
case "node.duplicate": {
const root = find(),
copies = clone(subtree(root.id)),
ids = new Map(copies.map((n) => [n.id, uid()]));
for (const n of copies) {
remapEntityReferences(n, ids, p.scripts);
if (n.id === root.id) {
n.name += " — копия";
n.transform.position[0] += 1;
} else n.parentId = ids.get(n.parentId!)!;
n.id = ids.get(n.id)!;
}
scene!.entities.push(...copies);
return { id: ids.get(root.id) };
}
case "component.set":
if (
!/^[a-zA-Z][a-zA-Z0-9_]{0,70}$/.test(a.type) ||
["__proto__", "constructor", "prototype"].includes(a.type)
)
throw Error("Недопустимое имя компонента");
find().components[a.type] = clone(a.value);
return { id: a.id };
case "component.remove":
if (["__proto__", "constructor", "prototype"].includes(a.type))
throw Error("Недопустимое имя компонента");
delete find().components[a.type];
return { id: a.id };
case "asset.upsert": {
const i = p.assets.findIndex((n) => n.id === a.asset.id);
if (i >= 0) p.assets[i] = clone(a.asset);
else p.assets.push(clone(a.asset));
return { id: a.asset.id };
}
case "asset.delete":
p.assets = p.assets.filter((n) => n.id !== a.id);
return { id: a.id };
case "script.upsert": {
const i = p.scripts.findIndex((n) => n.id === a.script.id);
if (i >= 0) p.scripts[i] = clone(a.script);
else p.scripts.push(clone(a.script));
return { id: a.script.id };
}
case "prefab.create": {
const root = find(),
nodes = clone(subtree(root.id));
nodes.find((n) => n.id === root.id)!.parentId = null;
const id = a.assetId || uid("prefab");
p.assets.push({
id,
name: a.name || root.name,
kind: "prefab",
entities: nodes,
});
return { id };
}
case "prefab.instantiate": {
const asset = p.assets.find(
(a2) => a2.id === a.assetId && a2.kind === "prefab",
);
if (!asset?.entities) throw Error("Префаб не найден");
const nodes = clone(asset.entities),
ids = new Map(nodes.map((n) => [n.id, uid()]));
for (const n of nodes) {
remapEntityReferences(n, ids, p.scripts);
n.id = ids.get(n.id)!;
n.parentId = n.parentId ? ids.get(n.parentId)! : null;
if (!n.parentId && a.position)
n.transform.position = clone(a.position);
}
scene!.entities.push(...nodes);
return { id: nodes.find((n) => !n.parentId)!.id };
}
default:
throw Error("Неизвестная команда: " + op);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { emptyProject, type Project, type ScriptAsset } from "./schema.ts";
/** New projects contain no game content or pre-bound behaviors. */
export const builtinScripts = (): ScriptAsset[] => [];
// Preserve the optional flag for callers written before the empty-only release.
export function defaultProject(_blank = true): Project {
return emptyProject();
}