1277 lines
44 KiB
TypeScript
1277 lines
44 KiB
TypeScript
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<Uint8Array>;
|
|
createWorker?: (source: string) => Worker;
|
|
}
|
|
let physicsModule: Promise<any> | 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<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>();
|
|
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<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.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();
|
|
}
|
|
}
|