Files
2026-09-12 04:58:59 +03:00

1651 lines
57 KiB
TypeScript

import * as B from "@babylonjs/core";
import "./gltf-compat.ts";
import {
type Project,
type Entity,
type Vec3,
activeScene,
clone,
deepMerge,
uid,
remapEntityReferences,
} from "./schema.ts";
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;
mode?: (playing: boolean) => void;
event?: (name: string, data: any, entityId: string) => void;
}
export interface RuntimeOptions {
engine?: B.Engine;
headless?: boolean;
readAsset?: (uri: string) => Promise<Uint8Array>;
createWorker?: (source: string) => Worker;
}
let physicsModule: Promise<any> | null = null;
export class FormaRuntime {
graphics2d!: Graphics2D;
physics2d?: Physics2D;
view2d: View2D;
engine: B.Engine;
scene!: B.Scene;
camera!: B.ArcRotateCamera;
gameCamera!: B.FreeCamera;
gizmos!: B.GizmoManager;
highlight!: B.HighlightLayer;
shadow!: B.ShadowGenerator;
grid!: B.LinesMesh;
nodes = new Map<string, B.TransformNode>();
animations = new Map<string, B.AnimationGroup[]>();
containers = new Map<string, B.AssetContainer>();
signatures = new Map<string, string>();
importInfo = new Map<string, any>();
document!: Project;
state: Entity[] = [];
playing = false;
paused = false;
disposed = false;
selection: string | null = null;
tool = "move";
runId: string | null = null;
logs: any[] = [];
touch: {
x: number;
z: number;
attack: boolean;
jump?: boolean;
dash?: boolean;
sprint?: boolean;
} = { x: 0, z: 0, attack: false };
look = { yaw: 0, pitch: 0 };
lookSensitivity = 0.0022;
private actionQueue = new Set<string>();
private motors = new Map<string, CharacterMotor>();
automation: any = null;
private tasks: Promise<any> = Promise.resolve();
private cleanup: (() => void)[] = [];
private keys = new Set<string>();
private input = {
x: 0,
z: 0,
attack: false,
pointer: false,
aim: null as Vec3 | null,
};
private worker: Worker | null = null;
private workerUrl = "";
private ready = false;
private pending = false;
private watchdog: any;
private last = performance.now();
private lastStats = 0;
private scriptTime = 0;
private accumulator = 0;
private rapier: any;
private world: any;
private bodies = new Map<string, any>();
private colliders = new Map<string, any>();
private controllers = new Map<string, any>();
private moves = new Map<string, Vec3>();
private currentAnims = new Map<string, string>();
private blends = new Map<
string,
{ to: B.AnimationGroup; from: B.AnimationGroup[]; time: number }
>();
private flashes = new Map<string, number>();
private presentation: RuntimePresentation | null = null;
private binding(
action: "attack" | "jump" | "dash" | "sprint" | "reset",
defaults: string[],
) {
return this.document?.settings.controls?.[action] ?? defaults;
}
constructor(
public canvas: HTMLCanvasElement,
public callbacks: RuntimeCallbacks = {},
public options: RuntimeOptions = {},
) {
const runtime = this;
this.engine =
options.engine ||
new B.Engine(
canvas,
true,
{
preserveDrawingBuffer: true,
stencil: true,
powerPreference: "high-performance",
},
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),
}),
);
const resize = new ResizeObserver(() => this.engine.resize());
resize.observe(canvas);
this.cleanup.push(() => resize.disconnect());
const down = (e: KeyboardEvent) => {
if (
!this.playing ||
this.paused ||
/INPUT|TEXTAREA|SELECT/.test((e.target as HTMLElement)?.tagName)
)
return;
if (
[
"KeyW",
"KeyA",
"KeyS",
"KeyD",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Space",
].includes(e.code)
)
e.preventDefault();
if (!this.keys.has(e.code)) {
if (this.binding("jump", ["Space"]).includes(e.code))
this.requestAction("jump");
if (this.binding("dash", ["KeyE"]).includes(e.code))
this.requestAction("dash");
if (this.binding("reset", ["KeyR"]).includes(e.code))
this.requestAction("reset");
}
this.keys.add(e.code);
},
up = (e: KeyboardEvent) => this.keys.delete(e.code),
reset = () => {
this.keys.clear();
this.actionQueue.clear();
this.input.attack = false;
this.touch = { x: 0, z: 0, attack: false };
};
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", reset);
document.addEventListener("visibilitychange", reset);
this.cleanup.push(() => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", reset);
document.removeEventListener("visibilitychange", reset);
});
const pd = (e: PointerEvent) => {
if (this.playing && e.button === 0 && e.pointerType === "mouse")
this.input.attack = true;
},
pu = () => {
this.input.attack = false;
},
pm = (e: PointerEvent) => {
if (!this.playing || e.pointerType !== "mouse") return;
if (this.firstPerson()) {
if (!this.paused && document.pointerLockElement === canvas)
this.lookBy(
e.movementX * this.lookSensitivity,
-e.movementY * this.lookSensitivity,
);
return;
}
const r = canvas.getBoundingClientRect(),
ray = this.scene.createPickingRay(
((e.clientX - r.left) * this.engine.getRenderWidth()) / r.width,
((e.clientY - r.top) * this.engine.getRenderHeight()) / r.height,
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) {
this.input.aim = [
ray.origin.x + ray.direction.x * t,
0,
ray.origin.z + ray.direction.z * t,
];
this.input.pointer = true;
}
}
};
canvas.addEventListener("pointerdown", pd);
canvas.addEventListener("pointermove", pm);
window.addEventListener("pointerup", pu);
this.cleanup.push(() => {
canvas.removeEventListener("pointerdown", pd);
canvas.removeEventListener("pointermove", pm);
window.removeEventListener("pointerup", pu);
});
}
this.view2d = new View2D(this, canvas);
this.engine.runRenderLoop(() => this.frame());
}
private firstPerson() {
return this.state.some(
(n) => n.enabled && n.components.camera?.mode === "firstPerson",
);
}
lookBy(yaw: number, pitch: number) {
if (Number.isFinite(yaw)) this.look.yaw += yaw;
if (Number.isFinite(pitch))
this.look.pitch = Math.max(-1.3, Math.min(1.3, this.look.pitch + pitch));
}
requestAction(name: "jump" | "dash" | "reset") {
this.actionQueue.add(name);
}
releaseInput() {
if (!this.options.headless && document.pointerLockElement === this.canvas)
document.exitPointerLock();
this.keys.clear();
this.actionQueue.clear();
this.automation = null;
this.touch = { x: 0, z: 0, attack: false };
this.input.attack = false;
}
physicsSnapshot() {
const handles = new Map(
[...this.colliders].map(([id, col]) => [col.handle, id]),
);
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(() => {
if (this.disposed) throw Error("Runtime disposed");
return fn();
});
this.tasks = p.catch(() => {});
return p;
}
log(level: string, message: string, id?: string) {
this.logs.push({ level, message, entityId: id, time: Date.now() });
this.logs = this.logs.slice(-150);
this.callbacks.log?.(level, message, id);
}
load(p: Project) {
const doc = clone(p);
return this.enqueue(async () => {
if (!this.playing) await this.sync(doc);
});
}
private createScene(p: Project) {
const view = this.camera
? {
alpha: this.camera.alpha,
beta: this.camera.beta,
radius: this.camera.radius,
target: this.camera.target.clone(),
}
: null;
this.graphics2d?.dispose();
this.scene?.dispose();
this.nodes.clear();
this.animations.clear();
this.containers.clear();
this.signatures.clear();
this.currentAnims.clear();
this.blends.clear();
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",
view?.alpha ?? -Math.PI / 2 - 0.38,
view?.beta ?? 0.84,
view?.radius ?? 31,
view?.target ?? B.Vector3.Zero(),
scene,
);
this.camera.minZ = 0.05;
this.camera.lowerRadiusLimit = 0.5;
this.camera.upperRadiusLimit = 180;
this.camera.wheelDeltaPercentage = 0.018;
this.camera.panningSensibility = 80;
if (!this.options.headless) this.camera.attachControl(this.canvas, true);
this.camera.inputs.removeByType("ArcRotateCameraKeyboardMoveInput");
this.gameCamera = new B.FreeCamera(
"Game",
new B.Vector3(0, 13, -10),
scene,
);
this.gameCamera.minZ = 0.1;
this.gameCamera.maxZ = 500;
this.gameCamera.fov = 0.72;
const environment = (p.settings as any).environment;
if (environment?.fog) {
scene.fogMode = B.Scene.FOGMODE_LINEAR;
scene.fogStart = environment.fog.start ?? 60;
scene.fogEnd = environment.fog.end ?? 230;
scene.fogColor = B.Color3.FromHexString(
environment.fog.color || p.settings.background,
);
}
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.groundColor = B.Color3.FromHexString("#8a8475");
const sun = new B.DirectionalLight(
"Sun",
new B.Vector3(0.5, -1, 0.45),
scene,
);
sun.position = new B.Vector3(-12, 22, -14);
sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
sun.diffuse = B.Color3.FromHexString("#fff4df");
this.shadow = new B.ShadowGenerator(1024, sun);
this.shadow.usePercentageCloserFiltering = true;
this.shadow.filteringQuality = B.ShadowGenerator.QUALITY_LOW;
this.shadow.bias = 0.0005;
this.shadow.normalBias = 0.04;
scene.imageProcessingConfiguration.toneMappingEnabled =
p.settings.rendering?.toneMapping !== false;
scene.imageProcessingConfiguration.toneMappingType =
B.ImageProcessingConfiguration.TONEMAPPING_ACES;
scene.imageProcessingConfiguration.exposure =
p.settings.rendering?.exposure ?? 1.12;
scene.imageProcessingConfiguration.contrast =
p.settings.rendering?.contrast ?? 1.05;
const lines: B.Vector3[][] = [];
for (let i = -35; i <= 35; i++) {
lines.push([new B.Vector3(i, -0.55, -35), new B.Vector3(i, -0.55, 35)]);
lines.push([new B.Vector3(-35, -0.55, i), new B.Vector3(35, -0.55, i)]);
}
this.grid = B.MeshBuilder.CreateLineSystem("Grid", { lines }, scene);
this.grid.color = B.Color3.FromHexString("#989b91");
this.grid.alpha = 0.26;
this.grid.isPickable = false;
this.highlight = new B.HighlightLayer("Selection", scene);
this.highlight.innerGlow = false;
this.gizmos = new B.GizmoManager(scene);
this.gizmos.usePointerToAttachGizmos = false;
this.setTool(this.tool);
scene.onPointerObservable.add((info) => {
if (
!this.playing &&
!this.view2d.brush &&
info.type === B.PointerEventTypes.POINTERTAP &&
info.event.button === 0
)
this.callbacks.select?.(
info.pickInfo?.pickedMesh?.metadata?.entityId || null,
);
});
}
setTool(tool: string) {
this.tool = tool;
if (!this.gizmos) return;
this.gizmos.positionGizmoEnabled = tool === "move";
this.gizmos.rotationGizmoEnabled = tool === "rotate";
this.gizmos.scaleGizmoEnabled = tool === "scale";
for (const g of [
this.gizmos.gizmos.positionGizmo,
this.gizmos.gizmos.rotationGizmo,
this.gizmos.gizmos.scaleGizmo,
])
if (g && !(g as any)._wired) {
(g as any)._wired = true;
g.onDragEndObservable.add(() => {
const node = this.nodes.get(this.selection || "");
if (node)
this.callbacks.transform?.(this.selection!, {
position: node.position.asArray() as Vec3,
rotation: (
node.rotationQuaternion?.toEulerAngles() || node.rotation
).asArray() as Vec3,
scale: node.scaling.asArray() as Vec3,
});
});
}
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)
g.rotationGizmo.snapDistance = enabled ? Math.PI / 12 : 0;
if (g.scaleGizmo) g.scaleGizmo.snapDistance = enabled ? 0.1 : 0;
}
select(id: string | null) {
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 &&
!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));
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;
}
private applyTransform(n: Entity) {
const root = this.nodes.get(n.id);
if (!root) return;
root.position.copyFromFloats(...n.transform.position);
root.rotationQuaternion = null;
root.rotation.copyFromFloats(...n.transform.rotation);
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)) {
const mat = mesh.material;
if (mat instanceof B.PBRMaterial) {
mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697");
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 0);
mat.roughness = c.roughness ?? 0.8;
mat.metallic = c.metallic ?? 0;
mat.alpha = c.alpha ?? 1;
mat.unlit = c.unlit === true;
mat.transparencyMode =
mat.alpha < 1
? B.PBRMaterial.PBRMATERIAL_ALPHABLEND
: B.PBRMaterial.PBRMATERIAL_OPAQUE;
mat.backFaceCulling = c.doubleSided !== true;
}
}
}
}
private removeNode(id: string) {
const node = this.nodes.get(id);
if (node) {
for (const n of this.nodes.values())
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);
this.signatures.delete(id);
this.animations.get(id)?.forEach((a) => a.dispose());
this.animations.delete(id);
this.currentAnims.delete(id);
}
private async sync(p: Project) {
if (!this.scene || this.document?.activeSceneId !== p.activeSceneId)
this.createScene(p);
this.document = clone(p);
this.state = clone(activeScene(p).entities);
this.scene.clearColor = B.Color4.FromHexString(
p.settings.background + "ff",
);
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;
const sun = this.scene.getLightByName("Sun");
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);
for (const n of this.state) {
if (this.disposed) return;
const signature = JSON.stringify([
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)) {
this.removeNode(n.id);
await this.createEntity(n, p);
this.signatures.set(n.id, signature);
}
this.applyTransform(n);
}
if (this.disposed) return;
for (const n of this.state) {
const node = this.nodes.get(n.id)!;
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) {
const scene = this.scene;
if (scene.isDisposed || this.disposed) return;
const root = new B.TransformNode(n.id, scene);
root.metadata = { entityId: n.id };
this.nodes.set(n.id, root);
const m = n.components.mesh;
let meshes: B.AbstractMesh[] = [];
try {
if (m) {
let mesh: B.Mesh | undefined;
const size = m.size || [1, 1, 1];
if (m.type === "model") {
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);
else {
const response = await fetch(a.uri);
if (!response.ok) throw Error("Ошибка загрузки " + a.name);
bytes = new Uint8Array(await response.arrayBuffer());
}
if (scene.isDisposed || this.disposed) return;
const metadata = inspectModel(bytes, a.name);
// A container per entity keeps cameras, lights, morph targets, textures
// 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",
name: a.name,
pluginOptions: { gltf: { animationStartMode: 0 } },
});
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);
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());
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,
),
});
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 =
m.type === "custom"
? m.geometry
: p.assets.find((a) => a.id === m.assetId)?.geometry;
if (!g) throw Error("Нет геометрии");
mesh = new B.Mesh(n.name, scene);
const vd = new B.VertexData();
vd.positions = g.positions;
vd.indices = g.indices;
if (g.uvs) vd.uvs = g.uvs;
const normals: number[] = [];
B.VertexData.ComputeNormals(g.positions, g.indices, normals);
vd.normals = g.normals || normals;
vd.applyToMesh(mesh);
mesh.convertToFlatShadedMesh();
} else if (m.type === "sphere")
mesh = B.MeshBuilder.CreateSphere(
n.name,
{ diameter: 1, segments: 16 },
scene,
);
else if (m.type === "icosphere")
mesh = B.MeshBuilder.CreateIcoSphere(
n.name,
{ radius: 0.5, subdivisions: 1, flat: true },
scene,
);
else if (m.type === "cylinder")
mesh = B.MeshBuilder.CreateCylinder(
n.name,
{ diameter: 1, height: 1, tessellation: 12 },
scene,
);
else if (m.type === "torus")
mesh = B.MeshBuilder.CreateTorus(
n.name,
{ diameter: 1, thickness: 0.15, tessellation: 24 },
scene,
);
else mesh = B.MeshBuilder.CreateBox(n.name, { size: 1 }, scene);
if (mesh) {
mesh.parent = root;
if (m.type !== "custom" && m.type !== "geometry")
mesh.scaling.copyFromFloats(size[0], size[1], size[2]);
meshes = [mesh];
}
if (
n.components.material &&
(m.type !== "model" || n.components.material.override)
) {
const c = n.components.material;
const mat = new B.PBRMaterial(n.id + "_material", scene);
mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697");
mat.roughness = c.roughness ?? 0.8;
mat.metallic = c.metallic ?? 0;
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 0);
mat.alpha = c.alpha ?? 1;
mat.unlit = c.unlit === true;
mat.transparencyMode =
mat.alpha < 1
? B.PBRMaterial.PBRMATERIAL_ALPHABLEND
: B.PBRMaterial.PBRMATERIAL_OPAQUE;
mat.backFaceCulling = c.doubleSided !== true;
for (const mesh of meshes) mesh.material = mat;
}
}
} catch (e) {
if (scene.isDisposed || this.disposed) return;
this.log("error", "Импорт " + n.name + ": " + String(e), n.id);
const mesh = B.MeshBuilder.CreateBox("Missing", { size: 1 }, scene);
mesh.parent = root;
const mat = new B.StandardMaterial("Error", scene);
mat.diffuseColor = B.Color3.FromHexString("#b86755");
mat.wireframe = true;
mesh.material = mat;
meshes = [mesh];
}
if (n.components.light) {
const c = n.components.light,
l = new B.PointLight(n.id + "_light", B.Vector3.Zero(), scene);
l.parent = root;
l.diffuse = B.Color3.FromHexString(c.color || "#fff1da");
l.intensity = c.intensity ?? 2;
}
if (n.components.sign && !this.options.headless) {
const c = n.components.sign;
const texture = new B.DynamicTexture(
n.id + "_sign",
{ width: 1024, height: 256 },
scene,
false,
);
texture.hasAlpha = true;
texture.drawText(
String(c.text || "").slice(0, 40),
null,
173,
"bold 115px sans-serif",
c.color || "#ffffff",
"transparent",
true,
);
const material = new B.StandardMaterial(n.id + "_sign_material", scene);
material.diffuseTexture = texture;
material.emissiveTexture = texture;
material.opacityTexture = texture;
material.disableLighting = true;
material.backFaceCulling = false;
const sign = B.MeshBuilder.CreatePlane(
n.id + "_sign",
{
width: c.width || 5,
height: (c.width || 5) / 4,
sideOrientation: B.Mesh.DOUBLESIDE,
},
scene,
);
sign.material = material;
sign.parent = root;
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;
mesh.receiveShadows = n.components.mesh?.receiveShadows !== false;
if (n.components.mesh?.castShadows !== false)
this.shadow.addShadowCaster(mesh);
}
this.applyTransform(n);
}
private async setupPhysics() {
physicsModule ??= import("@dimforge/rapier3d-compat").then(async (m) => {
await m.init();
return m;
});
this.rapier = await physicsModule;
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,
root = this.nodes.get(n.id);
if (!c || c.enabled === false || !root?.isEnabled()) return;
root.computeWorldMatrix(true);
const p = root.getAbsolutePosition(),
s = root.absoluteScaling,
q = root.absoluteRotationQuaternion,
rb = n.components.rigidbody || { type: "fixed" },
R = this.rapier;
const desc =
rb.type === "dynamic"
? R.RigidBodyDesc.dynamic()
: rb.type === "kinematic"
? R.RigidBodyDesc.kinematicPositionBased()
: R.RigidBodyDesc.fixed();
desc
.setTranslation(p.x, p.y, p.z)
.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w });
const body = this.world.createRigidBody(desc),
size = c.size || n.components.mesh?.size || [1, 1, 1];
let col;
if (c.shape === "ball")
col = R.ColliderDesc.ball((c.radius || 0.4) * Math.abs(s.x));
else if (c.shape === "capsule") {
const r = (c.radius || 0.3) * Math.abs(s.x);
col = R.ColliderDesc.capsule(
Math.max(0.01, ((c.height || 1.8) * Math.abs(s.y)) / 2 - r),
r,
);
} else
col = R.ColliderDesc.cuboid(
Math.max(0.01, (size[0] * Math.abs(s.x)) / 2),
Math.max(0.01, (size[1] * Math.abs(s.y)) / 2),
Math.max(0.01, (size[2] * Math.abs(s.z)) / 2),
);
const offset = c.offset || [0, 0, 0];
col
.setTranslation(offset[0] * s.x, offset[1] * s.y, offset[2] * s.z)
.setMass(rb.mass || 1)
.setRestitution(rb.restitution ?? 0.1);
if (c.sensor) col.setSensor(true);
const collider = this.world.createCollider(col, body);
this.bodies.set(n.id, body);
this.colliders.set(n.id, collider);
if (rb.type === "kinematic") {
const controller = this.world.createCharacterController(0.025);
controller.enableAutostep(0.25, 0.2, true);
controller.enableSnapToGround(0.2);
this.controllers.set(n.id, controller);
if (n.components.character) {
controller.enableAutostep(
n.components.character.autostep ?? 0.25,
0.2,
true,
);
this.motors.set(
n.id,
new CharacterMotor(
body,
collider,
controller,
n.components.character,
R,
),
);
}
}
}
play(project: Project) {
const p = clone(project);
return this.enqueue(async () => {
if (this.playing) return;
await this.sync(p);
if (this.disposed) return;
await this.setupPhysics();
if (this.disposed) {
this.world?.free();
return;
}
this.graphics2d.start();
this.playing = true;
this.paused = false;
this.runId = uid("run");
this.camera.detachControl();
this.scene.activeCamera = this.gameCamera;
this.updateFixedCamera();
this.updateProjection();
this.grid.setEnabled(false);
this.select(null);
this.keys.clear();
this.input.attack = false;
this.accumulator = 0;
this.scriptTime = 0;
this.actionQueue.clear();
const fp = this.state.find(
(n) => n.components.camera?.mode === "firstPerson",
)?.components.camera;
this.look = { yaw: fp?.yaw ?? 0, pitch: fp?.pitch ?? 0 };
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 (!this.options.headless && p.settings.presentation) {
this.presentation?.dispose();
this.presentation = new RuntimePresentation(
this.canvas,
p.settings.presentation,
{
pause: (value) => {
this.paused = value;
this.releaseInput();
},
paused: () => this.paused,
restart: () => {
this.releaseInput();
this.requestAction("reset");
this.paused = false;
},
move: (x, z) => {
this.touch.x = x;
this.touch.z = z;
},
attack: (value) => {
this.touch.attack = value;
},
dash: () => this.requestAction("dash"),
},
);
}
this.startWorker();
this.callbacks.mode?.(true);
this.log("info", "Запуск " + this.runId);
});
}
stop(project?: Project) {
return this.enqueue(async () => {
this.stopWorker();
this.presentation?.dispose();
this.presentation = null;
this.playing = false;
this.paused = false;
this.world?.free();
this.physics2d?.dispose();
this.physics2d = undefined;
this.world = null;
this.bodies.clear();
this.colliders.clear();
this.controllers.clear();
this.motors.clear();
this.moves.clear();
this.automation = null;
if (!this.options.headless && document.pointerLockElement === this.canvas)
document.exitPointerLock();
this.callbacks.mode?.(false);
const p = project || this.document;
this.createScene(p);
await this.sync(p);
this.log("info", "Сцена восстановлена после игры");
});
}
private startWorker() {
this.stopWorker();
this.workerUrl = URL.createObjectURL(
new Blob([workerSource], { type: "text/javascript" }),
);
this.worker = this.options.createWorker
? this.options.createWorker(workerSource)
: new Worker(this.workerUrl);
this.pending = true;
this.worker.onmessage = (e) => {
clearTimeout(this.watchdog);
this.pending = false;
if (e.data.type === "ready") this.ready = true;
this.applyCommands(e.data.commands || []);
};
this.worker.onerror = (e) => {
this.log("error", "Worker: " + e.message);
this.stopWorker();
};
this.worker.postMessage({
type: "init",
entities: this.state,
scripts: this.document.scripts,
});
this.armWatchdog();
}
private armWatchdog() {
clearTimeout(this.watchdog);
this.watchdog = setTimeout(() => {
this.log(
"error",
"Скрипт не ответил за 1500 мс. Worker остановлен, редактор доступен.",
);
this.stopWorker();
}, 1500);
}
private stopWorker() {
clearTimeout(this.watchdog);
this.worker?.terminate();
this.worker = null;
this.ready = false;
this.pending = false;
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,
target = groups.find(
(g) => g.name.toLowerCase() === mapped.toLowerCase(),
);
if (!target || this.currentAnims.get(id) === target.name) return;
const old = groups.filter((g) => g !== target && g.isPlaying);
target.start(loop, n?.components.animator?.speed || 1);
target.setWeightForAllAnimatables(old.length ? 0 : 1);
if (old.length) this.blends.set(id, { to: target, from: old, time: 0 });
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));
}
private applyCommands(commands: any[]) {
for (const c of commands.slice(0, 5000)) {
try {
const n = this.state.find((n) => n.id === c.id);
if (c.type === "patch" && n) {
const next = deepMerge(clone(n), c.patch);
if (
!next.transform.position.every(Number.isFinite) ||
!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) {
const p = root.getAbsolutePosition();
body.setTranslation({ x: p.x, y: p.y, z: p.z }, true);
}
if (body && c.patch.transform?.rotation) {
const q = root.absoluteRotationQuaternion;
body.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true);
}
if (
c.patch.enabled !== undefined ||
c.patch.components?.collider?.enabled !== undefined
)
for (const [id, col] of this.colliders) {
const ent = this.state.find((n) => n.id === id)!;
col.setEnabled(
this.nodes.get(id)!.isEnabled() &&
ent.components.collider.enabled !== false,
);
}
} else if (c.type === "velocity" && n) {
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);
this.moves.delete(n.id);
n.transform.position = [...c.position] as Vec3;
this.applyTransform(n);
if (Number.isFinite(c.yaw)) this.look = { yaw: c.yaw, pitch: 0 };
} else if (c.type === "event") {
this.presentation?.event(String(c.name), c.data);
this.callbacks.event?.(String(c.name), c.data, c.id);
} else if (c.type === "move" && n) {
if (
!Array.isArray(c.delta) ||
c.delta.length !== 3 ||
!c.delta.every(Number.isFinite)
)
throw Error("Неверное перемещение");
const prev = this.moves.get(n.id) || [0, 0, 0];
this.moves.set(
n.id,
prev.map((v, i) =>
Math.max(-10, Math.min(10, v + c.delta[i])),
) as Vec3,
);
} else if (c.type === "animate") this.animate(c.id, c.name, c.loop);
else if (c.type === "log" || c.type === "error")
this.log(
c.type === "error" ? "error" : "info",
(c.scriptId
? "[" +
(this.document.scripts.find((s) => s.id === c.scriptId)?.name ||
c.scriptId) +
"] "
: "") + c.message,
c.id,
);
else if (c.type === "effect" && n) {
if (c.name === "hit") this.flashes.set(n.id, 0.16);
if (c.name === "swing") this.swing(n);
} else if (c.type === "spawn")
void this.enqueue(() => this.spawn(c.template, c.position)).catch(
(e) => this.log("error", String(e)),
);
else if (
c.type === "scene" &&
this.document.scenes.some((s) => s.id === c.sceneId)
) {
const p = clone(this.document);
p.activeSceneId = c.sceneId;
void this.stop(p).then(() => this.play(p));
}
} catch (e) {
this.log("error", String(e), c.id);
}
}
}
private async spawn(assetId: string, position: Vec3) {
if (!this.playing) return;
const asset = this.document.assets.find(
(a) => a.id === assetId && a.kind === "prefab",
);
if (!asset?.entities) throw Error("Префаб не найден");
if (this.state.length + asset.entities.length > 3000)
throw Error("Лимит объектов runtime");
const nodes = clone(asset.entities),
ids = new Map(nodes.map((n) => [n.id, uid()]));
for (const n of nodes) {
remapEntityReferences(n, ids, this.document.scripts);
n.id = ids.get(n.id)!;
n.parentId = n.parentId ? ids.get(n.parentId)! : null;
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();
if (!p) return;
const points: B.Vector3[] = [];
for (let i = 0; i <= 16; i++) {
const a = n.transform.rotation[1] - 1 + i / 8;
points.push(
new B.Vector3(
p.x + Math.sin(a) * 1.7,
p.y + 0.8,
p.z + Math.cos(a) * 1.7,
),
);
}
const m = B.MeshBuilder.CreateLines("Attack", { points }, this.scene);
m.color = B.Color3.FromHexString("#e5a250");
m.isPickable = false;
setTimeout(() => {
if (!m.isDisposed()) m.dispose();
}, 180);
}
private physics(dt: number) {
if (!this.world) return;
this.accumulator = Math.min(0.1, this.accumulator + dt);
let steps = Math.floor(this.accumulator * 60);
if (!steps) return;
const count = steps;
while (steps-- > 0) {
for (const [id, body] of this.bodies) {
const move = this.moves.get(id) || [0, 0, 0],
controller = this.controllers.get(id),
col = this.colliders.get(id);
if (controller && col.isEnabled()) {
const motor = this.motors.get(id);
if (motor) {
motor.step(
1 / 60,
move.map((v) => v / count),
);
continue;
}
controller.computeColliderMovement(col, {
x: move[0] / count,
y: move[1] / count - 0.06,
z: move[2] / count,
});
const m = controller.computedMovement(),
p = body.translation();
body.setNextKinematicTranslation({
x: p.x + m.x,
y: p.y + m.y,
z: p.z + m.z,
});
} else if (move.some((v) => v)) {
const p = body.translation();
body.setTranslation(
{
x: p.x + move[0] / count,
y: p.y + move[1] / count,
z: p.z + move[2] / count,
},
true,
);
}
}
this.physics2d?.step(1 / 60, this.moves, count);
this.world.step();
this.accumulator -= 1 / 60;
}
for (const [id, body] of this.bodies) {
const n = this.state.find((n) => n.id === id)!,
node = this.nodes.get(id)!;
const p = body.translation();
let pos = new B.Vector3(p.x, p.y, p.z);
if (node.parent)
pos = B.Vector3.TransformCoordinates(
pos,
B.Matrix.Invert(node.parent.getWorldMatrix()),
);
node.position.copyFrom(pos);
n.transform.position = pos.asArray() as Vec3;
if (n.components.rigidbody?.type === "dynamic") {
const r = body.rotation();
let q = new B.Quaternion(r.x, r.y, r.z, r.w);
if (node.parent instanceof B.TransformNode)
q = node.parent.absoluteRotationQuaternion.conjugate().multiply(q);
node.rotationQuaternion = q;
n.transform.rotation = q.toEulerAngles().asArray() as Vec3;
}
}
for (const [id, d] of this.moves)
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(
(v, i) => v + d[i],
) as Vec3;
this.applyTransform(n);
}
}
this.moves.clear();
}
setInput(value: any) {
if (Number.isFinite(value.yaw)) this.look.yaw = value.yaw;
if (Number.isFinite(value.pitch))
this.look.pitch = Math.max(-1.3, Math.min(1.3, value.pitch));
if (value.jump) this.requestAction("jump");
if (value.dash) this.requestAction("dash");
if (value.reset) this.requestAction("reset");
this.automation = {
...value,
until:
performance.now() +
Math.max(0, Math.min(10000, value.durationMs ?? 500)),
};
}
snapshot() {
return {
runId: this.runId,
revision: this.document?.revision,
playing: this.playing,
paused: this.paused,
entities: clone(this.state),
physics: this.physicsSnapshot(),
animations: Object.fromEntries(
[...this.animations].map(([id, groups]) => [
id,
groups.map((g) => ({
name: g.name,
playing: g.isPlaying,
frame: g.animatables[0]?.masterFrame ?? null,
})),
]),
),
sprites: this.graphics2d?.snapshot(),
imports: Object.fromEntries(this.importInfo),
logs: this.logs.slice(-30),
fps: Math.round(this.engine.getFps()),
};
}
screenshot() {
this.scene.render();
return this.canvas.toDataURL("image/png");
}
private frame() {
if (this.disposed || !this.scene) return;
const now = performance.now(),
dt = Math.min(0.05, (now - this.last) / 1000);
this.last = now;
if (this.playing && !this.paused) {
const input = {
...this.input,
x:
this.touch.x +
(this.keys.has("KeyD") || this.keys.has("ArrowRight") ? 1 : 0) -
(this.keys.has("KeyA") || this.keys.has("ArrowLeft") ? 1 : 0),
z:
this.touch.z +
(this.keys.has("KeyW") || this.keys.has("ArrowUp") ? 1 : 0) -
(this.keys.has("KeyS") || this.keys.has("ArrowDown") ? 1 : 0),
attack:
this.input.attack ||
this.touch.attack ||
this.binding("attack", ["Space"]).some((k) => this.keys.has(k)),
jump:
this.touch.jump ||
this.binding("jump", ["Space"]).some((k) => this.keys.has(k)),
dash:
this.touch.dash ||
this.binding("dash", ["KeyE"]).some((k) => this.keys.has(k)),
sprint:
this.touch.sprint ||
this.binding("sprint", ["ShiftLeft", "ShiftRight"]).some((k) =>
this.keys.has(k),
),
jumpPressed: this.actionQueue.has("jump"),
dashPressed: this.actionQueue.has("dash"),
resetPressed: this.actionQueue.has("reset"),
yaw: this.look.yaw,
pitch: this.look.pitch,
};
if (Math.hypot(this.touch.x, this.touch.z) > 0.05) input.pointer = false;
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) {
this.pending = true;
this.worker!.postMessage({
type: "tick",
entities: this.state,
input,
physics: this.physicsSnapshot(),
events2d: this.physics2d?.drainEvents() || [],
dt: Math.min(0.1, this.scriptTime),
});
this.scriptTime = 0;
this.actionQueue.clear();
this.armWatchdog();
}
const c = this.state.find((n) => n.components.camera && n.enabled)
?.components.camera,
target = c?.targetId
? this.nodes.get(c.targetId)?.getAbsolutePosition()
: B.Vector3.Zero();
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);
const data = player?.components.data || {};
const speed = data.speed || 0;
const motion = c.motion !== false;
const bob =
motion && data.grounded
? Math.sin(now * 0.012) * Math.min(0.028, speed * 0.002)
: 0;
this.gameCamera.position.copyFrom(
target.add(B.Vector3.FromArray(c.offset || [0, 0.65, 0])),
);
this.gameCamera.position.y += bob;
const dir = new B.Vector3(
Math.sin(this.look.yaw) * Math.cos(this.look.pitch),
Math.sin(this.look.pitch),
-Math.cos(this.look.yaw) * Math.cos(this.look.pitch),
);
this.gameCamera.setTarget(this.gameCamera.position.add(dir));
this.gameCamera.rotation.z = motion ? (data.wallSide || 0) * 0.045 : 0;
const fov =
(c.fov || 1.12) + (motion ? Math.min(0.13, speed * 0.009) : 0);
this.gameCamera.fov +=
(fov - this.gameCamera.fov) * Math.min(1, dt * 8);
} else if (target) {
this.gameCamera.position = B.Vector3.Lerp(
this.gameCamera.position,
target.add(B.Vector3.FromArray(c?.offset || [0, 13, -10])),
Math.min(1, dt * 8),
);
this.gameCamera.setTarget(target.add(new B.Vector3(0, 0.3, 0)));
this.gameCamera.fov = c?.fov || 0.72;
}
}
if (this.playing) this.updateProjection();
for (const [id, b] of this.blends) {
b.time += dt;
const t = Math.min(1, b.time / 0.16);
b.to.setWeightForAllAnimatables(t);
b.from.forEach((g) => g.setWeightForAllAnimatables(1 - t));
if (t >= 1) {
b.from.forEach((g) => g.stop());
this.blends.delete(id);
}
}
for (const [id, t] of this.flashes) {
for (const mesh of this.nodes.get(id)?.getChildMeshes() || []) {
mesh.renderOverlay = t > 0;
mesh.overlayColor = B.Color3.FromHexString("#eea273");
mesh.overlayAlpha = 0.65;
}
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();
} catch (e) {
this.log("error", "Render: " + String(e));
}
if (now - this.lastStats > 350) {
this.lastStats = now;
this.callbacks.stats?.({
fps: Math.round(this.engine.getFps()),
objects: this.state.length,
triangles: this.scene.meshes.reduce(
(s, m) => s + m.getTotalIndices() / 3,
0,
),
firstPerson: this.firstPerson(),
twoD: this.state.some(
(n) =>
n.enabled &&
n.components.character2d?.controls !== false &&
n.components.character2d,
),
playing: this.playing,
});
}
}
dispose() {
this.disposed = true;
this.stopWorker();
this.presentation?.dispose();
this.presentation = null;
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();
}
private updateFixedCamera() {
const entity = this.state.find((n) => n.enabled && n.components.camera);
const c = entity?.components.camera;
if (c?.mode !== "fixed") return;
this.gameCamera.position.copyFrom(
this.nodes.get(entity!.id)?.getAbsolutePosition() ||
B.Vector3.FromArray(entity!.transform.position),
);
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 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;
}
}
this.scene.activeCamera = this.gameCamera;
const cam = this.gameCamera;
cam.mode =
c?.projection === "orthographic"
? B.Camera.ORTHOGRAPHIC_CAMERA
: B.Camera.PERSPECTIVE_CAMERA;
const screen =
this.engine.getRenderWidth() / Math.max(1, this.engine.getRenderHeight());
const aspect =
Number.isFinite(c?.aspect) && c?.aspect > 0 ? c!.aspect : screen;
cam.viewport = c?.aspect
? screen > aspect
? new B.Viewport((1 - aspect / screen) / 2, 0, aspect / screen, 1)
: new B.Viewport(0, (1 - screen / aspect) / 2, 1, screen / aspect)
: new B.Viewport(0, 0, 1, 1);
if (cam.mode === B.Camera.ORTHOGRAPHIC_CAMERA) {
const w = Math.max(0.1, c?.orthoWidth || 20);
cam.orthoLeft = -w / 2;
cam.orthoRight = w / 2;
cam.orthoTop = w / aspect / 2;
cam.orthoBottom = -w / aspect / 2;
}
}
}