fa5485049a
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import * as THREE from 'three';
|
|
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
|
|
import type {
|
|
EditableMapDocument,
|
|
EditableMapObject,
|
|
MapEditorTransformMode,
|
|
} from '../map/editor/types';
|
|
import type { MapEditorDraftPreviewInstance } from '../map/mapSceneDraft';
|
|
import {
|
|
DEFAULT_MAP_INSTANCE_TRANSFORM,
|
|
type MapInstanceTransform,
|
|
type SpawnPoint,
|
|
} from '../map/types';
|
|
|
|
export interface MapEditorLayerCallbacks {
|
|
onSelect(id: string | null): void;
|
|
onPreviewSelect(mapAssetId: string, objectId: string): void;
|
|
onTransform(
|
|
id: string,
|
|
position: [number, number, number],
|
|
quaternion: [number, number, number, number],
|
|
scale: [number, number, number],
|
|
): void;
|
|
onDragging(value: boolean): void;
|
|
}
|
|
|
|
function material(object: EditableMapObject): THREE.MeshStandardMaterial {
|
|
const [r, g, b, a] = object.rgba;
|
|
return new THREE.MeshStandardMaterial({
|
|
color: new THREE.Color(r, g, b),
|
|
emissive: new THREE.Color(0x000000),
|
|
opacity: Math.min(a, 0.65),
|
|
transparent: true,
|
|
depthWrite: false,
|
|
});
|
|
}
|
|
function mesh(geometry: THREE.BufferGeometry, object: EditableMapObject): THREE.Mesh {
|
|
const result = new THREE.Mesh(geometry, material(object));
|
|
result.castShadow = true;
|
|
result.receiveShadow = true;
|
|
result.renderOrder = 9;
|
|
return result;
|
|
}
|
|
function spawnPreview(spawn: SpawnPoint): THREE.Group {
|
|
const group = new THREE.Group();
|
|
group.name = `__platform_map_editor_spawn_${spawn.id}`;
|
|
group.position.set(spawn.position[0], spawn.position[1], spawn.position[2] + 0.03);
|
|
group.rotation.z = (spawn.yawDeg * Math.PI) / 180;
|
|
const markerMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x22c55e,
|
|
emissive: 0x14532d,
|
|
transparent: true,
|
|
opacity: 0.9,
|
|
});
|
|
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.025, 8, 24), markerMaterial);
|
|
const arrow = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.32, 12), markerMaterial);
|
|
ring.renderOrder = 9;
|
|
arrow.renderOrder = 9;
|
|
arrow.rotation.z = -Math.PI / 2;
|
|
arrow.position.x = 0.25;
|
|
group.add(ring, arrow);
|
|
return group;
|
|
}
|
|
function objectPreview(object: EditableMapObject): THREE.Group {
|
|
const group = new THREE.Group(),
|
|
p = object.parameters;
|
|
group.name = `__platform_map_editor_${object.id}`;
|
|
group.userData.mapEditorObjectId = object.id;
|
|
group.userData.mapEditorLocked = object.placementMode === 'locked';
|
|
group.userData.mapEditorPlacementMode = object.placementMode;
|
|
group.position.fromArray(object.pose.position);
|
|
group.quaternion.set(
|
|
object.pose.quaternion[1],
|
|
object.pose.quaternion[2],
|
|
object.pose.quaternion[3],
|
|
object.pose.quaternion[0],
|
|
);
|
|
if (object.type === 'box')
|
|
group.add(mesh(new THREE.BoxGeometry(p.sizeX, p.sizeY, p.sizeZ), object));
|
|
else if (object.type === 'cylinder')
|
|
group.add(
|
|
mesh(
|
|
new THREE.CylinderGeometry(p.radius, p.radius, p.height, 24).rotateX(Math.PI / 2),
|
|
object,
|
|
),
|
|
);
|
|
else if (object.type === 'capsule')
|
|
group.add(
|
|
mesh(new THREE.CapsuleGeometry(p.radius, p.length, 8, 16).rotateX(Math.PI / 2), object),
|
|
);
|
|
else if (object.type === 'ramp') {
|
|
const item = mesh(
|
|
new THREE.BoxGeometry(Math.hypot(p.length, p.rise), p.width, p.thickness),
|
|
object,
|
|
);
|
|
item.position.z = p.rise / 2;
|
|
item.rotation.y = -Math.atan2(p.rise, p.length);
|
|
group.add(item);
|
|
} else
|
|
for (let index = 0; index < p.count; index += 1) {
|
|
const height = p.stepHeight * (index + 1);
|
|
const item = mesh(new THREE.BoxGeometry(p.stepDepth, p.width, height), object);
|
|
item.position.set(p.stepDepth * index, 0, height / 2);
|
|
group.add(item);
|
|
}
|
|
group.traverse((child) => {
|
|
child.userData.mapEditorObjectId = object.id;
|
|
});
|
|
return group;
|
|
}
|
|
|
|
function disposeGroupChildren(group: THREE.Group): void {
|
|
const geometries = new Set<THREE.BufferGeometry>();
|
|
const materials = new Set<THREE.Material>();
|
|
for (const child of group.children)
|
|
child.traverse((object) => {
|
|
if (!(object instanceof THREE.Mesh)) return;
|
|
geometries.add(object.geometry);
|
|
for (const item of Array.isArray(object.material) ? object.material : [object.material])
|
|
materials.add(item);
|
|
});
|
|
group.clear();
|
|
for (const geometry of geometries) geometry.dispose();
|
|
for (const item of materials) item.dispose();
|
|
}
|
|
|
|
export class MapEditorLayer {
|
|
readonly group = new THREE.Group();
|
|
readonly previewGroup = new THREE.Group();
|
|
readonly transform: TransformControls;
|
|
private readonly helper: THREE.Object3D;
|
|
private readonly raycaster = new THREE.Raycaster();
|
|
private readonly pointer = new THREE.Vector2();
|
|
private readonly objects = new Map<string, THREE.Group>();
|
|
private readonly previewInstances = new Map<string, THREE.Group>();
|
|
private readonly surfaceIndicator = new THREE.Group();
|
|
private readonly surfaceRingMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0x34d399,
|
|
transparent: true,
|
|
opacity: 0.42,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
private readonly surfaceLineMaterial = new THREE.LineBasicMaterial({
|
|
color: 0x6ee7b7,
|
|
transparent: true,
|
|
opacity: 0.7,
|
|
depthTest: false,
|
|
});
|
|
private selectedId: string | null = null;
|
|
private documentLoaded = false;
|
|
private surfaceIndicatorScale = 0.25;
|
|
private surfacePulseStarted = 0;
|
|
|
|
constructor(
|
|
private readonly scene: THREE.Scene,
|
|
private readonly camera: THREE.Camera,
|
|
domElement: HTMLElement,
|
|
private readonly callbacks: MapEditorLayerCallbacks,
|
|
) {
|
|
this.previewGroup.name = '__platform_map_editor_scene_previews__';
|
|
this.group.name = '__platform_map_editor__';
|
|
this.surfaceIndicator.name = '__platform_snap_surface_indicator__';
|
|
const surfaceRing = new THREE.Mesh(
|
|
new THREE.RingGeometry(0.72, 1, 48),
|
|
this.surfaceRingMaterial,
|
|
);
|
|
surfaceRing.renderOrder = 110;
|
|
const surfaceCross = new THREE.LineSegments(
|
|
new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(-1.18, 0, 0),
|
|
new THREE.Vector3(1.18, 0, 0),
|
|
new THREE.Vector3(0, -1.18, 0),
|
|
new THREE.Vector3(0, 1.18, 0),
|
|
]),
|
|
this.surfaceLineMaterial,
|
|
);
|
|
surfaceCross.renderOrder = 110;
|
|
this.surfaceIndicator.add(surfaceRing, surfaceCross);
|
|
this.surfaceIndicator.visible = false;
|
|
scene.add(this.previewGroup, this.group, this.surfaceIndicator);
|
|
this.transform = new TransformControls(camera, domElement);
|
|
this.transform.setSpace('world');
|
|
this.transform.setSize(0.8);
|
|
this.transform.setTranslationSnap(0.1);
|
|
this.transform.setRotationSnap(THREE.MathUtils.degToRad(5));
|
|
this.helper = this.transform.getHelper();
|
|
this.helper.name = '__platform_map_editor_transform__';
|
|
this.helper.visible = false;
|
|
scene.add(this.helper);
|
|
this.transform.addEventListener('dragging-changed', (event) => {
|
|
this.callbacks.onDragging(Boolean(event.value));
|
|
});
|
|
this.transform.addEventListener('mouseUp', () => this.commitTransform());
|
|
this.setTransformMode('translate');
|
|
}
|
|
|
|
get enabled(): boolean {
|
|
return this.documentLoaded || this.previewInstances.size > 0;
|
|
}
|
|
|
|
selectedBoundingSphere(): THREE.Sphere | null {
|
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
|
if (!selected) return null;
|
|
selected.updateWorldMatrix(true, true);
|
|
const bounds = new THREE.Box3().setFromObject(selected);
|
|
if (bounds.isEmpty()) return null;
|
|
return bounds.getBoundingSphere(new THREE.Sphere());
|
|
}
|
|
|
|
flashSurfaceAlignment(id?: string): void {
|
|
if (id && id !== this.selectedId) return;
|
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
|
if (!selected || selected.userData.mapEditorLocked) return;
|
|
this.positionSurfaceIndicator(selected);
|
|
this.startSurfacePulse();
|
|
this.surfaceIndicator.visible = true;
|
|
}
|
|
|
|
update(now: number): void {
|
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
|
if (!selected || selected.userData.mapEditorLocked) {
|
|
this.surfaceIndicator.visible = false;
|
|
return;
|
|
}
|
|
this.positionSurfaceIndicator(selected);
|
|
const elapsed = now - this.surfacePulseStarted;
|
|
const pulse = elapsed >= 0 && elapsed < 720 ? 1 - elapsed / 720 : 0;
|
|
const wave = pulse * (0.16 + Math.sin((elapsed / 720) * Math.PI * 4) * 0.08);
|
|
this.surfaceIndicator.scale.setScalar(this.surfaceIndicatorScale * (1 + wave));
|
|
this.surfaceRingMaterial.opacity = 0.32 + pulse * 0.38;
|
|
this.surfaceLineMaterial.opacity = 0.5 + pulse * 0.4;
|
|
}
|
|
|
|
setDocument(
|
|
document: EditableMapDocument | null,
|
|
instanceTransform: MapInstanceTransform = DEFAULT_MAP_INSTANCE_TRANSFORM,
|
|
): void {
|
|
const selected = this.selectedId;
|
|
this.clearObjects();
|
|
this.group.position.set(instanceTransform.positionX, instanceTransform.positionY, 0);
|
|
this.group.rotation.set(0, 0, THREE.MathUtils.degToRad(instanceTransform.yawDeg));
|
|
this.group.updateMatrixWorld(true);
|
|
this.documentLoaded = Boolean(document);
|
|
this.transform.enabled = this.documentLoaded;
|
|
if (!document) {
|
|
this.selectObject(null, false);
|
|
return;
|
|
}
|
|
for (const object of document.objects) {
|
|
if (!object.enabled) continue;
|
|
const preview = objectPreview(object);
|
|
this.objects.set(object.id, preview);
|
|
this.group.add(preview);
|
|
}
|
|
for (const spawn of document.spawnPoints) this.group.add(spawnPreview(spawn));
|
|
this.selectObject(selected && this.objects.has(selected) ? selected : null, false);
|
|
}
|
|
|
|
/** 绘制非激活、但仍处于统一场景草稿中的认证资产实例。 */
|
|
setPreviewInstances(instances: readonly MapEditorDraftPreviewInstance[]): void {
|
|
this.clearPreviewInstances();
|
|
for (const instance of instances) {
|
|
const root = new THREE.Group();
|
|
root.name = `__platform_map_editor_scene_preview_${instance.id}__`;
|
|
root.userData.mapAssetId = instance.id;
|
|
root.position.set(instance.transform.positionX, instance.transform.positionY, 0);
|
|
root.rotation.z = THREE.MathUtils.degToRad(instance.transform.yawDeg);
|
|
for (const object of instance.document.objects) {
|
|
if (object.enabled) root.add(objectPreview(object));
|
|
}
|
|
for (const spawn of instance.document.spawnPoints) root.add(spawnPreview(spawn));
|
|
root.traverse((object) => {
|
|
object.userData.mapEditorMapAssetId = instance.id;
|
|
});
|
|
this.previewInstances.set(instance.id, root);
|
|
this.previewGroup.add(root);
|
|
}
|
|
}
|
|
|
|
setTransformMode(mode: MapEditorTransformMode): void {
|
|
this.transform.setMode(mode);
|
|
const translate = mode === 'translate';
|
|
const scale = mode === 'scale';
|
|
this.transform.showX = translate || scale;
|
|
this.transform.showY = translate || scale;
|
|
this.transform.showZ = translate || scale || mode === 'rotate';
|
|
this.transform.showXY = translate;
|
|
this.transform.showYZ = false;
|
|
this.transform.showXZ = false;
|
|
this.transform.showE = false;
|
|
}
|
|
|
|
setSnapping(translation: number | null, rotationDegrees: number | null): void {
|
|
this.transform.setTranslationSnap(translation);
|
|
this.transform.setRotationSnap(
|
|
rotationDegrees === null ? null : THREE.MathUtils.degToRad(rotationDegrees),
|
|
);
|
|
}
|
|
|
|
selectObject(id: string | null, notify = false): void {
|
|
const previousId = this.selectedId;
|
|
this.selectedId = id && this.objects.has(id) ? id : null;
|
|
this.transform.detach();
|
|
for (const [objectId, root] of this.objects)
|
|
root.traverse((child) => {
|
|
if (!(child instanceof THREE.Mesh)) return;
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
for (const item of materials) {
|
|
if (!(item instanceof THREE.MeshStandardMaterial)) continue;
|
|
item.emissive.setHex(objectId === this.selectedId ? 0x1d4ed8 : 0x000000);
|
|
item.emissiveIntensity = objectId === this.selectedId ? 0.45 : 1;
|
|
item.opacity = objectId === this.selectedId ? 0.85 : 0.65;
|
|
}
|
|
});
|
|
const selected = this.selectedId ? this.objects.get(this.selectedId) : undefined;
|
|
const editable = Boolean(selected && !selected.userData.mapEditorLocked);
|
|
if (selected && editable) {
|
|
this.transform.attach(selected);
|
|
this.positionSurfaceIndicator(selected);
|
|
if (previousId !== this.selectedId) this.startSurfacePulse();
|
|
} else this.surfaceIndicator.visible = false;
|
|
this.helper.visible = editable;
|
|
if (notify) this.callbacks.onSelect(this.selectedId);
|
|
}
|
|
|
|
/** 命中编辑对象或操纵器时消费左键;点击空白区域取消对象选中。 */
|
|
handlePointerDown(
|
|
event: PointerEvent,
|
|
bounds: DOMRect,
|
|
maximumDistance = Number.POSITIVE_INFINITY,
|
|
): boolean {
|
|
if (!this.enabled || event.button !== 0) return false;
|
|
if (this.transform.axis || this.transform.dragging) return true;
|
|
this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
|
this.pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1;
|
|
this.raycaster.setFromCamera(this.pointer, this.camera);
|
|
this.group.updateMatrixWorld(true);
|
|
this.previewGroup.updateMatrixWorld(true);
|
|
const hit = this.raycaster
|
|
.intersectObjects([...this.objects.values(), ...this.previewInstances.values()], true)
|
|
.find(
|
|
(candidate) =>
|
|
typeof candidate.object.userData.mapEditorObjectId === 'string' &&
|
|
candidate.distance <= maximumDistance + 1e-3,
|
|
);
|
|
const objectId = hit?.object.userData.mapEditorObjectId;
|
|
const mapAssetId = hit?.object.userData.mapEditorMapAssetId;
|
|
if (typeof objectId === 'string' && typeof mapAssetId === 'string') {
|
|
this.callbacks.onPreviewSelect(mapAssetId, objectId);
|
|
return true;
|
|
}
|
|
if (typeof objectId === 'string') {
|
|
this.selectObject(objectId, true);
|
|
return true;
|
|
}
|
|
if (this.selectedId) this.selectObject(null, true);
|
|
return false;
|
|
}
|
|
|
|
private startSurfacePulse(): void {
|
|
const now = performance.now();
|
|
this.surfacePulseStarted = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
? now - 720
|
|
: now;
|
|
}
|
|
|
|
private positionSurfaceIndicator(selected: THREE.Group): void {
|
|
selected.updateWorldMatrix(true, true);
|
|
const bounds = new THREE.Box3().setFromObject(selected);
|
|
if (bounds.isEmpty()) {
|
|
this.surfaceIndicator.visible = false;
|
|
return;
|
|
}
|
|
const center = bounds.getCenter(new THREE.Vector3());
|
|
const size = bounds.getSize(new THREE.Vector3());
|
|
const placementMode = selected.userData.mapEditorPlacementMode;
|
|
const color = placementMode === 'gravity' ? 0x38bdf8 : 0x34d399;
|
|
this.surfaceRingMaterial.color.setHex(color);
|
|
this.surfaceLineMaterial.color.setHex(color);
|
|
this.surfaceIndicatorScale = Math.min(1.5, Math.max(0.16, Math.max(size.x, size.y) * 0.62));
|
|
this.surfaceIndicator.position.set(center.x, center.y, bounds.min.z + 0.004);
|
|
this.surfaceIndicator.scale.setScalar(this.surfaceIndicatorScale);
|
|
this.surfaceIndicator.visible = true;
|
|
}
|
|
|
|
private commitTransform(): void {
|
|
if (!this.selectedId) return;
|
|
const object = this.objects.get(this.selectedId);
|
|
if (!object) return;
|
|
const position: [number, number, number] = [
|
|
object.position.x,
|
|
object.position.y,
|
|
object.position.z,
|
|
];
|
|
const quaternion: [number, number, number, number] = [
|
|
object.quaternion.w,
|
|
object.quaternion.x,
|
|
object.quaternion.y,
|
|
object.quaternion.z,
|
|
];
|
|
const scale: [number, number, number] = [object.scale.x, object.scale.y, object.scale.z];
|
|
this.callbacks.onTransform(this.selectedId, position, quaternion, scale);
|
|
}
|
|
|
|
private clearObjects(): void {
|
|
this.transform.detach();
|
|
this.helper.visible = false;
|
|
disposeGroupChildren(this.group);
|
|
this.objects.clear();
|
|
}
|
|
|
|
private clearPreviewInstances(): void {
|
|
disposeGroupChildren(this.previewGroup);
|
|
this.previewInstances.clear();
|
|
}
|
|
|
|
clear(): void {
|
|
this.documentLoaded = false;
|
|
this.selectedId = null;
|
|
this.clearObjects();
|
|
this.clearPreviewInstances();
|
|
this.group.position.set(0, 0, 0);
|
|
this.group.rotation.set(0, 0, 0);
|
|
this.surfaceIndicator.visible = false;
|
|
this.surfacePulseStarted = 0;
|
|
this.transform.enabled = false;
|
|
this.callbacks.onDragging(false);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.clear();
|
|
this.callbacks.onDragging(false);
|
|
this.transform.dispose();
|
|
this.helper.removeFromParent();
|
|
for (const child of this.surfaceIndicator.children)
|
|
if (child instanceof THREE.Mesh || child instanceof THREE.Line) child.geometry.dispose();
|
|
this.surfaceRingMaterial.dispose();
|
|
this.surfaceLineMaterial.dispose();
|
|
this.surfaceIndicator.removeFromParent();
|
|
this.previewGroup.removeFromParent();
|
|
this.group.removeFromParent();
|
|
}
|
|
}
|