feat(web-platform): release V0.7 地图模块
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-01 14:23:49 +08:00
parent 60d3a6d68c
commit 3b4ced6437
58 changed files with 7287 additions and 269 deletions
+267
View File
@@ -0,0 +1,267 @@
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 { SpawnPoint } from '../map/types';
export interface MapEditorLayerCallbacks {
onSelect(id: string | null): 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;
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);
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.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;
}
export class MapEditorLayer {
readonly group = 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 selectedId: string | null = null;
private documentLoaded = false;
constructor(
private readonly scene: THREE.Scene,
private readonly camera: THREE.Camera,
domElement: HTMLElement,
private readonly callbacks: MapEditorLayerCallbacks,
) {
this.group.name = '__platform_map_editor__';
scene.add(this.group);
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;
}
setDocument(document: EditableMapDocument | null): void {
const selected = this.selectedId;
this.clearObjects();
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);
}
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 {
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.helper.visible = editable;
if (notify) this.callbacks.onSelect(this.selectedId);
}
/** 命中编辑对象或操纵器时消费左键;空白区域留给 OrbitControls 旋转视角。 */
handlePointerDown(event: PointerEvent, bounds: DOMRect): 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);
const hit = this.raycaster.intersectObjects([...this.objects.values()], true)[0];
const id = hit?.object.userData.mapEditorObjectId;
if (typeof id !== 'string') return false;
this.selectObject(id, true);
return 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;
const geometries = new Set<THREE.BufferGeometry>(),
materials = new Set<THREE.Material>();
for (const child of this.group.children)
child.traverse((object) => {
if (object instanceof THREE.Mesh) {
geometries.add(object.geometry);
for (const item of Array.isArray(object.material) ? object.material : [object.material])
materials.add(item);
}
});
this.group.clear();
this.objects.clear();
for (const geometry of geometries) geometry.dispose();
for (const item of materials) item.dispose();
}
clear(): void {
this.documentLoaded = false;
this.selectedId = null;
this.clearObjects();
this.transform.enabled = false;
this.callbacks.onDragging(false);
}
dispose(): void {
this.clear();
this.callbacks.onDragging(false);
this.transform.dispose();
this.helper.removeFromParent();
this.group.removeFromParent();
}
}