import * as B from "@babylonjs/core"; import "@babylonjs/loaders/glTF"; 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"; export interface RuntimeCallbacks { select?: (id: string | null) => 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; createWorker?: (source: string) => Worker; } let physicsModule: Promise | null = null; export class FormaRuntime { 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(); animations = new Map(); containers = new Map(); signatures = new Map(); importInfo = new Map(); 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(); private motors = new Map(); automation: any = null; private tasks: Promise = Promise.resolve(); private cleanup: (() => void)[] = []; private keys = new Set(); 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(); private colliders = new Map(); private controllers = new Map(); private moves = new Map(); private currentAnims = new Map(); private blends = new Map< string, { to: B.AnimationGroup; from: B.AnimationGroup[]; time: number } >(); private flashes = new Map(); constructor( public canvas: HTMLCanvasElement, public callbacks: RuntimeCallbacks = {}, public options: RuntimeOptions = {}, ) { this.engine = options.engine || new B.Engine( canvas, true, { preserveDrawingBuffer: true, stencil: true, powerPreference: "high-performance", }, false, ); if (!options.headless) { 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 (e.code === "Space") this.requestAction("jump"); if (e.code === "KeyE") this.requestAction("dash"); if (e.code === "KeyR") 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 (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.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() { 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 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, })), }, ]), ); } private enqueue(fn: () => Promise) { 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.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; 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.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 = 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 = true; scene.imageProcessingConfiguration.toneMappingType = B.ImageProcessingConfiguration.TONEMAPPING_ACES; scene.imageProcessingConfiguration.exposure = 1.12; scene.imageProcessingConfiguration.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 && 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, }); }); } this.select(this.selection); } setSnap(enabled: boolean) { 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(); const node = this.nodes.get(id || ""); if (node && !this.playing) for (const mesh of node.getChildMeshes()) if (mesh instanceof B.Mesh) this.highlight.addMesh(mesh, B.Color3.FromHexString("#d59565")); this.gizmos.attachToNode(this.playing ? null : node || null); } 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); } else { this.camera.setTarget(n?.getAbsolutePosition() || B.Vector3.Zero()); this.camera.radius = n ? 6 : 31; } } topView() { 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); } 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.nodes.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.ambient; 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, 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.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("У модели нет файла"); const key = a.id + "|" + a.uri; let container = this.containers.get(key); if (!container) { let bytes: Uint8Array; if (this.options.readAsset) bytes = await this.options.readAsset(a.uri); else { const r = await fetch(a.uri); if (!r.ok) throw Error("Ошибка загрузки " + a.name); bytes = new Uint8Array(await r.arrayBuffer()); } if (scene.isDisposed || this.disposed) return; inspectModel(bytes, a.name); container = await B.LoadAssetContainerAsync(bytes, scene, { pluginExtension: a.name.toLowerCase().endsWith(".gltf") ? ".gltf" : ".glb", name: a.name, }); if (scene.isDisposed || this.disposed) { container.dispose(); return; } this.containers.set(key, container); const info = { clips: container.animationGroups.map((g) => g.name), skeletons: container.skeletons.length, triangles: container.meshes.reduce( (s, m) => s + m.getTotalIndices() / 3, 0, ), }; this.importInfo.set(a.id, info); this.log( "info", "Импорт " + a.name + ": " + info.triangles + " треугольников, " + info.clips.length + " анимаций", ); } const instance = container.instantiateModelsToScene( (name) => n.id + "_" + name, true, { doNotInstantiate: true }, ); for (const node of instance.rootNodes) node.parent = root; this.animations.set(n.id, instance.animationGroups); instance.animationGroups.forEach((g, i) => { g.name = container!.animationGroups[i].name; g.stop(); }); 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); 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; } for (const mesh of meshes) { 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); } 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.playing = true; this.paused = false; this.runId = uid("run"); this.camera.detachControl(); this.scene.activeCamera = this.gameCamera; 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(); this.startWorker(); this.callbacks.mode?.(true); this.log("info", "Запуск " + this.runId); }); } stop(project?: Project) { return this.enqueue(async () => { this.stopWorker(); this.playing = false; this.paused = false; this.world?.free(); 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) { 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) { 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("Скрипт вернул некорректную трансформацию"); Object.assign(n, next); this.applyTransform(n); 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) { this.motors.get(n.id)?.set(c.value); } else if (c.type === "teleport" && n) { 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.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); } 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); } } 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.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)) { 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, })), ]), ), 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.keys.has("Space"), jump: this.touch.jump || this.keys.has("Space"), dash: this.touch.dash || this.keys.has("KeyE"), sprint: this.touch.sprint || this.keys.has("ShiftLeft") || this.keys.has("ShiftRight"), 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; 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(), 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 (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; } } 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); } try { 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(), playing: this.playing, }); } } dispose() { this.disposed = true; this.stopWorker(); this.cleanup.forEach((fn) => fn()); this.world?.free(); this.world = null; this.scene?.dispose(); this.engine.dispose(); } }