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

279 lines
8.8 KiB
TypeScript

import * as B from "@babylonjs/core";
import type { FormaRuntime } from "./runtime.ts";
import { clone, type Vec3 } from "./schema.ts";
import { paintTiles, fillTiles } from "./two-d.ts";
export class View2D {
enabled = false;
width = 20;
snap = 0;
brush: { id: string; frame: number | null; fill?: boolean } | null = null;
private orbit: any;
private drag: any;
private cleanup: (() => void)[] = [];
constructor(
private runtime: FormaRuntime,
canvas: HTMLCanvasElement,
) {
if (runtime.options.headless) return;
const down = (e: PointerEvent) => {
const r = this.runtime;
if (!this.enabled || r.playing) return;
if (e.button === 1 || e.button === 2 || e.pointerType === "touch") {
this.drag = {
type: "pan",
x: e.clientX,
y: e.clientY,
target: r.camera.target.clone(),
};
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
if (e.button !== 0) return;
if (this.brush) {
const node = r.state.find((n) => n.id === this.brush!.id);
if (node?.components.tilemap) {
this.drag = {
type: "paint",
id: node.id,
original: clone(node.components.tilemap.cells),
last: null,
};
canvas.setPointerCapture(e.pointerId);
paint(e);
return;
}
}
const bounds = canvas.getBoundingClientRect(),
pick = r.scene.pick(
((e.clientX - bounds.left) * r.engine.getRenderWidth()) /
bounds.width,
((e.clientY - bounds.top) * r.engine.getRenderHeight()) /
bounds.height,
(m) => !!m.metadata?.entityId,
);
const id = pick?.pickedMesh?.metadata?.entityId;
if (id) {
r.callbacks.select?.(id);
const root = r.nodes.get(id)!;
if (r.tool === "move") {
this.drag = {
type: "move",
id,
point: this.point(
e.clientX,
e.clientY,
root.getAbsolutePosition().z,
),
original: root.position.clone(),
};
canvas.setPointerCapture(e.pointerId);
}
}
};
const paint = (e: PointerEvent) => {
const r = this.runtime,
node = r.state.find((n) => n.id === this.drag?.id),
root = node && r.nodes.get(node.id);
if (!node || !root || !this.brush) return;
const point = this.point(
e.clientX,
e.clientY,
root.getAbsolutePosition().z,
),
local = B.Vector3.TransformCoordinates(
point,
B.Matrix.Invert(root.getWorldMatrix()),
),
map = node.components.tilemap,
x = Math.floor(local.x / map.tileSize[0]),
y = Math.floor(local.y / map.tileSize[1]);
if (this.drag.last?.x === x && this.drag.last?.y === y) return;
const points = [];
if (this.brush.fill) {
if (this.drag.last) return;
map.cells = fillTiles(
map.cells,
x,
y,
this.brush.frame,
map.width,
map.height,
);
} else {
const prev = this.drag.last || { x, y },
count = Math.max(Math.abs(x - prev.x), Math.abs(y - prev.y));
for (let i = 0; i <= count; i++)
points.push({
x: Math.round(prev.x + ((x - prev.x) * i) / Math.max(1, count)),
y: Math.round(prev.y + ((y - prev.y) * i) / Math.max(1, count)),
});
map.cells = paintTiles(
map.cells,
points,
this.brush.frame,
map.width,
map.height,
);
}
this.drag.last = { x, y };
r.graphics2d.update(node, true);
};
const move = (e: PointerEvent) => {
const r = this.runtime,
d = this.drag;
if (!d || r.playing) return;
if (d.type === "pan") {
const rect = canvas.getBoundingClientRect(),
scale = this.width / rect.width;
r.camera.setTarget(
d.target.add(
new B.Vector3(
(d.x - e.clientX) * scale,
(e.clientY - d.y) * scale,
0,
),
),
);
} else if (d.type === "paint") paint(e);
else {
const root = r.nodes.get(d.id)!;
let delta = this.point(
e.clientX,
e.clientY,
root.getAbsolutePosition().z,
).subtract(d.point);
if (root.parent)
delta = B.Vector3.TransformNormal(
delta,
B.Matrix.Invert(root.parent.getWorldMatrix()),
);
const p = d.original.add(delta);
if (this.snap) {
p.x = Math.round(p.x / this.snap) * this.snap;
p.y = Math.round(p.y / this.snap) * this.snap;
}
root.position.copyFrom(p);
root.computeWorldMatrix(true);
}
};
const end = (e: PointerEvent) => {
const r = this.runtime,
d = this.drag;
if (!d) return;
this.drag = null;
if (d.type === "move") {
const root = r.nodes.get(d.id)!,
node = r.state.find((n) => n.id === d.id)!;
if (e.type === "pointercancel") root.position.copyFrom(d.original);
else if (!root.position.equals(d.original))
r.callbacks.transform?.(d.id, {
...clone(node.transform),
position: root.position.asArray() as Vec3,
});
}
if (d.type === "paint") {
const node = r.state.find((n) => n.id === d.id);
if (!node) return;
const cells = clone(node.components.tilemap.cells);
node.components.tilemap.cells = d.original;
r.graphics2d.update(node, true);
if (
e.type !== "pointercancel" &&
JSON.stringify(cells) !== JSON.stringify(d.original)
)
r.callbacks.paintTiles?.(d.id, cells);
}
};
const wheel = (e: WheelEvent) => {
if (this.enabled && !this.runtime.playing) {
e.preventDefault();
this.width = Math.max(
0.25,
Math.min(2000, this.width * Math.exp(e.deltaY * 0.001)),
);
this.update();
}
};
canvas.addEventListener("pointerdown", down);
canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerup", end);
canvas.addEventListener("pointercancel", end);
canvas.addEventListener("wheel", wheel, { passive: false });
this.cleanup.push(() => {
canvas.removeEventListener("pointerdown", down);
canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerup", end);
canvas.removeEventListener("pointercancel", end);
canvas.removeEventListener("wheel", wheel);
});
}
point(x: number, y: number, z = 0) {
const r = this.runtime,
rect = r.canvas.getBoundingClientRect(),
ray = r.scene.createPickingRay(
((x - rect.left) * r.engine.getRenderWidth()) / rect.width,
((y - rect.top) * r.engine.getRenderHeight()) / rect.height,
B.Matrix.Identity(),
r.camera,
);
const t = (z - ray.origin.z) / ray.direction.z;
return ray.origin.add(ray.direction.scale(t));
}
set(enabled: boolean) {
const r = this.runtime;
this.enabled = enabled;
if (!r.camera) return;
if (enabled) {
if (!this.orbit)
this.orbit = {
alpha: r.camera.alpha,
beta: r.camera.beta,
radius: r.camera.radius,
target: r.camera.target.clone(),
};
r.camera.detachControl();
r.camera.alpha = Math.PI / 2;
r.camera.beta = Math.PI / 2;
r.camera.radius = 100;
r.camera.mode = B.Camera.ORTHOGRAPHIC_CAMERA;
r.grid.rotation.x = Math.PI / 2;
r.grid.color = B.Color3.FromHexString("#334655");
r.grid.position.z = -0.05;
this.update();
} else {
r.camera.mode = B.Camera.PERSPECTIVE_CAMERA;
if (this.orbit) {
Object.assign(r.camera, {
alpha: this.orbit.alpha,
beta: this.orbit.beta,
radius: this.orbit.radius,
});
r.camera.setTarget(this.orbit.target);
this.orbit = null;
}
r.grid.rotation.x = 0;
r.grid.position.z = 0;
r.grid.color = B.Color3.FromHexString("#989b91");
if (!r.options.headless && !r.playing)
r.camera.attachControl(r.canvas, true);
this.brush = null;
}
r.setTool(r.tool);
}
update() {
if (!this.enabled || !this.runtime.camera) return;
const c = this.runtime.camera,
aspect =
this.runtime.engine.getRenderWidth() /
Math.max(1, this.runtime.engine.getRenderHeight());
c.orthoLeft = -this.width / 2;
c.orthoRight = this.width / 2;
c.orthoTop = this.width / aspect / 2;
c.orthoBottom = -this.width / aspect / 2;
}
dispose() {
for (const f of this.cleanup) f();
}
}