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

This commit is contained in:
emil28092005
2026-09-12 04:58:59 +03:00
parent 6f497d1977
commit 3afac743a4
32 changed files with 4999 additions and 270 deletions
+23 -3
View File
@@ -1,7 +1,17 @@
import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
import { type Project, clone, validateProject } from "./schema.ts";
export const assetExtension = (name: string) =>
/\.(gltf|png|jpg|jpeg|webp)$/i.exec(name)?.[0].toLowerCase() || ".glb";
export const mimeFor = (n: string) =>
n.toLowerCase().endsWith(".gltf") ? "model/gltf+json" : "model/gltf-binary";
(
({
".gltf": "model/gltf+json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
}) as Record<string, string>
)[assetExtension(n)] || "model/gltf-binary";
export function base64(a: Uint8Array) {
let s = "";
for (let i = 0; i < a.length; i += 16384)
@@ -14,7 +24,10 @@ export function decodeData(uri: string) {
throw Error("Ожидался data URI base64");
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
}
export function engineResource(path: string, base = typeof document === "undefined" ? undefined : document.baseURI) {
export function engineResource(
path: string,
base = typeof document === "undefined" ? undefined : document.baseURI,
) {
return base ? new URL(path.replace(/^\//, ""), base).href : path;
}
export async function readBytes(uri: string) {
@@ -35,7 +48,14 @@ async function pack(
const file =
"assets/" +
a.id +
(a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb");
(a.kind === "image"
? a.uri.startsWith("data:")
? "." +
(
/^data:image\/(png|jpeg|webp);/i.exec(a.uri)?.[1] || "png"
).replace("jpeg", "jpg")
: assetExtension(a.uri)
: assetExtension(a.name));
files[file] = await read(a.uri);
a.uri = file;
}
+331
View File
@@ -0,0 +1,331 @@
import * as B from "@babylonjs/core";
import type { Asset, Entity, Project } from "./schema.ts";
import { frameAt, type SpriteFrame } from "./two-d.ts";
const whiteFrame: SpriteFrame = {
name: "0",
x: 0,
y: 0,
width: 100,
height: 100,
pivot: [0.5, 0.5],
};
export function spriteQuad(
frame: SpriteFrame,
width: number,
height: number,
ppu: number,
size?: number[],
flipX = false,
flipY = false,
) {
const w = size?.[0] ?? frame.width / ppu,
h = size?.[1] ?? frame.height / ppu;
const x = -(flipX ? 1 - frame.pivot[0] : frame.pivot[0]) * w,
y = -(flipY ? frame.pivot[1] : 1 - frame.pivot[1]) * h;
let u0 = frame.x / width,
u1 = (frame.x + frame.width) / width,
v0 = 1 - (frame.y + frame.height) / height,
v1 = 1 - frame.y / height;
if (flipX) [u0, u1] = [u1, u0];
if (flipY) [v0, v1] = [v1, v0];
return {
positions: [x, y, 0, x + w, y, 0, x + w, y + h, 0, x, y + h, 0],
indices: [0, 1, 2, 0, 2, 3],
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
uvs: [u0, v0, u1, v0, u1, v1, u0, v1],
};
}
interface Visual {
mesh: B.Mesh;
material: B.PBRMaterial;
asset?: Asset;
node: Entity;
frame: number;
animation?: { name: string; clip: any; time: number; preview: boolean };
preview?: boolean;
configuredFrame?: number;
}
export class Graphics2D {
private debug?: B.LinesMesh;
visuals = new Map<string, Visual>();
textures = new Map<string, B.Texture>();
constructor(
private scene: B.Scene,
private onEvent?: (name: string, data: any, id: string) => void,
) {}
create(node: Entity, project: Project, root: B.TransformNode) {
const c = node.components.sprite || node.components.tilemap;
if (!c) return [];
const asset = project.assets.find(
(a) => a.id === c.assetId && a.kind === "image",
);
const mesh = new B.Mesh(node.id + "_2d", this.scene),
material = new B.PBRMaterial(node.id + "_2d_material", this.scene);
mesh.parent = root;
mesh.metadata = { entityId: node.id };
mesh.material = material;
material.unlit = c.lit !== true;
material.backFaceCulling = false;
material.metallic = 0;
material.roughness = 1;
material.transparencyMode = B.PBRMaterial.PBRMATERIAL_ALPHABLEND;
if (asset?.uri) {
const key = asset.id + "|" + asset.uri + "|" + asset.image?.filter;
let tex = this.textures.get(key);
if (!tex) {
tex = new B.Texture(
asset.uri,
this.scene,
true,
true,
asset.image?.filter === "nearest"
? B.Texture.NEAREST_SAMPLINGMODE
: B.Texture.BILINEAR_SAMPLINGMODE,
);
tex.hasAlpha = true;
tex.wrapU = tex.wrapV = B.Texture.CLAMP_ADDRESSMODE;
this.textures.set(key, tex);
}
material.albedoTexture = tex;
material.useAlphaFromAlbedoTexture = true;
}
this.visuals.set(node.id, { mesh, material, asset, node, frame: -1 });
this.update(node, true);
return [mesh];
}
remove(id: string) {
const v = this.visuals.get(id);
if (v) {
v.mesh.dispose();
v.material.dispose(false, false);
this.visuals.delete(id);
}
}
update(node: Entity, force = false) {
const v = this.visuals.get(node.id);
if (!v) return;
v.node = node;
const c = node.components.sprite || node.components.tilemap;
v.material.albedoColor = B.Color3.FromHexString(c.color || "#ffffff");
v.material.alpha = c.alpha ?? 1;
v.material.unlit = c.lit !== true;
v.mesh.computeWorldMatrix(true);
const y = v.mesh.absolutePosition.y;
// Preserve depth testing against 3D meshes; transparent sprites use explicit ordering.
v.mesh.alphaIndex =
(c.layer || 0) * 1e6 +
(c.order || 0) * 10 -
(c.sortY ? Math.atan(y) / Math.PI : 0);
if (node.components.tilemap) {
if (force) this.tileGeometry(v);
return;
}
const requested = c.frame || 0;
if (!v.animation && (!v.preview || requested !== v.configuredFrame))
this.setFrame(v, requested, force);
else if (force) this.setFrame(v, v.frame, true);
v.configuredFrame = requested;
}
private setFrame(v: Visual, index: number, force = false) {
if (!force && v.frame === index) return;
v.frame = index;
const im = v.asset?.image,
frame = im?.frames[index] || im?.frames[0] || whiteFrame,
c = v.node.components.sprite;
const data = spriteQuad(
frame,
im?.width || 100,
im?.height || 100,
im?.pixelsPerUnit || 100,
c.size,
c.flipX,
c.flipY,
);
const vd = new B.VertexData();
Object.assign(vd, data);
vd.applyToMesh(v.mesh, true);
}
private tileGeometry(v: Visual) {
const c = v.node.components.tilemap,
im = v.asset?.image,
data = {
positions: [] as number[],
indices: [] as number[],
normals: [] as number[],
uvs: [] as number[],
};
for (const cell of c.cells) {
const frame = im?.frames[cell.frame] || whiteFrame,
q = spriteQuad(
{ ...frame, pivot: [0, 1] },
im?.width || 100,
im?.height || 100,
im?.pixelsPerUnit || 100,
c.tileSize,
);
const base = data.positions.length / 3;
for (let i = 0; i < q.positions.length; i += 3) {
q.positions[i] += cell.x * c.tileSize[0];
q.positions[i + 1] += cell.y * c.tileSize[1];
}
data.positions.push(...q.positions);
data.normals.push(...q.normals);
data.uvs.push(...q.uvs);
data.indices.push(...q.indices.map((i) => i + base));
}
// Empty maps still have a pickable editor extent supplied by the XY grid.
const vd = new B.VertexData();
Object.assign(vd, data);
vd.applyToMesh(v.mesh, true);
v.mesh.isVisible = !!c.cells.length;
}
play(id: string, name: string, loop?: boolean, preview = false) {
const v = this.visuals.get(id),
config = v?.node.components.spriteAnimator;
const clip = config?.clips.find((c: any) => c.name === name);
if (!v || !clip) return false;
if (v.animation?.name === name && !preview) return true;
v.animation = {
name,
clip: { ...clip, ...(loop !== undefined ? { loop } : {}) },
time: 0,
preview,
};
this.setFrame(v, clip.frames[0]);
return true;
}
start() {
for (const [id, v] of this.visuals) {
v.animation = undefined;
v.preview = false;
if (v.node.components.sprite)
this.setFrame(v, v.node.components.sprite.frame || 0);
const autoplay = v.node.components.spriteAnimator?.autoplay;
if (autoplay) this.play(id, autoplay);
}
}
tick(
dt: number,
playing: boolean,
paused: boolean,
physics: Record<string, any> = {},
) {
if (paused) return;
for (const [id, v] of this.visuals) {
this.update(v.node);
const config = v.node.components.spriteAnimator,
p = physics[id];
if (playing && config?.autoStates && p) {
const state =
!p.grounded && v.node.components.character2d?.mode !== "topDown"
? "jump"
: Math.hypot(p.velocity?.x || 0, p.velocity?.y || 0) > 0.1
? "run"
: "idle";
if (config.clips.some((c: any) => c.name === state))
this.play(id, state);
if (v.node.components.sprite && Math.abs(p.velocity.x) > 0.1) {
const flip = p.velocity.x < 0;
if (v.node.components.sprite.flipX !== flip) {
v.node.components.sprite.flipX = flip;
this.setFrame(v, v.frame, true);
}
}
}
const a = v.animation;
if (!a || (!playing && !a.preview)) continue;
a.time += dt;
this.setFrame(v, frameAt(a.clip, a.time));
if (
a.clip.loop === false &&
a.time >= a.clip.frames.length / a.clip.fps
) {
v.animation = undefined;
v.preview = true;
this.onEvent?.("animationend2d", { clip: a.name }, id);
}
}
}
snapshot() {
return Object.fromEntries(
[...this.visuals]
.filter(([, v]) => v.node.components.sprite)
.map(([id, v]) => [
id,
{
frame: v.frame,
clip: v.animation?.name || null,
playing: !!v.animation,
},
]),
);
}
outline(node?: Entity, root?: B.TransformNode) {
this.debug?.dispose();
this.debug = undefined;
if (!node || !root) return;
const c = node.components.collider2d,
t = node.components.tilemap;
if (!c && !t && !node.components.sprite) return;
let points: number[][] = [];
if (t)
points = [
[0, 0],
[t.width * t.tileSize[0], 0],
[t.width * t.tileSize[0], t.height * t.tileSize[1]],
[0, t.height * t.tileSize[1]],
];
else if (!c) {
const box = this.visuals.get(node.id)!.mesh.getBoundingInfo().boundingBox;
points = [
[box.minimum.x, box.minimum.y],
[box.maximum.x, box.minimum.y],
[box.maximum.x, box.maximum.y],
[box.minimum.x, box.maximum.y],
];
} else if (c.shape === "polygon") points = c.points;
else if (c.shape === "circle" || c.shape === "capsule") {
const radius = c.radius || 0.5,
shaft =
c.shape === "capsule"
? Math.max(0, (c.height || 1.8) / 2 - radius)
: 0;
for (let i = 0; i < 48; i++) {
const a = (i / 48) * Math.PI * 2;
points.push([
Math.cos(a) * radius,
Math.sin(a) * radius + (Math.sin(a) >= 0 ? shaft : -shaft),
]);
}
} else {
const [w, h] = c.size || [1, 1];
points = [
[-w / 2, -h / 2],
[w / 2, -h / 2],
[w / 2, h / 2],
[-w / 2, h / 2],
];
}
const offset = t || !c ? [0, 0] : c.offset || [0, 0],
vectors = [...points, points[0]].map(
(p) => new B.Vector3(p[0] + offset[0], p[1] + offset[1], 0.005),
);
this.debug = B.MeshBuilder.CreateLines(
"Collider2D outline",
{ points: vectors },
this.scene,
);
this.debug.parent = root;
this.debug.color = B.Color3.FromHexString(
c?.sensor ? "#ffd274" : "#68e6bd",
);
this.debug.isPickable = false;
this.debug.renderingGroupId = 1;
}
dispose() {
this.debug?.dispose();
for (const id of this.visuals.keys()) this.remove(id);
for (const t of this.textures.values()) t.dispose();
this.textures.clear();
}
}
+84
View File
@@ -0,0 +1,84 @@
import { base64 } from "./archive.ts";
import { uid, type Asset } from "./schema.ts";
import { sliceImage } from "./two-d.ts";
/** Read image dimensions without DOM, so browser and MCP import share validation. */
export function imageInfo(bytes: Uint8Array) {
if (bytes.length > 25 * 1024 * 1024)
throw Error("Изображение превышает 25 МБ");
const d = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let width = 0,
height = 0,
mime = "",
ext = "";
if (
bytes.length >= 24 &&
d.getUint32(0) === 0x89504e47 &&
d.getUint32(4) === 0x0d0a1a0a
) {
width = d.getUint32(16);
height = d.getUint32(20);
mime = "image/png";
ext = "png";
} else if (
bytes.length >= 30 &&
d.getUint32(0) === 0x52494646 &&
d.getUint32(8) === 0x57454250
) {
const kind = d.getUint32(12);
if (kind === 0x56503858) {
width = 1 + bytes[24] + bytes[25] * 256 + bytes[26] * 65536;
height = 1 + bytes[27] + bytes[28] * 256 + bytes[29] * 65536;
} else if (kind === 0x5650384c && bytes[20] === 0x2f) {
const bits = d.getUint32(21, true);
width = (bits & 0x3fff) + 1;
height = ((bits >>> 14) & 0x3fff) + 1;
} else if (
kind === 0x56503820 &&
bytes[23] === 0x9d &&
bytes[24] === 1 &&
bytes[25] === 0x2a
) {
width = d.getUint16(26, true) & 0x3fff;
height = d.getUint16(28, true) & 0x3fff;
}
mime = "image/webp";
ext = "webp";
} else if (bytes.length > 4 && d.getUint16(0) === 0xffd8) {
let offset = 2;
while (offset + 4 <= bytes.length) {
if (bytes[offset++] !== 255) break;
while (bytes[offset] === 255) offset++;
const marker = bytes[offset++];
if (marker === 0xda || marker === 0xd9) break;
const length = d.getUint16(offset);
if (length < 2 || offset + length > bytes.length) break;
if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 7) {
height = d.getUint16(offset + 3);
width = d.getUint16(offset + 5);
break;
}
offset += length;
}
mime = "image/jpeg";
ext = "jpg";
}
if (!width || !height || width > 16384 || height > 16384)
throw Error("Ожидалось PNG, JPEG или WebP размером до 16384×16384");
return { width, height, mime, ext };
}
export function imageAsset(bytes: Uint8Array, name: string): Asset {
const info = imageInfo(bytes);
return {
id: uid("image"),
name: name.replace(/\.[^.]+$/, "") + "." + info.ext,
kind: "image",
uri: `data:${info.mime};base64,${base64(bytes)}`,
image: {
width: info.width,
height: info.height,
pixelsPerUnit: 100,
filter: "nearest",
frames: sliceImage(info.width, info.height, info.width, info.height),
},
};
}
+481
View File
@@ -0,0 +1,481 @@
import * as B from "@babylonjs/core";
import type { Entity, Project, Vec3 } from "./schema.ts";
import { tileRectangles } from "./two-d.ts";
let modulePromise: Promise<any> | undefined;
export const loadRapier2D = () =>
(modulePromise ??= import("@dimforge/rapier2d-compat").then(async (r) => {
await r.init();
return r;
}));
interface BodyEntry {
node: Entity;
root: B.TransformNode;
body: any;
colliders: any[];
controller?: any;
velocity: { x: number; y: number };
grounded: boolean;
contacts: any[];
coyote: number;
halfHeight: number;
offsetY: number;
previousY: number;
}
export class Physics2D {
world: any;
queue: any;
entries = new Map<string, BodyEntry>();
owners = new Map<number, string>();
events: any[] = [];
joints = new Map<string, any>();
private controllerPairs = new Map<string, [string, string]>();
private input: any = {};
private jump = false;
private lastJump = false;
constructor(
private R: any,
gravity: number[] = [0, -9.81],
) {
this.world = new R.World({ x: gravity[0], y: gravity[1] });
this.world.timestep = 1 / 60;
this.queue = new R.EventQueue(true);
}
static async create(project: Project) {
return new Physics2D(
await loadRapier2D(),
project.settings.physics2d?.gravity,
);
}
add(node: Entity, root: B.TransformNode) {
const c = node.components.collider2d,
tile = node.components.tilemap;
if (!c && !tile?.collisions) return;
const R = this.R,
rb = node.components.rigidbody2d || { type: "fixed" };
root.computeWorldMatrix(true);
const pos = root.getAbsolutePosition(),
scale = root.absoluteScaling,
rotation = root.absoluteRotationQuaternion.toEulerAngles().z;
const desc = (
rb.type === "dynamic"
? R.RigidBodyDesc.dynamic()
: rb.type === "kinematic"
? R.RigidBodyDesc.kinematicPositionBased()
: R.RigidBodyDesc.fixed()
)
.setTranslation(pos.x, pos.y)
.setRotation(rotation)
.setGravityScale(rb.gravityScale ?? 1)
.setLinearDamping(rb.linearDamping ?? 0)
.setAngularDamping(rb.angularDamping ?? 0)
.setCcdEnabled(rb.ccd !== false);
if (rb.lockRotation) desc.lockRotations();
const body = this.world.createRigidBody(desc),
colliders: any[] = [];
let halfHeight = 0.5,
offsetY = (c?.offset?.[1] || 0) * scale.y;
const add = (shape: any, offset: number[], sensor = false) => {
shape
.setTranslation(offset[0], offset[1])
.setMass(rb.mass ?? 1)
.setFriction(rb.friction ?? 0.5)
.setRestitution(rb.restitution ?? 0)
.setSensor(sensor)
.setCollisionGroups(
(((c?.membership ?? 1) << 16) | (c?.mask ?? 65535)) >>> 0,
)
.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS)
.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
if (c?.oneWay) shape.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS);
const col = this.world.createCollider(shape, body);
colliders.push(col);
this.owners.set(col.handle, node.id);
};
if (tile?.collisions) {
for (const rect of tileRectangles(tile.cells))
add(
R.ColliderDesc.cuboid(
(rect.width * tile.tileSize[0] * Math.abs(scale.x)) / 2,
(rect.height * tile.tileSize[1] * Math.abs(scale.y)) / 2,
),
[
(rect.x + rect.width / 2) * tile.tileSize[0] * scale.x,
(rect.y + rect.height / 2) * tile.tileSize[1] * scale.y,
],
);
} else {
let shape;
const size = c.size || [1, 1];
halfHeight = (size[1] * Math.abs(scale.y)) / 2;
if (c.shape === "circle") {
halfHeight =
(c.radius || 0.5) * Math.max(Math.abs(scale.x), Math.abs(scale.y));
shape = R.ColliderDesc.ball(halfHeight);
} else if (c.shape === "capsule") {
const radius = (c.radius || 0.3) * Math.abs(scale.x);
halfHeight = Math.max(
radius,
((c.height || 1.8) * Math.abs(scale.y)) / 2,
);
shape = R.ColliderDesc.capsule(halfHeight - radius, radius);
} else if (c.shape === "polygon") {
const vertices = new Float32Array(
c.points.flatMap((p: number[]) => [p[0] * scale.x, p[1] * scale.y]),
);
shape = R.ColliderDesc.convexHull(vertices);
halfHeight = Math.max(
...c.points.map((p: number[]) => Math.abs(p[1] * scale.y)),
);
if (!shape) throw Error("Не удалось построить Collider2D");
} else
shape = R.ColliderDesc.cuboid(
(size[0] * Math.abs(scale.x)) / 2,
halfHeight,
);
add(shape, [(c.offset?.[0] || 0) * scale.x, offsetY], !!c.sensor);
}
const e: BodyEntry = {
node,
root,
body,
colliders,
velocity: { x: 0, y: 0 },
grounded: false,
contacts: [],
coyote: 0,
halfHeight,
offsetY,
previousY: pos.y,
};
if (rb.type === "kinematic" && colliders.length === 1 && !c?.sensor) {
e.controller = this.world.createCharacterController(0.015);
e.controller.setUp({ x: 0, y: 1 });
const step = node.components.character2d?.autostep ?? 0;
if (step > 0) e.controller.enableAutostep(step, 0.15, true);
e.controller.disableSnapToGround();
e.controller.setApplyImpulsesToDynamicBodies(true);
}
this.entries.set(node.id, e);
this.setEnabled(node.id);
}
connect() {
for (const [id, e] of this.entries) {
const j = e.node.components.joint2d,
target = j && this.entries.get(j.targetId);
if (!j || !target || this.joints.has(id)) continue;
const a = { x: j.anchor[0], y: j.anchor[1] },
b = { x: j.targetAnchor[0], y: j.targetAnchor[1] },
J = this.R.JointData;
let data;
if (j.type === "revolute") data = J.revolute(a, b);
else if (j.type === "rope") data = J.rope(j.length || 1, a, b);
else if (j.type === "spring")
data = J.spring(j.length || 1, j.stiffness ?? 50, j.damping ?? 5, a, b);
else data = J.fixed(a, 0, b, 0);
const joint = this.world.createImpulseJoint(
data,
e.body,
target.body,
true,
);
joint.setContactsEnabled(false);
this.joints.set(id, joint);
}
}
setInput(input: any) {
this.input = input;
const jump = !!(input.jump || input.jumpPressed);
if (jump && !this.lastJump) this.jump = true;
this.lastJump = jump;
}
setEnabled(id: string) {
const e = this.entries.get(id);
if (!e) return;
const enabled =
e.root.isEnabled() && e.node.components.collider2d?.enabled !== false;
e.body.setEnabled(enabled);
for (const c of e.colliders) c.setEnabled(enabled);
}
velocity(id: string, value: any) {
const e = this.entries.get(id);
if (!e) return;
if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() };
for (const k of ["x", "y"] as const)
if (value[k] !== undefined) {
if (!Number.isFinite(value[k])) throw Error("Неверная скорость 2D");
e.velocity[k] = Math.max(-200, Math.min(200, value[k]));
}
if (value.gravityScale !== undefined) {
if (!Number.isFinite(value.gravityScale))
throw Error("Неверная гравитация");
e.body.setGravityScale(value.gravityScale, true);
}
if (e.body.isDynamic()) e.body.setLinvel(e.velocity, true);
}
impulse(id: string, value: number[]) {
if (
!Array.isArray(value) ||
value.length !== 2 ||
!value.every(Number.isFinite)
)
throw Error("Импульс 2D: [x,y]");
this.entries.get(id)?.body.applyImpulse({ x: value[0], y: value[1] }, true);
}
teleport(id: string, position: number[]) {
const e = this.entries.get(id);
if (!e) return;
if (
!Array.isArray(position) ||
position.length !== 3 ||
!position.every(Number.isFinite)
)
throw Error("Позиция 2D: [x,y,z]");
e.node.transform.position = [...position] as Vec3;
e.root.position.copyFromFloats(...e.node.transform.position);
e.root.computeWorldMatrix(true);
this.patch(id, true);
e.body.setLinvel({ x: 0, y: 0 }, true);
e.velocity = { x: 0, y: 0 };
e.grounded = false;
e.contacts = [];
}
patch(id: string, position = false, rotation = false) {
const e = this.entries.get(id);
if (!e) return;
e.root.computeWorldMatrix(true);
if (position) {
const p = e.root.getAbsolutePosition();
e.body.setTranslation({ x: p.x, y: p.y }, true);
if (e.body.isKinematic())
e.body.setNextKinematicTranslation({ x: p.x, y: p.y });
e.previousY = p.y;
}
if (rotation) {
const angle = e.root.absoluteRotationQuaternion.toEulerAngles().z;
e.body.setRotation(angle, true);
if (e.body.isKinematic()) e.body.setNextKinematicRotation(angle);
}
this.setEnabled(id);
}
private accepts(platform: BodyEntry, other: BodyEntry) {
const top = platform.previousY + platform.offsetY + platform.halfHeight;
const vel = other.velocity;
return (
vel.y <= 0.05 &&
other.previousY + other.offsetY - other.halfHeight >= top - 0.08
);
}
step(dt: number, moves: Map<string, Vec3>, divisor = 1) {
for (const e of this.entries.values()) {
e.previousY = e.body.translation().y;
if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() };
}
for (const [id, e] of this.entries) {
e.contacts = [];
if (!e.body.isEnabled()) continue;
const c = e.node.components.character2d,
move = moves.get(id) || [0, 0, 0];
if (e.controller) {
if (c) {
if (c.controls !== false) {
e.velocity.x = (this.input.x || 0) * (c.speed ?? 5);
if (c.mode === "topDown") {
e.velocity.y =
(this.input.y ?? this.input.z ?? 0) * (c.speed ?? 5);
const length = Math.hypot(e.velocity.x, e.velocity.y);
if (length > (c.speed ?? 5)) {
e.velocity.x *= (c.speed ?? 5) / length;
e.velocity.y *= (c.speed ?? 5) / length;
}
}
}
if (c.mode !== "topDown") {
e.coyote = e.grounded ? 0.1 : Math.max(0, e.coyote - dt);
if (this.jump && c.controls !== false && e.coyote > 0) {
e.velocity.y = c.jumpSpeed ?? 8;
e.coyote = 0;
e.grounded = false;
}
e.velocity.y = Math.max(-50, e.velocity.y - (c.gravity ?? 20) * dt);
}
}
const desired = {
x: e.velocity.x * dt + move[0] / divisor,
y: e.velocity.y * dt + move[1] / divisor,
};
e.controller.computeColliderMovement(
e.colliders[0],
desired,
this.R.QueryFilterFlags.EXCLUDE_SENSORS,
e.colliders[0].collisionGroups(),
(col: any) => {
const owner = this.entries.get(this.owners.get(col.handle)!);
return (
!owner?.node.components.collider2d?.oneWay ||
this.accepts(owner, e)
);
},
);
const delta = e.controller.computedMovement(),
p = e.body.translation();
e.body.setNextKinematicTranslation({
x: p.x + delta.x,
y: p.y + delta.y,
});
e.grounded = e.controller.computedGrounded();
for (let i = 0; i < e.controller.numComputedCollisions(); i++) {
const hit = e.controller.computedCollision(i);
if (hit?.collider)
e.contacts.push({
entityId: this.owners.get(hit.collider.handle),
normal: [hit.normal1.x, hit.normal1.y, 0],
});
}
if (e.grounded && e.velocity.y < 0) e.velocity.y = 0;
if (e.contacts.some((c) => c.normal[1] < -0.5) && e.velocity.y > 0)
e.velocity.y = 0;
} else if (e.body.isKinematic()) {
const p = e.body.translation();
e.body.setNextKinematicTranslation({
x: p.x + e.velocity.x * dt + move[0] / divisor,
y: p.y + e.velocity.y * dt + move[1] / divisor,
});
} else if (move[0] || move[1]) {
const p = e.body.translation();
e.body.setTranslation(
{ x: p.x + move[0] / divisor, y: p.y + move[1] / divisor },
true,
);
}
}
this.jump = false;
this.world.step(this.queue, {
filterContactPair: (a: number, b: number) => {
const ea = this.entries.get(this.owners.get(a)!),
eb = this.entries.get(this.owners.get(b)!);
if (
ea &&
eb &&
((ea.node.components.collider2d?.oneWay && !this.accepts(ea, eb)) ||
(eb.node.components.collider2d?.oneWay && !this.accepts(eb, ea)))
)
return null;
return this.R.SolverFlags.COMPUTE_IMPULSE;
},
filterIntersectionPair: () => true,
});
this.queue.drainCollisionEvents(
(a: number, b: number, started: boolean) => {
const id = this.owners.get(a),
otherId = this.owners.get(b);
if (!id || !otherId) return;
const sensor =
this.world.getCollider(a)?.isSensor() ||
this.world.getCollider(b)?.isSensor();
if (
!sensor &&
(this.entries.get(id)?.controller ||
this.entries.get(otherId)?.controller)
)
return;
for (const [entityId, other] of [
[id, otherId],
[otherId, id],
])
this.events.push({
entityId,
otherId: other,
started,
sensor: !!sensor,
});
},
);
// Character-controller contacts stop at a small gap, so no solver collision is generated.
const pairs = new Map<string, [string, string]>();
for (const [id, e] of this.entries)
for (const hit of e.contacts)
if (hit.entityId) {
const pair = [id, hit.entityId].sort() as [string, string];
pairs.set(pair.join("|"), pair);
}
for (const [current, previous, started] of [
[pairs, this.controllerPairs, true],
[this.controllerPairs, pairs, false],
] as const)
for (const [key, [a, b]] of current)
if (!previous.has(key)) {
this.events.push(
{ entityId: a, otherId: b, started, sensor: false },
{ entityId: b, otherId: a, started, sensor: false },
);
}
this.controllerPairs = pairs;
if (this.events.length > 2000)
this.events.splice(0, this.events.length - 2000);
// Parents first: child physics poses are converted through their current world matrix.
const depth = (e: BodyEntry) => {
let n = e.root.parent,
d = 0;
while (n) {
d++;
n = n.parent;
}
return d;
};
for (const e of [...this.entries.values()].sort(
(a, b) => depth(a) - depth(b),
)) {
if (!e.body.isEnabled()) continue;
const pos = e.body.translation();
e.root.computeWorldMatrix(true);
const z = e.root.getAbsolutePosition().z;
let local = new B.Vector3(pos.x, pos.y, z);
if (e.root.parent)
local = B.Vector3.TransformCoordinates(
local,
B.Matrix.Invert(e.root.parent.getWorldMatrix()),
);
e.root.position.copyFrom(local);
e.node.transform.position = local.asArray() as Vec3;
if (e.body.isDynamic() || e.body.isKinematic()) {
let q = B.Quaternion.RotationAxis(B.Axis.Z, e.body.rotation());
if (e.root.parent instanceof B.TransformNode)
q = e.root.parent.absoluteRotationQuaternion.conjugate().multiply(q);
e.root.rotationQuaternion = q;
e.node.transform.rotation = q.toEulerAngles().asArray() as Vec3;
}
e.root.computeWorldMatrix(true);
}
}
snapshot() {
return Object.fromEntries(
[...this.entries].map(([id, e]) => [
id,
{
dimension: 2,
grounded: e.grounded,
velocity: {
...(e.body.isDynamic() ? e.body.linvel() : e.velocity),
z: 0,
},
contacts: e.contacts,
enabled: e.body.isEnabled(),
},
]),
);
}
drainEvents() {
const result = this.events;
this.events = [];
return result;
}
dispose() {
this.controllerPairs.clear();
this.entries.clear();
this.owners.clear();
this.joints.clear();
this.events = [];
this.queue.free();
this.world.free();
}
}
+3 -1
View File
@@ -37,7 +37,9 @@ async function boot() {
firstPerson = stats.firstPerson;
document.getElementById("hint")!.textContent = firstPerson
? "Клик · захват мыши / Esc · отпустить / WASD · ввод движения"
: "WASD и кнопки действий передаются скриптам проекта";
: stats.twoD
? "WASD / стрелки · движение, пробел · прыжок"
: "WASD и кнопки действий передаются скриптам проекта";
},
log: (level, message) => {
if (level !== "error") return;
+295 -44
View File
@@ -14,9 +14,14 @@ import { workerSource } from "./script-host.ts";
import { inspectModel } from "./model.ts";
import { CharacterMotor } from "./character.ts";
import { bindViewInput } from "./view-input.ts";
import { Graphics2D } from "./graphics2d.ts";
import { Physics2D } from "./physics2d.ts";
import { View2D } from "./view2d.ts";
import { validate2D, type TileCell } from "./two-d.ts";
import { RuntimePresentation } from "./presentation.ts";
export interface RuntimeCallbacks {
select?: (id: string | null) => void;
paintTiles?: (id: string, cells: TileCell[]) => void;
transform?: (id: string, t: Entity["transform"]) => void;
log?: (level: string, message: string, id?: string) => void;
stats?: (s: any) => void;
@@ -31,6 +36,9 @@ export interface RuntimeOptions {
}
let physicsModule: Promise<any> | null = null;
export class FormaRuntime {
graphics2d!: Graphics2D;
physics2d?: Physics2D;
view2d: View2D;
engine: B.Engine;
scene!: B.Scene;
camera!: B.ArcRotateCamera;
@@ -123,12 +131,18 @@ export class FormaRuntime {
false,
);
if (!options.headless) {
this.cleanup.push(bindViewInput(canvas, {
get playing() { return runtime.playing; },
get paused() { return runtime.paused; },
firstPerson: () => this.firstPerson(),
lookBy: (x, y) => this.lookBy(x, y),
}));
this.cleanup.push(
bindViewInput(canvas, {
get playing() {
return runtime.playing;
},
get paused() {
return runtime.paused;
},
firstPerson: () => this.firstPerson(),
lookBy: (x, y) => this.lookBy(x, y),
}),
);
const resize = new ResizeObserver(() => this.engine.resize());
resize.observe(canvas);
this.cleanup.push(() => resize.disconnect());
@@ -204,6 +218,20 @@ export class FormaRuntime {
B.Matrix.Identity(),
this.scene.activeCamera,
);
if (
(this.state.find((n) => n.enabled && n.components.camera)
?.components.camera?.mode || activeScene(this.document).mode) ===
"2d"
) {
if (Math.abs(ray.direction.z) > 1e-6) {
const t = -ray.origin.z / ray.direction.z;
this.input.aim = ray.origin
.add(ray.direction.scale(t))
.asArray() as Vec3;
this.input.pointer = true;
}
return;
}
if (Math.abs(ray.direction.y) > 1e-6) {
const t = -ray.origin.y / ray.direction.y;
if (t > 0) {
@@ -225,6 +253,7 @@ export class FormaRuntime {
window.removeEventListener("pointerup", pu);
});
}
this.view2d = new View2D(this, canvas);
this.engine.runRenderLoop(() => this.frame());
}
private firstPerson() {
@@ -241,7 +270,8 @@ export class FormaRuntime {
this.actionQueue.add(name);
}
releaseInput() {
if (!this.options.headless && document.pointerLockElement === this.canvas) document.exitPointerLock();
if (!this.options.headless && document.pointerLockElement === this.canvas)
document.exitPointerLock();
this.keys.clear();
this.actionQueue.clear();
this.automation = null;
@@ -252,20 +282,24 @@ export class FormaRuntime {
const handles = new Map(
[...this.colliders].map(([id, col]) => [col.handle, id]),
);
return Object.fromEntries(
[...this.motors].map(([id, m]) => [
id,
{
grounded: m.grounded,
velocity: { ...m.velocity },
actualVelocity: { ...m.actualVelocity },
contacts: m.contacts.map((c) => ({
entityId: handles.get(c.handle),
normal: c.normal,
})),
},
]),
);
return {
...this.physics2d?.snapshot(),
...Object.fromEntries(
[...this.motors].map(([id, m]) => [
id,
{
dimension: 3,
grounded: m.grounded,
velocity: { ...m.velocity },
actualVelocity: { ...m.actualVelocity },
contacts: m.contacts.map((c) => ({
entityId: handles.get(c.handle),
normal: c.normal,
})),
},
]),
),
};
}
private enqueue<T>(fn: () => Promise<T>) {
const p = this.tasks.then(() => {
@@ -295,6 +329,7 @@ export class FormaRuntime {
target: this.camera.target.clone(),
}
: null;
this.graphics2d?.dispose();
this.scene?.dispose();
this.nodes.clear();
this.animations.clear();
@@ -305,6 +340,9 @@ export class FormaRuntime {
this.flashes.clear();
const scene = (this.scene = new B.Scene(this.engine));
scene.useRightHandedSystem = true;
this.graphics2d = new Graphics2D(scene, (name, data, id) =>
this.callbacks.event?.(name, data, id),
);
scene.clearColor = B.Color4.FromHexString(p.settings.background + "ff");
this.camera = new B.ArcRotateCamera(
"Editor",
@@ -340,7 +378,8 @@ export class FormaRuntime {
}
scene.activeCamera = this.camera;
const sky = new B.HemisphericLight("Sky", B.Vector3.Up(), scene);
sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
sky.intensity =
p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
sky.groundColor = B.Color3.FromHexString("#8a8475");
const sun = new B.DirectionalLight(
"Sun",
@@ -380,6 +419,7 @@ export class FormaRuntime {
scene.onPointerObservable.add((info) => {
if (
!this.playing &&
!this.view2d.brush &&
info.type === B.PointerEventTypes.POINTERTAP &&
info.event.button === 0
)
@@ -413,9 +453,32 @@ export class FormaRuntime {
});
});
}
if (this.view2d?.enabled) {
const g = this.gizmos.gizmos;
if (g.positionGizmo) {
g.positionGizmo.zGizmo.isEnabled = false;
g.positionGizmo.xGizmo.isEnabled = true;
g.positionGizmo.yGizmo.isEnabled = true;
}
if (g.rotationGizmo) {
g.rotationGizmo.xGizmo.isEnabled = false;
g.rotationGizmo.yGizmo.isEnabled = false;
g.rotationGizmo.zGizmo.isEnabled = true;
}
if (g.scaleGizmo) g.scaleGizmo.zGizmo.isEnabled = false;
} else {
const g = this.gizmos.gizmos;
if (g.positionGizmo) g.positionGizmo.zGizmo.isEnabled = true;
if (g.rotationGizmo) {
g.rotationGizmo.xGizmo.isEnabled = true;
g.rotationGizmo.yGizmo.isEnabled = true;
}
if (g.scaleGizmo) g.scaleGizmo.zGizmo.isEnabled = true;
}
this.select(this.selection);
}
setSnap(enabled: boolean) {
this.view2d.snap = enabled ? 0.5 : 0;
const g = this.gizmos.gizmos;
if (g.positionGizmo) g.positionGizmo.snapDistance = enabled ? 0.5 : 0;
if (g.rotationGizmo)
@@ -426,25 +489,53 @@ export class FormaRuntime {
this.selection = id;
if (!this.highlight) return;
this.highlight.removeAllMeshes();
this.graphics2d?.outline(
this.playing ? undefined : this.state.find((n) => n.id === id),
this.nodes.get(id || ""),
);
const node = this.nodes.get(id || "");
if (node && !this.playing)
for (const mesh of node.getChildMeshes())
if (mesh instanceof B.Mesh)
if (
mesh instanceof B.Mesh &&
!this.graphics2d.visuals.has(mesh.metadata?.entityId)
)
this.highlight.addMesh(mesh, B.Color3.FromHexString("#d59565"));
this.gizmos.attachToNode(this.playing ? null : node || null);
}
setView2D(enabled: boolean) {
this.view2d.set(enabled);
}
focus(id?: string) {
const n = this.nodes.get(id || this.selection || "");
if (n && n.getChildMeshes().length) {
const b = n.getHierarchyBoundingVectors(true);
this.camera.setTarget(b.min.add(b.max).scale(0.5));
this.camera.radius = Math.max(4, B.Vector3.Distance(b.min, b.max) * 1.7);
if (this.view2d.enabled) {
this.view2d.width = Math.max(
2,
Math.max(
b.max.x - b.min.x,
((b.max.y - b.min.y) * this.engine.getRenderWidth()) /
this.engine.getRenderHeight(),
) * 1.4,
);
this.view2d.update();
} else
this.camera.radius = Math.max(
4,
B.Vector3.Distance(b.min, b.max) * 1.7,
);
} else {
this.camera.setTarget(n?.getAbsolutePosition() || B.Vector3.Zero());
this.camera.radius = n ? 6 : 31;
}
}
topView() {
if (this.view2d.enabled) {
this.view2d.set(true);
return;
}
this.camera.alpha = -Math.PI / 2;
this.camera.beta = 0.015;
this.camera.radius = 26;
@@ -458,9 +549,12 @@ export class FormaRuntime {
root.scaling.copyFromFloats(...n.transform.scale);
root.setEnabled(n.enabled);
root.computeWorldMatrix(true);
this.graphics2d?.update(n, true);
const c = n.components.material;
if (c && (n.components.mesh?.type !== "model" || c.override)) {
for (const mesh of root.getChildMeshes().filter(mesh => mesh.metadata?.entityId === n.id)) {
for (const mesh of root
.getChildMeshes()
.filter((mesh) => mesh.metadata?.entityId === n.id)) {
const mat = mesh.material;
if (mat instanceof B.PBRMaterial) {
mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697");
@@ -485,6 +579,7 @@ export class FormaRuntime {
if (n.parent === node) n.parent = null;
node.dispose();
}
this.graphics2d?.remove(id);
this.nodes.delete(id);
this.containers.get(id)?.dispose();
this.containers.delete(id);
@@ -503,9 +598,12 @@ export class FormaRuntime {
);
this.scene.shadowsEnabled = p.settings.shadows;
const sky = this.scene.getLightByName("Sky");
if (sky) sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
if (sky)
sky.intensity =
p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
const sun = this.scene.getLightByName("Sun");
if (sun) sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
if (sun)
sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
this.engine.setHardwareScalingLevel(1 / p.settings.renderScale);
const live = new Set(this.state.map((n) => n.id));
for (const id of this.nodes.keys()) if (!live.has(id)) this.removeNode(id);
@@ -515,6 +613,14 @@ export class FormaRuntime {
n.components.mesh,
n.components.material,
n.components.light,
n.components.sprite,
n.components.tilemap,
n.components.spriteAnimator,
p.assets.find(
(a) =>
a.id ===
(n.components.sprite?.assetId || n.components.tilemap?.assetId),
),
p.assets.find((a) => a.id === n.components.mesh?.assetId),
]);
if (signature !== this.signatures.get(n.id)) {
@@ -530,6 +636,7 @@ export class FormaRuntime {
node.parent = n.parentId ? this.nodes.get(n.parentId) || null : null;
node.computeWorldMatrix(true);
}
this.view2d.set(this.view2d.enabled);
this.select(this.selection);
}
private async createEntity(n: Entity, p: Project) {
@@ -548,7 +655,8 @@ export class FormaRuntime {
const a = p.assets.find((a) => a.id === m.assetId);
if (!a?.uri) throw Error("У модели нет файла");
let bytes: Uint8Array;
if (this.options.readAsset) bytes = await this.options.readAsset(a.uri);
if (this.options.readAsset)
bytes = await this.options.readAsset(a.uri);
else {
const response = await fetch(a.uri);
if (!response.ok) throw Error("Ошибка загрузки " + a.name);
@@ -560,27 +668,45 @@ export class FormaRuntime {
// and material animation targets independent. Mesh-only cloning can
// retain animation targets pointing into the cached source container.
const container = await B.LoadAssetContainerAsync(bytes, scene, {
pluginExtension: a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb",
pluginExtension: a.name.toLowerCase().endsWith(".gltf")
? ".gltf"
: ".glb",
name: a.name,
pluginOptions: { gltf: { animationStartMode: 0 } },
});
if (scene.isDisposed || this.disposed) { container.dispose(); return; }
if (scene.isDisposed || this.disposed) {
container.dispose();
return;
}
this.containers.set(n.id, container);
// getNodes() also includes Bones, whose parent must remain a Bone.
const roots = [...container.meshes, ...container.transformNodes, ...container.cameras, ...container.lights].filter(node => !node.parent);
const roots = [
...container.meshes,
...container.transformNodes,
...container.cameras,
...container.lights,
].filter((node) => !node.parent);
container.addAllToScene();
for (const imported of roots) imported.parent = root;
for (const camera of container.cameras) camera.detachControl();
this.animations.set(n.id, container.animationGroups);
container.animationGroups.forEach(group => group.stop());
container.animationGroups.forEach((group) => group.stop());
this.importInfo.set(a.id, {
...metadata,
clips: container.animationGroups.map(group => group.name),
cameraNames: container.cameras.map(camera => camera.name),
triangles: container.meshes.reduce((sum, mesh) => sum + mesh.getTotalIndices() / 3, 0),
clips: container.animationGroups.map((group) => group.name),
cameraNames: container.cameras.map((camera) => camera.name),
triangles: container.meshes.reduce(
(sum, mesh) => sum + mesh.getTotalIndices() / 3,
0,
),
});
for (const warning of metadata.warnings) this.log("warning", warning, n.id);
this.log("info", `Импорт ${a.name}: ${metadata.nodes} узлов, ${metadata.clips.length} анимаций, ${metadata.animatedProperties.length} анимированных свойств`, n.id);
for (const warning of metadata.warnings)
this.log("warning", warning, n.id);
this.log(
"info",
`Импорт ${a.name}: ${metadata.nodes} узлов, ${metadata.clips.length} анимаций, ${metadata.animatedProperties.length} анимированных свойств`,
n.id,
);
meshes = root.getChildMeshes();
} else if (m.type === "custom" || m.type === "geometry") {
const g =
@@ -705,6 +831,7 @@ export class FormaRuntime {
sign.position.y = n.components.checkpoint ? 5.2 : 0;
sign.isPickable = false;
}
meshes.push(...this.graphics2d.create(n, p, root));
for (const mesh of meshes) {
mesh.metadata = { ...mesh.metadata, entityId: n.id };
mesh.isPickable = true;
@@ -723,6 +850,15 @@ export class FormaRuntime {
this.world = new this.rapier.World({ x: 0, y: -9.81, z: 0 });
this.world.timestep = 1 / 60;
for (const n of this.state) this.addBody(n);
if (
this.state.some(
(n) => n.components.collider2d || n.components.tilemap?.collisions,
)
) {
this.physics2d = await Physics2D.create(this.document);
for (const n of this.state) this.physics2d.add(n, this.nodes.get(n.id)!);
this.physics2d.connect();
}
}
private addBody(n: Entity) {
const c = n.components.collider,
@@ -804,6 +940,7 @@ export class FormaRuntime {
this.world?.free();
return;
}
this.graphics2d.start();
this.playing = true;
this.paused = false;
this.runId = uid("run");
@@ -825,7 +962,8 @@ export class FormaRuntime {
this.currentAnims.clear();
for (const node of this.state) {
const animator = node.components.animator;
if (node.enabled && animator?.autoplay) this.animate(node.id, animator.autoplay, animator.loop !== false);
if (node.enabled && animator?.autoplay)
this.animate(node.id, animator.autoplay, animator.loop !== false);
}
if (!this.options.headless && p.settings.presentation) {
this.presentation?.dispose();
@@ -867,6 +1005,8 @@ export class FormaRuntime {
this.playing = false;
this.paused = false;
this.world?.free();
this.physics2d?.dispose();
this.physics2d = undefined;
this.world = null;
this.bodies.clear();
this.colliders.clear();
@@ -928,6 +1068,7 @@ export class FormaRuntime {
if (this.workerUrl) URL.revokeObjectURL(this.workerUrl);
}
animate(id: string, name: string, loop = true) {
if (this.graphics2d?.play(id, name, loop)) return;
const groups = this.animations.get(id) || [],
n = this.state.find((n) => n.id === id),
mapped = n?.components.animator?.[name.toLowerCase()] || name,
@@ -942,6 +1083,7 @@ export class FormaRuntime {
this.currentAnims.set(id, target.name);
}
previewAnimation(id: string, name: string) {
if (this.graphics2d?.play(id, name, undefined, true)) return;
this.animations.get(id)?.forEach((g) => g.stop());
this.currentAnims.delete(id);
this.animate(id, name, !["Attack", "Death"].includes(name));
@@ -957,8 +1099,17 @@ export class FormaRuntime {
!next.transform.rotation.every(Number.isFinite)
)
throw Error("Скрипт вернул некорректную трансформацию");
validate2D(next, this.document, this.state);
Object.assign(n, next);
this.applyTransform(n);
this.physics2d?.patch(
n.id,
!!c.patch.transform?.position,
!!c.patch.transform?.rotation,
);
if (c.patch.enabled !== undefined)
for (const id of this.physics2d?.entries.keys() || [])
this.physics2d!.setEnabled(id);
const root = this.nodes.get(n.id)!,
body = this.bodies.get(n.id);
if (body && c.patch.transform?.position) {
@@ -981,8 +1132,17 @@ export class FormaRuntime {
);
}
} else if (c.type === "velocity" && n) {
this.motors.get(n.id)?.set(c.value);
if (this.physics2d?.entries.has(n.id))
this.physics2d.velocity(n.id, c.value);
else this.motors.get(n.id)?.set(c.value);
} else if (c.type === "impulse2d" && n) {
this.physics2d?.impulse(n.id, c.value);
} else if (c.type === "teleport" && n) {
if (this.physics2d?.entries.has(n.id)) {
this.physics2d.teleport(n.id, c.position);
this.moves.delete(n.id);
continue;
}
const motor = this.motors.get(n.id);
if (!motor) throw Error("Teleport requires a character component");
motor.teleport(c.position);
@@ -1056,13 +1216,24 @@ export class FormaRuntime {
if (!n.parentId && position) n.transform.position = position;
await this.createEntity(n, this.document);
}
if (
!this.physics2d &&
nodes.some(
(n) => n.components.collider2d || n.components.tilemap?.collisions,
)
)
this.physics2d = await Physics2D.create(this.document);
for (const n of nodes) {
this.nodes.get(n.id)!.parent = n.parentId
? this.nodes.get(n.parentId)!
: null;
this.state.push(n);
this.addBody(n);
this.physics2d?.add(n, this.nodes.get(n.id)!);
const autoplay = n.components.spriteAnimator?.autoplay;
if (autoplay) this.graphics2d.play(n.id, autoplay);
}
this.physics2d?.connect();
}
private swing(n: Entity) {
const p = this.nodes.get(n.id)?.getAbsolutePosition();
@@ -1129,6 +1300,7 @@ export class FormaRuntime {
);
}
}
this.physics2d?.step(1 / 60, this.moves, count);
this.world.step();
this.accumulator -= 1 / 60;
}
@@ -1154,7 +1326,7 @@ export class FormaRuntime {
}
}
for (const [id, d] of this.moves)
if (!this.bodies.has(id)) {
if (!this.bodies.has(id) && !this.physics2d?.entries.has(id)) {
const n = this.state.find((n) => n.id === id);
if (n) {
n.transform.position = n.transform.position.map(
@@ -1197,6 +1369,7 @@ export class FormaRuntime {
})),
]),
),
sprites: this.graphics2d?.snapshot(),
imports: Object.fromEntries(this.importInfo),
logs: this.logs.slice(-30),
fps: Math.round(this.engine.getFps()),
@@ -1247,6 +1420,8 @@ export class FormaRuntime {
if (this.automation && now < this.automation.until)
Object.assign(input, this.automation);
else this.automation = null;
(input as any).y = this.automation?.y ?? input.z;
this.physics2d?.setInput(input);
this.physics(dt);
this.scriptTime += dt;
if (this.ready && !this.pending) {
@@ -1256,6 +1431,7 @@ export class FormaRuntime {
entities: this.state,
input,
physics: this.physicsSnapshot(),
events2d: this.physics2d?.drainEvents() || [],
dt: Math.min(0.1, this.scriptTime),
});
this.scriptTime = 0;
@@ -1267,7 +1443,12 @@ export class FormaRuntime {
target = c?.targetId
? this.nodes.get(c.targetId)?.getAbsolutePosition()
: B.Vector3.Zero();
if (c?.mode === "fixed") {
if (
c?.mode === "2d" ||
(!c && activeScene(this.document).mode === "2d")
) {
this.updateCamera2D();
} else if (c?.mode === "fixed") {
this.updateFixedCamera();
} else if (target && c?.mode === "firstPerson") {
const player = this.state.find((n) => n.id === c.targetId);
@@ -1323,6 +1504,13 @@ export class FormaRuntime {
if (t <= 0) this.flashes.delete(id);
else this.flashes.set(id, t - dt);
}
this.view2d.update();
this.graphics2d.tick(
dt,
this.playing,
this.paused,
this.physics2d?.snapshot(),
);
try {
this.scene.animationsEnabled = !(this.playing && this.paused);
this.scene.render();
@@ -1339,6 +1527,12 @@ export class FormaRuntime {
0,
),
firstPerson: this.firstPerson(),
twoD: this.state.some(
(n) =>
n.enabled &&
n.components.character2d?.controls !== false &&
n.components.character2d,
),
playing: this.playing,
});
}
@@ -1351,6 +1545,9 @@ export class FormaRuntime {
this.cleanup.forEach((fn) => fn());
this.world?.free();
this.world = null;
this.physics2d?.dispose();
this.graphics2d?.dispose();
this.view2d.dispose();
this.scene?.dispose();
this.engine.dispose();
}
@@ -1365,13 +1562,67 @@ export class FormaRuntime {
this.gameCamera.setTarget(B.Vector3.FromArray(c.lookAt || [0, 0, 0]));
this.gameCamera.fov = c.fov || 0.72;
}
private updateCamera2D() {
const node = this.state.find((n) => n.enabled && n.components.camera),
c = node?.components.camera || {};
const follow = c.targetId
? this.nodes.get(c.targetId)?.getAbsolutePosition()
: null;
const pos =
follow ||
(node ? this.nodes.get(node.id)?.getAbsolutePosition() : null) ||
B.Vector3.Zero();
let x = pos.x + (follow ? c.offset?.[0] || 0 : 0),
y = pos.y + (follow ? c.offset?.[1] || 0 : 0);
if (c.bounds) {
x = Math.max(c.bounds[0], Math.min(c.bounds[2], x));
y = Math.max(c.bounds[1], Math.min(c.bounds[3], y));
}
this.gameCamera.position.copyFromFloats(
x,
y,
follow ? pos.z + (c.distance || 20) : Math.max(20, pos.z),
);
this.gameCamera.mode = B.Camera.ORTHOGRAPHIC_CAMERA;
const width = this.engine.getRenderWidth(),
height = Math.max(1, this.engine.getRenderHeight()),
ppu = c.pixelsPerUnit || 100;
const w = c.pixelPerfect
? width /
(ppu * Math.max(1, Math.floor(width / ((c.orthoWidth || 20) * ppu))))
: c.orthoWidth || 20;
this.gameCamera.orthoLeft = -w / 2;
this.gameCamera.orthoRight = w / 2;
this.gameCamera.orthoTop = (w * height) / width / 2;
this.gameCamera.orthoBottom = (-w * height) / width / 2;
this.gameCamera.viewport = new B.Viewport(0, 0, 1, 1);
if (c.pixelPerfect) {
this.gameCamera.position.x = Math.round(x * ppu) / ppu;
this.gameCamera.position.y = Math.round(y * ppu) / ppu;
}
this.gameCamera.setTarget(
this.gameCamera.position.add(new B.Vector3(0, 0, -20)),
);
}
private updateProjection() {
const cameraEntity = this.state.find(n => n.enabled && n.components.camera);
const cameraEntity = this.state.find(
(n) => n.enabled && n.components.camera,
);
const c = cameraEntity?.components.camera;
if (c?.mode === "2d" || (!c && activeScene(this.document).mode === "2d")) {
this.scene.activeCamera = this.gameCamera;
this.updateCamera2D();
return;
}
if (cameraEntity && c?.mode === "imported") {
const cameras = this.containers.get(cameraEntity.id)?.cameras || [];
const imported = cameras.find(camera => camera.name === c.cameraName) || cameras[0];
if (imported) { this.scene.activeCamera = imported; return; }
const imported =
cameras.find((camera) => camera.name === c.cameraName) || cameras[0];
if (imported) {
this.scene.activeCamera = imported;
return;
}
}
this.scene.activeCamera = this.gameCamera;
const cam = this.gameCamera;
+99 -7
View File
@@ -1,3 +1,4 @@
import { validate2D, validateImage, type ImageSettings } from "./two-d.ts";
export type Vec3 = [number, number, number];
export type Component = Record<string, any>;
export interface Transform {
@@ -22,7 +23,8 @@ export interface Geometry {
export interface Asset {
id: string;
name: string;
kind: "model" | "geometry" | "prefab";
kind: "model" | "geometry" | "prefab" | "image";
image?: ImageSettings;
uri?: string;
geometry?: Geometry;
entities?: Entity[];
@@ -50,15 +52,26 @@ export interface Project {
name: string;
revision: number;
activeSceneId: string;
scenes: { id: string; name: string; entities: Entity[] }[];
scenes: {
id: string;
name: string;
mode?: "2d" | "3d";
entities: Entity[];
}[];
assets: Asset[];
scripts: ScriptAsset[];
settings: {
physics2d?: { gravity: [number, number] };
background: string;
ambient: number;
shadows: boolean;
renderScale: number;
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number; defaultLights?: boolean };
rendering?: {
toneMapping?: boolean;
exposure?: number;
contrast?: number;
defaultLights?: boolean;
};
controls?: Partial<
Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]>
>;
@@ -254,12 +267,16 @@ export function validateProject(p: Project) {
for (const c of Object.values(n.components))
if (!c || typeof c !== "object" || Array.isArray(c))
throw Error("Компонент должен быть объектом");
validate2D(n, p, list);
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")
!p.assets.some(
(a) =>
a.id === m.assetId && a.kind !== "prefab" && a.kind !== "image",
)
)
throw Error("Не найден ресурс " + m.assetId);
if (
@@ -304,13 +321,25 @@ export function validateProject(p: Project) {
}
}
};
for (const s of p.scenes) nodes(s.entities);
for (const s of p.scenes) {
if (s.mode && !["2d", "3d"].includes(s.mode))
throw Error("Неизвестный режим сцены");
nodes(s.entities);
}
if (
p.settings.physics2d &&
(!Array.isArray(p.settings.physics2d.gravity) ||
p.settings.physics2d.gravity.length !== 2 ||
!p.settings.physics2d.gravity.every(Number.isFinite))
)
throw Error("Некорректная гравитация 2D");
for (const a of p.assets) {
if (
typeof a.name !== "string" ||
!["model", "geometry", "prefab"].includes(a.kind)
!["model", "geometry", "prefab", "image"].includes(a.kind)
)
throw Error("Некорректный ресурс");
if (a.kind === "image") validateImage(a);
if (a.kind === "geometry") validateGeometry(a.geometry!);
if (a.kind === "prefab") {
nodes(a.entities!);
@@ -318,7 +347,11 @@ export function validateProject(p: Project) {
throw Error("Префабу нужен один корневой объект");
}
if (a.uri && !a.uri.startsWith("data:")) {
if (!/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf)$/i.test(a.uri))
if (
!/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf|png|jpg|jpeg|webp)$/i.test(
a.uri,
)
)
throw Error("Импортируйте ресурс в assets/, внешние пути запрещены");
}
}
@@ -347,6 +380,8 @@ export function remapEntityReferences(
) {
if (ids.has(n.components.camera?.targetId))
n.components.camera.targetId = ids.get(n.components.camera.targetId);
if (ids.has(n.components.joint2d?.targetId))
n.components.joint2d.targetId = ids.get(n.components.joint2d.targetId);
const binding = n.components.script,
script = scripts.find((s) => s.id === binding?.scriptId);
if (binding && script)
@@ -361,6 +396,63 @@ export function remapEntityReferences(
return n;
}
export const componentDefaults: Record<string, Component> = {
sprite: {
assetId: "",
frame: 0,
color: "#ffffff",
alpha: 1,
layer: 0,
order: 0,
flipX: false,
flipY: false,
lit: false,
},
tilemap: {
assetId: "",
tileSize: [1, 1],
width: 32,
height: 18,
cells: [],
layer: 0,
order: 0,
collisions: false,
},
collider2d: {
shape: "box",
size: [1, 1],
offset: [0, 0],
sensor: false,
oneWay: false,
membership: 1,
mask: 65535,
},
rigidbody2d: {
type: "dynamic",
mass: 1,
friction: 0.5,
restitution: 0,
gravityScale: 1,
lockRotation: true,
ccd: true,
},
character2d: {
mode: "platformer",
controls: true,
speed: 5,
jumpSpeed: 8,
gravity: 20,
autostep: 0.2,
},
spriteAnimator: { autoplay: "", clips: [] },
joint2d: {
type: "revolute",
targetId: "",
anchor: [0, 0],
targetAnchor: [0, 0],
length: 1,
stiffness: 50,
damping: 5,
},
mesh: { type: "box", size: [1, 1, 1] },
material: {
color: "#91a697",
+1 -1
View File
@@ -1 +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";
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) =>\n structuredClone(\n physics[id] || {\n grounded: false,\n velocity: { x: 0, y: 0, z: 0 },\n contacts: [],\n },\n ),\n velocity(value) {\n commands.push({ type: \"velocity\", id: i.id, value });\n },\n teleport(position, yaw) {\n commands.push({ type: \"teleport\", id: i.id, position, yaw });\n },\n emit(name, data = {}) {\n commands.push({ type: \"event\", id: i.id, name, data });\n },\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 impulse2D(value) {\n commands.push({ type: \"impulse2d\", id: i.id, value });\n },\n rotate2D(z) {\n this.patch(i.id, { transform: { rotation: [0, 0, z] } });\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 for (const event of m.events2d || [])\n if (event.entityId === i.id) i.behavior.collision2d?.(api(i), event);\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";
+25 -4
View File
@@ -22,10 +22,23 @@ function api(i) {
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 }); },
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),
@@ -42,6 +55,12 @@ function api(i) {
move(delta) {
commands.push({ type: "move", id: i.id, delta });
},
impulse2D(value) {
commands.push({ type: "impulse2d", id: i.id, value });
},
rotate2D(z) {
this.patch(i.id, { transform: { rotation: [0, 0, z] } });
},
rotate(y) {
this.patch(i.id, { transform: { rotation: [0, y, 0] } });
},
@@ -126,6 +145,8 @@ self.onmessage = (e) => {
if (i.failed || !entities.some((n) => n.id === i.id && n.enabled))
continue;
try {
for (const event of m.events2d || [])
if (event.entityId === i.id) i.behavior.collision2d?.(api(i), event);
i.behavior.update?.(api(i), m.dt);
} catch (e) {
fail(i, e);
+6
View File
@@ -146,10 +146,16 @@ export class ProjectStore {
case "project.settings":
deepMerge(p.settings, a);
return p.settings;
case "scene.configure": {
if (!scene) throw Error("Сцена не найдена");
scene.mode = a.mode;
return { id: scene.id };
}
case "scene.create": {
const s = {
id: a.id || uid("scene"),
name: a.name || "Сцена",
...(a.mode ? { mode: a.mode } : {}),
entities: [],
};
p.scenes.push(s);
+135
View File
@@ -0,0 +1,135 @@
import { emptyProject, entity, activeScene, type Asset } from "./schema.ts";
import { sliceImage } from "./two-d.ts";
/** Small original pixel-art sheet for the runnable 2D example; no external assets. */
const sheet =
"iVBORw0KGgoAAAANSUhEUgAAAGAAAAAQCAYAAADpunr5AAAAw0lEQVR4nGNgGOGgeHPBf0rw0nQHijCGg74d7f+PDxPy0FDTPxoBwyQCLs0KJwmTFAGSaqZgTG4ADGb9MU6O/ynBoxEwyCJgWrQjXkxWBAzmIoRS/aMRMBoBmI733HgVKyYUCENRPywgS4I9UPBoBIxGwNAIQGpFwGgRNBoBhD0Awrg8P1T1D6oIAAF8jicUAENRP7EB7WBoBcf4IoDijpiqVdx/GMYnhg8gq6dELz3sH42AAbafWkUQ1QbjRiOAvhEAAD+44yBoQmIqAAAAAElFTkSuQmCC";
export function project2D(example = false) {
const p = emptyProject();
p.name = example ? "2D Platformer" : "2D Project";
const scene = activeScene(p);
scene.name = "2D Scene";
scene.mode = "2d";
p.settings.background = "#233546";
p.settings.physics2d = { gravity: [0, -9.81] };
if (!example) return p;
const a: Asset = {
id: "sprites_2d",
kind: "image",
name: "platformer.png",
uri: "data:image/png;base64," + sheet,
image: {
width: 96,
height: 16,
pixelsPerUnit: 16,
filter: "nearest",
frames: sliceImage(96, 16, 16, 16),
},
};
p.assets = [a];
const sprite = (frame: number, size = [1, 1]) => ({
assetId: a.id,
frame,
size,
});
const player = entity(
"Player",
{
sprite: sprite(0, [1, 1.5]),
spriteAnimator: {
autoplay: "idle",
autoStates: true,
clips: [
{ name: "idle", frames: [0], fps: 4, loop: true },
{ name: "run", frames: [1, 2], fps: 10, loop: true },
{ name: "jump", frames: [3], fps: 1, loop: true },
],
},
collider2d: { shape: "capsule", radius: 0.3, height: 1.4 },
rigidbody2d: { type: "kinematic", lockRotation: true },
character2d: {
mode: "platformer",
controls: true,
speed: 5,
jumpSpeed: 9,
gravity: 20,
autostep: 0.15,
},
},
[3, 2, 0],
"player_2d",
);
const ground = entity(
"Tilemap",
{
tilemap: {
assetId: a.id,
width: 26,
height: 10,
tileSize: [1, 1],
cells: [
...Array.from({ length: 26 }, (_, x) => ({ x, y: 0, frame: 4 })),
...Array.from({ length: 4 }, (_, x) => ({
x: x + 7,
y: 2,
frame: 4,
})),
...Array.from({ length: 4 }, (_, x) => ({
x: x + 18,
y: 3,
frame: 4,
})),
],
collisions: true,
layer: -1,
},
},
[0, -1, 0],
"tilemap_2d",
);
const platform = entity(
"One-way platform",
{
sprite: { ...sprite(4, [3, 0.3]), color: "#90bdc8" },
collider2d: { shape: "box", size: [3, 0.3], oneWay: true },
rigidbody2d: { type: "fixed" },
},
[14, 3, 0],
"platform_2d",
);
const crate = entity(
"Dynamic crate",
{
sprite: sprite(5),
collider2d: { shape: "box", size: [1, 1] },
rigidbody2d: { type: "dynamic", mass: 1, lockRotation: false },
},
[5, 4, 0],
"crate_2d",
);
const camera = entity(
"Camera 2D",
{
camera: {
mode: "2d",
orthoWidth: 22,
targetId: player.id,
offset: [5, 3, 0],
bounds: [11, 4, 15, 4],
},
},
[11, 4, 20],
"camera_2d",
);
const background = entity(
"3D object in the same scene",
{
mesh: { type: "box", size: [2, 2, 2] },
material: { color: "#45647c", roughness: 1 },
},
[20, 5, -4],
"background_3d",
);
background.transform.rotation = [0.3, 0.4, 0.2];
scene.entities = [ground, player, platform, crate, camera, background];
return p;
}
+431
View File
@@ -0,0 +1,431 @@
import type { Asset, Entity, Project } from "./schema.ts";
export interface SpriteFrame {
name: string;
x: number;
y: number;
width: number;
height: number;
pivot: [number, number];
}
export interface ImageSettings {
width: number;
height: number;
pixelsPerUnit: number;
filter: "nearest" | "linear";
frames: SpriteFrame[];
}
export interface TileCell {
x: number;
y: number;
frame: number;
solid?: boolean;
}
export const is2D = (n: Entity) =>
!!(
n.components.sprite ||
n.components.tilemap ||
n.components.collider2d ||
n.components.rigidbody2d
);
const finite = (n: any) => typeof n === "number" && Number.isFinite(n);
const pair = (v: any) => Array.isArray(v) && v.length === 2 && v.every(finite);
const positive = (v: any) => finite(v) && v > 0;
const integer = (v: any, min: number, max: number) =>
Number.isInteger(v) && v >= min && v <= max;
const fail = (s: string): never => {
throw Error("2D: " + s);
};
export function validateImage(a: Asset) {
const im = a.image;
if (!a.uri) fail("изображению нужен URI");
if (
!im ||
!integer(im.width, 1, 16384) ||
!integer(im.height, 1, 16384) ||
!positive(im.pixelsPerUnit) ||
!["nearest", "linear"].includes(im.filter) ||
!Array.isArray(im.frames) ||
!im.frames.length ||
im.frames.length > 4096
)
fail("некорректные настройки изображения");
for (const f of im!.frames)
if (
typeof f.name !== "string" ||
!integer(f.x, 0, im!.width - 1) ||
!integer(f.y, 0, im!.height - 1) ||
!integer(f.width, 1, im!.width - f.x) ||
!integer(f.height, 1, im!.height - f.y) ||
!pair(f.pivot) ||
f.pivot.some((v) => v < 0 || v > 1)
)
fail("кадр выходит за изображение или неверный pivot");
}
export function validate2D(n: Entity, p: Project, list: Entity[]) {
const c = n.components,
image = (id: string) =>
p.assets.find((a) => a.id === id && a.kind === "image");
if (c.sprite && c.mesh)
fail("Sprite и Mesh должны быть на разных объектах общей сцены");
if (c.sprite && c.tilemap)
fail("Sprite и Tilemap должны быть отдельными объектами");
for (const type of ["sprite", "tilemap"])
if (c[type]) {
const v = c[type],
a = v.assetId ? image(v.assetId) : null;
if (v.assetId && !a) fail("не найдено изображение " + v.assetId);
if (v.layer !== undefined && !integer(v.layer, -100, 100))
fail("слой: от -100 до 100");
if (v.order !== undefined && !integer(v.order, -10000, 10000))
fail("порядок: от -10000 до 10000");
if (
v.alpha !== undefined &&
(!finite(v.alpha) || v.alpha < 0 || v.alpha > 1)
)
fail("прозрачность: 0–1");
if (v.color !== undefined && !/^#[\da-f]{6}$/i.test(v.color))
fail("цвет #RRGGBB");
if (type === "sprite") {
if (v.size !== undefined && (!pair(v.size) || !v.size.every(positive)))
fail("размер спрайта должен быть положительным");
if (
v.frame !== undefined &&
!integer(v.frame, 0, Math.max(0, (a?.image?.frames.length || 1) - 1))
)
fail("нет такого кадра");
} else {
if (
!pair(v.tileSize) ||
!v.tileSize.every(positive) ||
!integer(v.width, 1, 512) ||
!integer(v.height, 1, 512) ||
!Array.isArray(v.cells) ||
v.cells.length > 65536
)
fail("неверная Tilemap (до 65536 тайлов)");
const keys = new Set();
for (const cell of v.cells) {
const key = cell.x + "," + cell.y;
if (
!integer(cell.x, 0, v.width - 1) ||
!integer(cell.y, 0, v.height - 1) ||
!integer(
cell.frame,
0,
Math.max(0, (a?.image?.frames.length || 1) - 1),
) ||
keys.has(key)
)
fail("неверный или повторный тайл");
keys.add(key);
}
}
}
if (c.spriteAnimator) {
if (
!c.sprite ||
!Array.isArray(c.spriteAnimator.clips) ||
c.spriteAnimator.clips.length > 100
)
fail("аниматору нужен спрайт и список клипов");
const count = image(c.sprite.assetId)?.image?.frames.length || 1,
names = new Set();
for (const clip of c.spriteAnimator.clips) {
if (
typeof clip.name !== "string" ||
!clip.name ||
names.has(clip.name) ||
!positive(clip.fps) ||
clip.fps > 120 ||
!Array.isArray(clip.frames) ||
!clip.frames.length ||
clip.frames.length > 4096 ||
!clip.frames.every((f: any) => integer(f, 0, count - 1))
)
fail("неверный клип спрайта");
names.add(clip.name);
}
if (c.spriteAnimator.autoplay && !names.has(c.spriteAnimator.autoplay))
fail("клип автозапуска не найден");
}
if (c.collider2d || c.rigidbody2d || c.character2d || c.tilemap?.collisions) {
if (c.collider || c.rigidbody || c.character)
fail("нельзя смешивать физические тела 2D и 3D на одном объекте");
let parent: Entity | undefined = n;
const ancestors = new Set<string>();
while (parent) {
if (ancestors.has(parent.id)) fail("цикл в иерархии");
ancestors.add(parent.id);
if (
Math.abs(parent.transform.rotation[0]) +
Math.abs(parent.transform.rotation[1]) >
1e-6
)
fail("физика XY допускает только вращение вокруг Z");
if (
parent !== n &&
Math.abs(parent.transform.scale[0] - parent.transform.scale[1]) > 1e-6
)
fail("родителям физики 2D нужен одинаковый масштаб X/Y");
parent = list.find((e) => e.id === parent!.parentId);
}
const r = c.rigidbody2d;
if (r) {
if (!["fixed", "dynamic", "kinematic"].includes(r.type))
fail("неверный тип тела");
for (const k of [
"mass",
"friction",
"restitution",
"linearDamping",
"angularDamping",
])
if (r[k] !== undefined && (!finite(r[k]) || r[k] < 0))
fail("неверное свойство тела " + k);
if (r.gravityScale !== undefined && !finite(r.gravityScale))
fail("неверная гравитация");
}
const col = c.collider2d;
if (col) {
if (!["box", "circle", "capsule", "polygon"].includes(col.shape))
fail("неверная форма коллайдера");
if (col.size && (!pair(col.size) || !col.size.every(positive)))
fail("неверный размер коллайдера");
if (col.offset && !pair(col.offset)) fail("неверное смещение");
if (col.radius !== undefined && !positive(col.radius)) fail("радиус > 0");
if (col.height !== undefined && !positive(col.height)) fail("высота > 0");
for (const k of ["membership", "mask"])
if (col[k] !== undefined && !integer(col[k], 0, 65535))
fail("маска: 0–65535");
if (col.shape === "polygon") {
if (
!Array.isArray(col.points) ||
col.points.length < 3 ||
col.points.length > 64 ||
!col.points.every(pair)
)
fail("полигон: 3–64 точки");
let sign = 0;
for (let i = 0; i < col.points.length; i++) {
const a = col.points[i],
b = col.points[(i + 1) % col.points.length],
d = col.points[(i + 2) % col.points.length],
cross =
(b[0] - a[0]) * (d[1] - b[1]) - (b[1] - a[1]) * (d[0] - b[0]);
if (Math.abs(cross) > 1e-8) {
if (sign && Math.sign(cross) !== sign)
fail(
"полигон должен быть выпуклым с последовательным порядком вершин",
);
sign = Math.sign(cross);
}
}
if (!sign) fail("полигон имеет нулевую площадь");
for (let i = 0; i < col.points.length; i++) {
const a = col.points[i],
b = col.points[(i + 1) % col.points.length];
for (const d of col.points) {
const cross =
(b[0] - a[0]) * (d[1] - a[1]) - (b[1] - a[1]) * (d[0] - a[0]);
if (cross * sign < -1e-8)
fail("полигон должен быть выпуклым без самопересечений");
}
}
}
}
if (col?.oneWay) {
let ancestor: Entity | undefined = n;
while (ancestor) {
if (Math.abs(ancestor.transform.rotation[2]) > 1e-6)
fail("односторонняя платформа должна быть горизонтальной");
ancestor = list.find((e) => e.id === ancestor!.parentId);
}
if (col.shape !== "box" || (r && r.type !== "fixed") || col.sensor)
fail("односторонняя платформа: неподвижный прямоугольник без триггера");
}
if (r && !col && !c.tilemap?.collisions)
fail("телу 2D нужен Collider2D или коллизии Tilemap");
if (c.character2d && (!col || r?.type !== "kinematic"))
fail("Character2D требует Collider2D и кинематическое тело");
if (c.character2d) {
for (const k of ["speed", "jumpSpeed", "gravity", "autostep"])
if (
c.character2d[k] !== undefined &&
(!finite(c.character2d[k]) || c.character2d[k] < 0)
)
fail("неверный параметр персонажа");
if (!["platformer", "topDown"].includes(c.character2d.mode))
fail("режим персонажа: platformer или topDown");
}
if (c.tilemap?.collisions && r && r.type !== "fixed")
fail("физическая Tilemap должна быть неподвижной");
}
if (c.joint2d) {
const j = c.joint2d;
if (
!["fixed", "revolute", "rope", "spring"].includes(j.type) ||
!c.rigidbody2d ||
!c.collider2d ||
!list.some(
(e) =>
e.id === j.targetId &&
e.id !== n.id &&
e.components.rigidbody2d &&
e.components.collider2d,
)
)
fail("шарниру нужны два различных тела 2D");
if (!pair(j.anchor) || !pair(j.targetAnchor))
fail("неверная точка крепления");
if (j.length !== undefined && !positive(j.length))
fail("длина шарнира > 0");
for (const k of ["stiffness", "damping"])
if (j[k] !== undefined && (!finite(j[k]) || j[k] < 0))
fail("неверный параметр пружины");
}
if (c.camera?.mode === "2d") {
if (
c.camera.bounds &&
(!Array.isArray(c.camera.bounds) ||
c.camera.bounds.length !== 4 ||
!c.camera.bounds.every(finite) ||
c.camera.bounds[0] > c.camera.bounds[2] ||
c.camera.bounds[1] > c.camera.bounds[3])
)
fail("границы камеры: [minX,minY,maxX,maxY]");
if (c.camera.orthoWidth !== undefined && !positive(c.camera.orthoWidth))
fail("ширина камеры > 0");
if (
c.camera.pixelsPerUnit !== undefined &&
!positive(c.camera.pixelsPerUnit)
)
fail("PPU камеры > 0");
}
}
export function sliceImage(
width: number,
height: number,
frameWidth: number,
frameHeight: number,
margin = 0,
spacing = 0,
): SpriteFrame[] {
if (
![width, height, frameWidth, frameHeight].every((v) =>
integer(v, 1, 16384),
) ||
![margin, spacing].every((v) => integer(v, 0, 16384))
)
fail("неверные размеры нарезки");
const frames: SpriteFrame[] = [];
for (
let y = margin;
y + frameHeight <= height - margin;
y += frameHeight + spacing
)
for (
let x = margin;
x + frameWidth <= width - margin;
x += frameWidth + spacing
) {
if (frames.length >= 4096) fail("слишком много кадров");
frames.push({
name: String(frames.length),
x,
y,
width: frameWidth,
height: frameHeight,
pivot: [0.5, 0.5],
});
}
if (!frames.length) fail("ни один кадр не помещается");
return frames;
}
export function frameAt(
clip: { frames: number[]; fps: number; loop?: boolean },
seconds: number,
) {
const i = Math.max(0, Math.floor(seconds * clip.fps));
return clip.frames[
clip.loop === false
? Math.min(i, clip.frames.length - 1)
: i % clip.frames.length
];
}
export function tileRectangles(cells: TileCell[]) {
const left = new Set(
cells.filter((c) => c.solid !== false).map((c) => `${c.x},${c.y}`),
),
rects: { x: number; y: number; width: number; height: number }[] = [];
for (const cell of [...cells].sort((a, b) => a.y - b.y || a.x - b.x)) {
if (!left.has(`${cell.x},${cell.y}`)) continue;
let width = 1,
height = 1;
while (left.has(`${cell.x + width},${cell.y}`)) width++;
while (
Array.from({ length: width }, (_, i) =>
left.has(`${cell.x + i},${cell.y + height}`),
).every(Boolean)
)
height++;
for (let y = 0; y < height; y++)
for (let x = 0; x < width; x++)
left.delete(`${cell.x + x},${cell.y + y}`);
rects.push({ x: cell.x, y: cell.y, width, height });
}
return rects;
}
export function paintTiles(
cells: TileCell[],
points: { x: number; y: number }[],
frame: number | null,
width: number,
height: number,
) {
const map = new Map(cells.map((c) => [`${c.x},${c.y}`, { ...c }]));
for (const p of points)
if (integer(p.x, 0, width - 1) && integer(p.y, 0, height - 1)) {
const key = `${p.x},${p.y}`;
if (frame === null) map.delete(key);
else map.set(key, { ...p, frame });
}
return [...map.values()];
}
export function fillTiles(
cells: TileCell[],
x: number,
y: number,
frame: number | null,
width: number,
height: number,
) {
const source = new Map(cells.map((c) => [`${c.x},${c.y}`, c.frame])),
from = source.get(`${x},${y}`) ?? null;
if (from === frame) return cells;
const seen = new Set<string>(),
stack = [{ x, y }],
points = [];
while (stack.length) {
const p = stack.pop()!,
key = `${p.x},${p.y}`;
if (
p.x < 0 ||
p.y < 0 ||
p.x >= width ||
p.y >= height ||
seen.has(key) ||
(source.get(key) ?? null) !== from
)
continue;
seen.add(key);
points.push(p);
if (points.length > 65536) fail("заливка превышает 65536 тайлов");
stack.push(
{ x: p.x - 1, y: p.y },
{ x: p.x + 1, y: p.y },
{ x: p.x, y: p.y - 1 },
{ x: p.x, y: p.y + 1 },
);
}
return paintTiles(cells, points, frame, width, height);
}
+278
View File
@@ -0,0 +1,278 @@
import * as B from "@babylonjs/core";
import type { FormaRuntime } from "./runtime.ts";
import { clone, type Vec3 } from "./schema.ts";
import { paintTiles, fillTiles } from "./two-d.ts";
export class View2D {
enabled = false;
width = 20;
snap = 0;
brush: { id: string; frame: number | null; fill?: boolean } | null = null;
private orbit: any;
private drag: any;
private cleanup: (() => void)[] = [];
constructor(
private runtime: FormaRuntime,
canvas: HTMLCanvasElement,
) {
if (runtime.options.headless) return;
const down = (e: PointerEvent) => {
const r = this.runtime;
if (!this.enabled || r.playing) return;
if (e.button === 1 || e.button === 2 || e.pointerType === "touch") {
this.drag = {
type: "pan",
x: e.clientX,
y: e.clientY,
target: r.camera.target.clone(),
};
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
if (e.button !== 0) return;
if (this.brush) {
const node = r.state.find((n) => n.id === this.brush!.id);
if (node?.components.tilemap) {
this.drag = {
type: "paint",
id: node.id,
original: clone(node.components.tilemap.cells),
last: null,
};
canvas.setPointerCapture(e.pointerId);
paint(e);
return;
}
}
const bounds = canvas.getBoundingClientRect(),
pick = r.scene.pick(
((e.clientX - bounds.left) * r.engine.getRenderWidth()) /
bounds.width,
((e.clientY - bounds.top) * r.engine.getRenderHeight()) /
bounds.height,
(m) => !!m.metadata?.entityId,
);
const id = pick?.pickedMesh?.metadata?.entityId;
if (id) {
r.callbacks.select?.(id);
const root = r.nodes.get(id)!;
if (r.tool === "move") {
this.drag = {
type: "move",
id,
point: this.point(
e.clientX,
e.clientY,
root.getAbsolutePosition().z,
),
original: root.position.clone(),
};
canvas.setPointerCapture(e.pointerId);
}
}
};
const paint = (e: PointerEvent) => {
const r = this.runtime,
node = r.state.find((n) => n.id === this.drag?.id),
root = node && r.nodes.get(node.id);
if (!node || !root || !this.brush) return;
const point = this.point(
e.clientX,
e.clientY,
root.getAbsolutePosition().z,
),
local = B.Vector3.TransformCoordinates(
point,
B.Matrix.Invert(root.getWorldMatrix()),
),
map = node.components.tilemap,
x = Math.floor(local.x / map.tileSize[0]),
y = Math.floor(local.y / map.tileSize[1]);
if (this.drag.last?.x === x && this.drag.last?.y === y) return;
const points = [];
if (this.brush.fill) {
if (this.drag.last) return;
map.cells = fillTiles(
map.cells,
x,
y,
this.brush.frame,
map.width,
map.height,
);
} else {
const prev = this.drag.last || { x, y },
count = Math.max(Math.abs(x - prev.x), Math.abs(y - prev.y));
for (let i = 0; i <= count; i++)
points.push({
x: Math.round(prev.x + ((x - prev.x) * i) / Math.max(1, count)),
y: Math.round(prev.y + ((y - prev.y) * i) / Math.max(1, count)),
});
map.cells = paintTiles(
map.cells,
points,
this.brush.frame,
map.width,
map.height,
);
}
this.drag.last = { x, y };
r.graphics2d.update(node, true);
};
const move = (e: PointerEvent) => {
const r = this.runtime,
d = this.drag;
if (!d || r.playing) return;
if (d.type === "pan") {
const rect = canvas.getBoundingClientRect(),
scale = this.width / rect.width;
r.camera.setTarget(
d.target.add(
new B.Vector3(
(d.x - e.clientX) * scale,
(e.clientY - d.y) * scale,
0,
),
),
);
} else if (d.type === "paint") paint(e);
else {
const root = r.nodes.get(d.id)!;
let delta = this.point(
e.clientX,
e.clientY,
root.getAbsolutePosition().z,
).subtract(d.point);
if (root.parent)
delta = B.Vector3.TransformNormal(
delta,
B.Matrix.Invert(root.parent.getWorldMatrix()),
);
const p = d.original.add(delta);
if (this.snap) {
p.x = Math.round(p.x / this.snap) * this.snap;
p.y = Math.round(p.y / this.snap) * this.snap;
}
root.position.copyFrom(p);
root.computeWorldMatrix(true);
}
};
const end = (e: PointerEvent) => {
const r = this.runtime,
d = this.drag;
if (!d) return;
this.drag = null;
if (d.type === "move") {
const root = r.nodes.get(d.id)!,
node = r.state.find((n) => n.id === d.id)!;
if (e.type === "pointercancel") root.position.copyFrom(d.original);
else if (!root.position.equals(d.original))
r.callbacks.transform?.(d.id, {
...clone(node.transform),
position: root.position.asArray() as Vec3,
});
}
if (d.type === "paint") {
const node = r.state.find((n) => n.id === d.id);
if (!node) return;
const cells = clone(node.components.tilemap.cells);
node.components.tilemap.cells = d.original;
r.graphics2d.update(node, true);
if (
e.type !== "pointercancel" &&
JSON.stringify(cells) !== JSON.stringify(d.original)
)
r.callbacks.paintTiles?.(d.id, cells);
}
};
const wheel = (e: WheelEvent) => {
if (this.enabled && !this.runtime.playing) {
e.preventDefault();
this.width = Math.max(
0.25,
Math.min(2000, this.width * Math.exp(e.deltaY * 0.001)),
);
this.update();
}
};
canvas.addEventListener("pointerdown", down);
canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerup", end);
canvas.addEventListener("pointercancel", end);
canvas.addEventListener("wheel", wheel, { passive: false });
this.cleanup.push(() => {
canvas.removeEventListener("pointerdown", down);
canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerup", end);
canvas.removeEventListener("pointercancel", end);
canvas.removeEventListener("wheel", wheel);
});
}
point(x: number, y: number, z = 0) {
const r = this.runtime,
rect = r.canvas.getBoundingClientRect(),
ray = r.scene.createPickingRay(
((x - rect.left) * r.engine.getRenderWidth()) / rect.width,
((y - rect.top) * r.engine.getRenderHeight()) / rect.height,
B.Matrix.Identity(),
r.camera,
);
const t = (z - ray.origin.z) / ray.direction.z;
return ray.origin.add(ray.direction.scale(t));
}
set(enabled: boolean) {
const r = this.runtime;
this.enabled = enabled;
if (!r.camera) return;
if (enabled) {
if (!this.orbit)
this.orbit = {
alpha: r.camera.alpha,
beta: r.camera.beta,
radius: r.camera.radius,
target: r.camera.target.clone(),
};
r.camera.detachControl();
r.camera.alpha = Math.PI / 2;
r.camera.beta = Math.PI / 2;
r.camera.radius = 100;
r.camera.mode = B.Camera.ORTHOGRAPHIC_CAMERA;
r.grid.rotation.x = Math.PI / 2;
r.grid.color = B.Color3.FromHexString("#334655");
r.grid.position.z = -0.05;
this.update();
} else {
r.camera.mode = B.Camera.PERSPECTIVE_CAMERA;
if (this.orbit) {
Object.assign(r.camera, {
alpha: this.orbit.alpha,
beta: this.orbit.beta,
radius: this.orbit.radius,
});
r.camera.setTarget(this.orbit.target);
this.orbit = null;
}
r.grid.rotation.x = 0;
r.grid.position.z = 0;
r.grid.color = B.Color3.FromHexString("#989b91");
if (!r.options.headless && !r.playing)
r.camera.attachControl(r.canvas, true);
this.brush = null;
}
r.setTool(r.tool);
}
update() {
if (!this.enabled || !this.runtime.camera) return;
const c = this.runtime.camera,
aspect =
this.runtime.engine.getRenderWidth() /
Math.max(1, this.runtime.engine.getRenderHeight());
c.orthoLeft = -this.width / 2;
c.orthoRight = this.width / 2;
c.orthoTop = this.width / aspect / 2;
c.orthoBottom = -this.width / aspect / 2;
}
dispose() {
for (const f of this.cleanup) f();
}
}