Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: {},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user