Fix editor regressions and add Blender scene and animated material import

This commit is contained in:
emil28092005
2026-09-12 00:53:20 +03:00
parent 1a0bf725fa
commit 6f497d1977
34 changed files with 1241 additions and 213 deletions
+5 -2
View File
@@ -14,6 +14,9 @@ export function decodeData(uri: string) {
throw Error("Ожидался data URI base64");
return Uint8Array.from(atob(uri.slice(i + 1)), (c) => c.charCodeAt(0));
}
export function engineResource(path: string, base = typeof document === "undefined" ? undefined : document.baseURI) {
return base ? new URL(path.replace(/^\//, ""), base).href : path;
}
export async function readBytes(uri: string) {
if (uri.startsWith("data:")) return decodeData(uri);
const r = await fetch(uri);
@@ -57,8 +60,8 @@ export async function gameArchive(
) {
const { p, files } = await pack(project, read);
files["project.forma.json"] = strToU8(JSON.stringify(p));
files["player.js"] = await read("/engine/player.js");
files["player.css"] = await read("/engine/player.css");
files["player.js"] = await read(engineResource("/engine/player.js"));
files["player.css"] = await read(engineResource("/engine/player.css"));
files["index.html"] = strToU8(
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>' +
p.name.replace(/[<>&"]/g, "") +
+3 -3
View File
@@ -1,5 +1,5 @@
import { zipSync, unzipSync, strToU8 } from "fflate";
import { gameArchive, readBytes } from "./archive.ts";
import { gameArchive, readBytes, engineResource } from "./archive.ts";
import type { Project } from "./schema.ts";
import { normalizeOptions } from "../native/options.mjs";
export async function buildKit(
@@ -13,7 +13,7 @@ export async function buildKit(
for (const [name, data] of Object.entries(game))
files["native/game/" + name] = data;
const manifest = JSON.parse(
new TextDecoder().decode(await read("/build-targets/manifest.json")),
new TextDecoder().decode(await read(engineResource("/build-targets/manifest.json"))),
) as string[];
for (const name of manifest) {
if (
@@ -22,7 +22,7 @@ export async function buildKit(
name.startsWith("/")
)
throw Error("Invalid build template path");
files["native/" + name] = await read("/build-targets/" + name);
files["native/" + name] = await read(engineResource("/build-targets/" + name));
}
files["native/build-config.json"] = strToU8(JSON.stringify(options, null, 2));
files["README.txt"] = strToU8(
+40
View File
@@ -0,0 +1,40 @@
import "@babylonjs/loaders/glTF";
import { Animation } from "@babylonjs/core/Animations/animation.js";
import { AnimationPropertyInfo } from "@babylonjs/loaders/glTF/2.0/glTFLoaderAnimation.js";
import { SetInterpolationForKey } from "@babylonjs/loaders/glTF/2.0/Extensions/objectModelMapping.js";
// Babylon 9.25.0 registers metallicFactor twice and omits roughnessFactor.
// Its metallic-roughness texture pointer paths also omit KHR_texture_transform.
// Keep these corrections covered by actual glTF animation playback tests.
class MaterialProperty extends AnimationPropertyInfo {
buildAnimations(target: any, name: string, fps: number, keys: any[]) {
return Object.values(target._data || {}).map((data: any) => ({
babylonAnimatable: data.babylonMaterial,
babylonAnimation: this._buildAnimation(name, fps, keys),
}));
}
}
const scalar = (property: string, index = 0, stride = 1) => new MaterialProperty(
Animation.ANIMATIONTYPE_FLOAT, property,
(_target, source, offset, scale) => source[offset + index] * scale,
() => stride,
);
SetInterpolationForKey("/materials/{}/pbrMetallicRoughness/metallicFactor", [scalar("metallic")]);
SetInterpolationForKey("/materials/{}/pbrMetallicRoughness/roughnessFactor", [scalar("roughness")]);
const texturePath = "/materials/{}/pbrMetallicRoughness/metallicRoughnessTexture/extensions/KHR_texture_transform/";
SetInterpolationForKey(texturePath + "offset", [scalar("metallicTexture.uOffset", 0, 2), scalar("metallicTexture.vOffset", 1, 2)]);
SetInterpolationForKey(texturePath + "scale", [scalar("metallicTexture.uScale", 0, 2), scalar("metallicTexture.vScale", 1, 2)]);
SetInterpolationForKey(texturePath + "rotation", [new MaterialProperty(Animation.ANIMATIONTYPE_FLOAT, "metallicTexture.wAng", (_t, s, o, scale) => -s[o] * scale, () => 1)]);
// xmag/ymag are scalar half-extents; both sides use the same sample.
class CameraProperty extends AnimationPropertyInfo {
buildAnimations(target: any, name: string, fps: number, keys: any[]) {
return [{ babylonAnimatable: target._babylonCamera, babylonAnimation: this._buildAnimation(name, fps, keys) }];
}
}
for (const [axis, negative, positive] of [["xmag", "orthoLeft", "orthoRight"], ["ymag", "orthoBottom", "orthoTop"]]) {
SetInterpolationForKey(`/cameras/{}/orthographic/${axis}`, [
new CameraProperty(Animation.ANIMATIONTYPE_FLOAT, negative, (_t, s, o, scale) => -s[o] * scale, () => 1),
new CameraProperty(Animation.ANIMATIONTYPE_FLOAT, positive, (_t, s, o, scale) => s[o] * scale, () => 1),
]);
}
+89
View File
@@ -0,0 +1,89 @@
import { unzipSync } from "fflate";
import { base64, mimeFor } from "./archive.ts";
import { inspectModel, modelDocument } from "./model.ts";
import { emptyProject, entity, uid, activeScene } from "./schema.ts";
/** Resolve inside a supplied bundle. Never fetch network or arbitrary local files. */
export function modelResourcePath(model: string, uri: string) {
const decoded = decodeURIComponent(uri);
if (/^[a-z][a-z\d+.-]*:/i.test(decoded) || /^[\\/]/.test(decoded) || /[\\?#\0]/.test(decoded))
throw Error("Недопустимый путь ресурса: " + uri);
const parts = model.split("/").slice(0, -1);
for (const part of decoded.split("/")) {
if (part === "..") { if (!parts.length) throw Error("Ресурс выходит за каталог импорта"); parts.pop(); }
else if (part && part !== ".") parts.push(part);
}
return parts.join("/");
}
export async function portableModel(
bytes: Uint8Array,
name: string,
read?: (path: string) => Promise<Uint8Array>,
): Promise<{ bytes: Uint8Array; name: string; metadata: ReturnType<typeof inspectModel> }> {
if (/\.zip$/i.test(name)) {
if (bytes.byteLength > 25 * 1024 * 1024) throw Error("Лимит ZIP модели: 25 МБ");
let size = 0;
const files = unzipSync(bytes, { filter: (file) => {
size += file.originalSize;
if (size > 80 * 1024 * 1024 || file.originalSize > 25 * 1024 * 1024)
throw Error("Слишком большой архив модели");
if (file.name.startsWith("/") || file.name.includes("\\") || file.name.split("/").includes(".."))
throw Error("Небезопасный путь в ZIP");
return true;
} });
const models = Object.keys(files).filter(path => /\.(glb|gltf)$/i.test(path) && !path.startsWith("__MACOSX/"));
if (models.length !== 1) throw Error("ZIP должен содержать ровно одну GLB/glTF-сцену и её ресурсы");
const model = models[0];
return portableModel(files[model], model, async path => {
if (!files[path]) throw Error("Отсутствует ресурс: " + path);
return files[path];
});
}
if (!/\.(glb|gltf)$/i.test(name)) throw Error("Ожидался GLB, glTF или ZIP модели");
if (/\.gltf$/i.test(name)) {
const doc = modelDocument(bytes, name);
let total = bytes.byteLength;
for (const [kind, list] of [["buffer", doc.buffers || []], ["image", doc.images || []]] as const) {
for (const item of list) {
if (!item.uri || item.uri.startsWith("data:")) continue;
const path = modelResourcePath(name, item.uri);
if (!read) throw Error("Добавь связанные файлы или импортируй ZIP: " + path);
const resource = await read(path);
total += resource.byteLength;
if (total > 25 * 1024 * 1024) throw Error("Лимит ресурсов модели: 25 МБ");
const ext = path.split(".").pop()?.toLowerCase();
const mime = kind === "buffer" ? "application/octet-stream" : ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" } as Record<string, string>)[ext || ""];
if (!mime) throw Error("Неподдерживаемая текстура: " + path);
item.uri = `data:${mime};base64,${base64(resource)}`;
}
}
bytes = new TextEncoder().encode(JSON.stringify(doc));
}
name = name.split("/").pop()!;
return { bytes, name, metadata: inspectModel(bytes, name) };
}
export function modelComponents(assetId: string, metadata: ReturnType<typeof inspectModel>) {
return {
mesh: { type: "model", assetId },
...(metadata.clips.length ? { animator: { autoplay: metadata.clips[0], loop: true, speed: 1 } } : {}),
};
}
export const modelDataUri = (model: { name: string; bytes: Uint8Array }) =>
`data:${mimeFor(model.name)};base64,${base64(model.bytes)}`;
/** Open a complete Blender scene as an undoable project replacement. */
export function modelProject(model: Awaited<ReturnType<typeof portableModel>>) {
const project = emptyProject(model.name.replace(/\.(glb|gltf)$/i, ""));
const id = uid("asset");
project.assets = [{ id, name: model.name, kind: "model", uri: modelDataUri(model), metadata: model.metadata }];
const components = { ...modelComponents(id, model.metadata), ...(model.metadata.cameras.length ? { camera: { mode: "imported", ...(model.metadata.activeCamera ? { cameraName: model.metadata.activeCamera } : {}) } } : {}) };
activeScene(project).entities = [entity(project.name, components)];
if (model.metadata.lights) {
project.settings.ambient = 0;
project.settings.rendering = { defaultLights: false };
}
return project;
}
+17 -2
View File
@@ -1,5 +1,5 @@
/** Validate portable model containers before allowing renderer-side resource loads. */
export function inspectModel(bytes: Uint8Array, name: string) {
export function modelDocument(bytes: Uint8Array, name: string) {
if (bytes.byteLength < 12 || bytes.byteLength > 25 * 1024 * 1024)
throw Error("Размер модели: 12 байт — 25 МБ");
let gltf: any;
@@ -24,6 +24,11 @@ export function inspectModel(bytes: Uint8Array, name: string) {
);
}
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
return gltf;
}
export function inspectModel(bytes: Uint8Array, name: string) {
const gltf = modelDocument(bytes, name);
if (gltf.asset?.version !== "2.0") throw Error("Поддерживается glTF 2.0");
if (
[...(gltf.buffers || []), ...(gltf.images || [])].some(
(r: any) => r.uri && !r.uri.startsWith("data:"),
@@ -41,12 +46,22 @@ export function inspectModel(bytes: Uint8Array, name: string) {
)
)
throw Error(
"Для автономного экспорта 0.1 используй GLB без Draco, Meshopt и KTX2",
"Используй экспорт без Draco, Meshopt и KTX2: автономные декодеры не включены",
);
return {
clips: (gltf.animations || []).map(
(a: any, i: number) => a.name || "Animation " + i,
),
skeletons: (gltf.skins || []).length,
nodes: (gltf.nodes || []).length,
meshes: (gltf.meshes || []).length,
materials: (gltf.materials || []).map((m: any, i: number) => m.name || `Material ${i}`),
cameras: (gltf.cameras || []).map((c: any, i: number) => c.name || `Camera ${i}`),
activeCamera: typeof gltf.extras?.forma?.activeCamera === "string" ? gltf.extras.forma.activeCamera : null,
lights: (gltf.extensions?.KHR_lights_punctual?.lights || []).length + (gltf.extensions?.EXT_lights_area?.lights || []).length,
morphTargets: (gltf.meshes || []).reduce((sum: number, m: any) => sum + Math.max(0, ...(m.primitives || []).map((p: any) => p.targets?.length || 0)), 0),
animatedProperties: [...new Set<string>((gltf.animations || []).flatMap((a: any) => (a.channels || []).map((c: any) => c.target?.extensions?.KHR_animation_pointer?.pointer).filter(Boolean)))],
extensions: gltf.extensionsUsed || [],
warnings: (Array.isArray(gltf.extras?.forma?.warnings) ? gltf.extras.forma.warnings : []).filter((w: any) => typeof w === "string").slice(0, 100),
};
}
-27
View File
@@ -74,7 +74,6 @@ async function boot() {
const stick = document.getElementById("stick")!;
const knob = stick.querySelector("i")!;
let stickId: number | null = null;
let look: { id: number; x: number; y: number } | null = null;
const move = (event: PointerEvent) => {
if (event.pointerId !== stickId || runtime.paused) return;
const bounds = stick.getBoundingClientRect();
@@ -111,37 +110,11 @@ async function boot() {
runtime.touch[input] = false;
};
}
canvas.addEventListener("pointerdown", (event) => {
if (!firstPerson || runtime.paused) return;
if (event.pointerType === "mouse") {
const lock = canvas.requestPointerLock?.();
if (lock && typeof lock.catch === "function") lock.catch(() => {});
} else {
look = { id: event.pointerId, x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
}
});
canvas.addEventListener("pointermove", (event) => {
if (!look || look.id !== event.pointerId || runtime.paused) return;
runtime.lookBy(
(event.clientX - look.x) * 0.004,
(look.y - event.clientY) * 0.004,
);
look.x = event.clientX;
look.y = event.clientY;
});
const release = () => {
stickId = null;
look = null;
runtime.releaseInput();
knob.style.transform = "";
};
canvas.addEventListener("pointerup", () => {
look = null;
});
canvas.addEventListener("pointercancel", () => {
look = null;
});
window.addEventListener("blur", release);
document.addEventListener("visibilitychange", () => {
release();
+67 -61
View File
@@ -1,5 +1,5 @@
import * as B from "@babylonjs/core";
import "@babylonjs/loaders/glTF";
import "./gltf-compat.ts";
import {
type Project,
type Entity,
@@ -13,6 +13,7 @@ import {
import { workerSource } from "./script-host.ts";
import { inspectModel } from "./model.ts";
import { CharacterMotor } from "./character.ts";
import { bindViewInput } from "./view-input.ts";
import { RuntimePresentation } from "./presentation.ts";
export interface RuntimeCallbacks {
select?: (id: string | null) => void;
@@ -108,6 +109,7 @@ export class FormaRuntime {
public callbacks: RuntimeCallbacks = {},
public options: RuntimeOptions = {},
) {
const runtime = this;
this.engine =
options.engine ||
new B.Engine(
@@ -121,6 +123,12 @@ export class FormaRuntime {
false,
);
if (!options.headless) {
this.cleanup.push(bindViewInput(canvas, {
get playing() { return runtime.playing; },
get paused() { return runtime.paused; },
firstPerson: () => this.firstPerson(),
lookBy: (x, y) => this.lookBy(x, y),
}));
const resize = new ResizeObserver(() => this.engine.resize());
resize.observe(canvas);
this.cleanup.push(() => resize.disconnect());
@@ -233,6 +241,7 @@ export class FormaRuntime {
this.actionQueue.add(name);
}
releaseInput() {
if (!this.options.headless && document.pointerLockElement === this.canvas) document.exitPointerLock();
this.keys.clear();
this.actionQueue.clear();
this.automation = null;
@@ -331,7 +340,7 @@ export class FormaRuntime {
}
scene.activeCamera = this.camera;
const sky = new B.HemisphericLight("Sky", B.Vector3.Up(), scene);
sky.intensity = p.settings.ambient;
sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
sky.groundColor = B.Color3.FromHexString("#8a8475");
const sun = new B.DirectionalLight(
"Sun",
@@ -339,7 +348,7 @@ export class FormaRuntime {
scene,
);
sun.position = new B.Vector3(-12, 22, -14);
sun.intensity = 2.5;
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;
@@ -451,11 +460,13 @@ export class FormaRuntime {
root.computeWorldMatrix(true);
const c = n.components.material;
if (c && (n.components.mesh?.type !== "model" || c.override)) {
for (const mesh of root.getChildMeshes()) {
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 =
@@ -475,6 +486,8 @@ export class FormaRuntime {
node.dispose();
}
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);
@@ -490,7 +503,9 @@ export class FormaRuntime {
);
this.scene.shadowsEnabled = p.settings.shadows;
const sky = this.scene.getLightByName("Sky");
if (sky) sky.intensity = p.settings.ambient;
if (sky) sky.intensity = p.settings.rendering?.defaultLights === false ? 0 : p.settings.ambient;
const sun = this.scene.getLightByName("Sun");
if (sun) sun.intensity = p.settings.rendering?.defaultLights === false ? 0 : 2.5;
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);
@@ -532,61 +547,40 @@ export class FormaRuntime {
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 +
" анимаций",
);
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());
}
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();
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 =
@@ -712,7 +706,7 @@ export class FormaRuntime {
sign.isPickable = false;
}
for (const mesh of meshes) {
mesh.metadata = { entityId: n.id };
mesh.metadata = { ...mesh.metadata, entityId: n.id };
mesh.isPickable = true;
mesh.receiveShadows = n.components.mesh?.receiveShadows !== false;
if (n.components.mesh?.castShadows !== false)
@@ -829,6 +823,10 @@ export class FormaRuntime {
)?.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(
@@ -1326,6 +1324,7 @@ export class FormaRuntime {
else this.flashes.set(id, t - dt);
}
try {
this.scene.animationsEnabled = !(this.playing && this.paused);
this.scene.render();
} catch (e) {
this.log("error", "Render: " + String(e));
@@ -1364,10 +1363,17 @@ export class FormaRuntime {
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 updateProjection() {
const c = this.state.find((n) => n.enabled && n.components.camera)
?.components.camera;
const cameraEntity = this.state.find(n => n.enabled && n.components.camera);
const c = cameraEntity?.components.camera;
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"
+1 -1
View File
@@ -58,7 +58,7 @@ export interface Project {
ambient: number;
shadows: boolean;
renderScale: number;
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number };
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number; defaultLights?: boolean };
controls?: Partial<
Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]>
>;
+38
View File
@@ -0,0 +1,38 @@
/** Shared by the editor and both standalone player presentations. */
export function bindViewInput(
canvas: HTMLCanvasElement,
runtime: { playing: boolean; paused: boolean; firstPerson: () => boolean; lookBy: (x: number, y: number) => void },
) {
let pointer: { id: number; x: number; y: number } | null = null;
const down = (event: PointerEvent) => {
if (!runtime.playing || runtime.paused || !runtime.firstPerson()) return;
if (event.pointerType === "mouse") {
if (event.button !== 0) return;
try { void canvas.requestPointerLock?.()?.catch(() => {}); } catch {}
} else {
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
}
};
const move = (event: PointerEvent) => {
if (!pointer || event.pointerId !== pointer.id) return;
if (!runtime.playing || runtime.paused || !runtime.firstPerson()) { pointer = null; return; }
runtime.lookBy((event.clientX - pointer.x) * .004, (pointer.y - event.clientY) * .004);
pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
};
const release = () => { pointer = null; };
canvas.addEventListener("pointerdown", down);
canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
canvas.addEventListener("lostpointercapture", release);
canvas.addEventListener("blur", release);
return () => {
canvas.removeEventListener("pointerdown", down);
canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerup", release);
canvas.removeEventListener("pointercancel", release);
canvas.removeEventListener("lostpointercapture", release);
canvas.removeEventListener("blur", release);
};
}