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(); const materials = new Set(); 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(); private readonly previewInstances = new Map(); 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(); } }