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(); } }