|
|
@@ -0,0 +1,689 @@
|
|
|
+<template>
|
|
|
+ <!-- 纯逻辑组件,无UI渲染 -->
|
|
|
+ <div class="first-person-controller" v-if="isActive">
|
|
|
+ <!-- 提示信息 overlay - 只在未锁定鼠标时显示 -->
|
|
|
+ <div
|
|
|
+ v-if="showInstructions && !isPointerLocked"
|
|
|
+ class="instructions-overlay"
|
|
|
+ @click="requestPointerLock"
|
|
|
+ >
|
|
|
+ <div class="instructions-content">
|
|
|
+ <h3>第一人称漫游模式</h3>
|
|
|
+ <p><strong>W/A/S/D</strong> 或 <strong>方向键</strong> - 移动</p>
|
|
|
+ <p><strong>鼠标</strong> - 控制视角</p>
|
|
|
+ <p><strong>ESC</strong> - 退出漫游模式</p>
|
|
|
+ <p class="click-hint">点击此处开始漫游</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+
|
|
|
+/**
|
|
|
+ * SuperMap 第一人称控制器组件
|
|
|
+ */
|
|
|
+export default {
|
|
|
+ name: 'SuperMapFirstPerson',
|
|
|
+
|
|
|
+ props: {
|
|
|
+ viewer: {
|
|
|
+ type: Object,
|
|
|
+ default: null
|
|
|
+ },
|
|
|
+ enabled: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ initialPosition: {
|
|
|
+ type: Object,
|
|
|
+ default: null
|
|
|
+ },
|
|
|
+ moveSpeed: {
|
|
|
+ type: Number,
|
|
|
+ default: 5.0
|
|
|
+ },
|
|
|
+ speedMultiplier: {
|
|
|
+ type: Number,
|
|
|
+ default: 2.0
|
|
|
+ },
|
|
|
+ rotationSensitivity: {
|
|
|
+ type: Number,
|
|
|
+ default: 0.002
|
|
|
+ },
|
|
|
+ eyeHeight: {
|
|
|
+ type: Number,
|
|
|
+ default: 1.7
|
|
|
+ },
|
|
|
+ jumpHeight: {
|
|
|
+ type: Number,
|
|
|
+ default: 2.0
|
|
|
+ },
|
|
|
+ collisionDistance: {
|
|
|
+ type: Number,
|
|
|
+ default: 1.0
|
|
|
+ },
|
|
|
+ enableCollision: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ enableGroundFollow: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ showInstructions: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ emits: ['enter', 'exit', 'position-change', 'collision', 'error'],
|
|
|
+
|
|
|
+ setup(props, { emit }) {
|
|
|
+ // 状态
|
|
|
+ const isActive = ref(false)
|
|
|
+ const isPointerLocked = ref(false)
|
|
|
+
|
|
|
+ // 输入状态
|
|
|
+ const keyState = ref({
|
|
|
+ forward: false,
|
|
|
+ backward: false,
|
|
|
+ left: false,
|
|
|
+ right: false,
|
|
|
+ shift: false
|
|
|
+ })
|
|
|
+
|
|
|
+ // 跳跃状态
|
|
|
+ const jumpState = ref({
|
|
|
+ isJumping: false,
|
|
|
+ jumpVelocity: 0,
|
|
|
+ baseHeight: 0
|
|
|
+ })
|
|
|
+
|
|
|
+ // 内部状态
|
|
|
+ let _originalCameraState = null
|
|
|
+ let _animationFrameId = null
|
|
|
+ let _lastUpdateTime = 0
|
|
|
+
|
|
|
+ // 获取 Cesium(延迟获取)
|
|
|
+ const getCesium = () => {
|
|
|
+ const Cesium = window.Cesium
|
|
|
+ if (!Cesium) {
|
|
|
+ console.error('Cesium 未加载')
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ return Cesium
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算属性
|
|
|
+ const actualMoveSpeed = computed(() => {
|
|
|
+ const baseSpeed = props.moveSpeed
|
|
|
+ return keyState.value.shift ? baseSpeed * props.speedMultiplier : baseSpeed
|
|
|
+ })
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 初始化第一人称控制器
|
|
|
+ */
|
|
|
+ const initController = () => {
|
|
|
+ const Cesium = getCesium()
|
|
|
+ if (!Cesium) {
|
|
|
+ emit('error', 'Cesium 未加载')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ const viewer = props.viewer || window.viewer
|
|
|
+ if (!viewer) {
|
|
|
+ emit('error', 'Viewer 实例未传入')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ console.log('🎮 初始化第一人称控制器...')
|
|
|
+
|
|
|
+ // 保存原始相机状态
|
|
|
+ _originalCameraState = {
|
|
|
+ position: viewer.camera.position.clone(),
|
|
|
+ heading: viewer.camera.heading,
|
|
|
+ pitch: viewer.camera.pitch,
|
|
|
+ roll: viewer.camera.roll
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置初始位置
|
|
|
+ if (props.initialPosition) {
|
|
|
+ try {
|
|
|
+ const pos = props.initialPosition
|
|
|
+ const lon = pos.lon || pos.longitude
|
|
|
+ const lat = pos.lat || pos.latitude
|
|
|
+ const height = pos.height || pos.altitude || 35
|
|
|
+ const heading = Cesium.Math.toRadians(pos.heading || 0)
|
|
|
+ const pitch = Cesium.Math.toRadians(pos.pitch || -10)
|
|
|
+
|
|
|
+ const cartesian = Cesium.Cartesian3.fromDegrees(lon, lat, height)
|
|
|
+
|
|
|
+ viewer.camera.setView({
|
|
|
+ destination: cartesian,
|
|
|
+ orientation: {
|
|
|
+ heading: heading,
|
|
|
+ pitch: pitch,
|
|
|
+ roll: 0
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ console.log(`📍 相机已定位到: ${lon}, ${lat}, ${height}`)
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('设置初始位置失败:', e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 禁用默认相机控制
|
|
|
+ viewer.scene.screenSpaceCameraController.enableRotate = false
|
|
|
+ viewer.scene.screenSpaceCameraController.enableTranslate = false
|
|
|
+ viewer.scene.screenSpaceCameraController.enableZoom = false
|
|
|
+ viewer.scene.screenSpaceCameraController.enableTilt = false
|
|
|
+ viewer.scene.screenSpaceCameraController.enableLook = false
|
|
|
+
|
|
|
+ // 注册事件监听
|
|
|
+ registerEventListeners(viewer)
|
|
|
+
|
|
|
+ // 启动更新循环
|
|
|
+ startUpdateLoop(Cesium, viewer)
|
|
|
+
|
|
|
+ isActive.value = true
|
|
|
+ emit('enter')
|
|
|
+
|
|
|
+ console.log('✅ 第一人称控制器初始化完成')
|
|
|
+ return true
|
|
|
+ } catch (error) {
|
|
|
+ console.error('❌ 初始化失败:', error)
|
|
|
+ emit('error', '初始化失败: ' + error.message)
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 注册事件监听器
|
|
|
+ */
|
|
|
+ const registerEventListeners = (viewer) => {
|
|
|
+ document.addEventListener('keydown', onKeyDown)
|
|
|
+ document.addEventListener('keyup', onKeyUp)
|
|
|
+ document.addEventListener('pointerlockchange', onPointerLockChange)
|
|
|
+ document.addEventListener('pointerlockerror', onPointerLockError)
|
|
|
+ document.addEventListener('mousemove', onMouseMove)
|
|
|
+
|
|
|
+ if (viewer && viewer.container) {
|
|
|
+ viewer.container.addEventListener('click', requestPointerLock)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 移除事件监听器
|
|
|
+ */
|
|
|
+ const unregisterEventListeners = (viewer) => {
|
|
|
+ document.removeEventListener('keydown', onKeyDown)
|
|
|
+ document.removeEventListener('keyup', onKeyUp)
|
|
|
+ document.removeEventListener('pointerlockchange', onPointerLockChange)
|
|
|
+ document.removeEventListener('pointerlockerror', onPointerLockError)
|
|
|
+ document.removeEventListener('mousemove', onMouseMove)
|
|
|
+
|
|
|
+ if (viewer && viewer.container) {
|
|
|
+ viewer.container.removeEventListener('click', requestPointerLock)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 键盘按下事件
|
|
|
+ */
|
|
|
+ const onKeyDown = (event) => {
|
|
|
+ if (!isActive.value) return
|
|
|
+
|
|
|
+ const key = event.key.toLowerCase()
|
|
|
+
|
|
|
+ switch (key) {
|
|
|
+ case 'w':
|
|
|
+ case 'arrowup':
|
|
|
+ keyState.value.forward = true
|
|
|
+ break
|
|
|
+ case 's':
|
|
|
+ case 'arrowdown':
|
|
|
+ keyState.value.backward = true
|
|
|
+ break
|
|
|
+ case 'a':
|
|
|
+ case 'arrowleft':
|
|
|
+ keyState.value.left = true
|
|
|
+ break
|
|
|
+ case 'd':
|
|
|
+ case 'arrowright':
|
|
|
+ keyState.value.right = true
|
|
|
+ break
|
|
|
+ case 'shift':
|
|
|
+ keyState.value.shift = true
|
|
|
+ break
|
|
|
+ case 'escape':
|
|
|
+ exitPointerLock()
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 键盘释放事件
|
|
|
+ */
|
|
|
+ const onKeyUp = (event) => {
|
|
|
+ if (!isActive.value) return
|
|
|
+
|
|
|
+ const key = event.key.toLowerCase()
|
|
|
+
|
|
|
+ switch (key) {
|
|
|
+ case 'w':
|
|
|
+ case 'arrowup':
|
|
|
+ keyState.value.forward = false
|
|
|
+ break
|
|
|
+ case 's':
|
|
|
+ case 'arrowdown':
|
|
|
+ keyState.value.backward = false
|
|
|
+ break
|
|
|
+ case 'a':
|
|
|
+ case 'arrowleft':
|
|
|
+ keyState.value.left = false
|
|
|
+ break
|
|
|
+ case 'd':
|
|
|
+ case 'arrowright':
|
|
|
+ keyState.value.right = false
|
|
|
+ break
|
|
|
+ case 'shift':
|
|
|
+ keyState.value.shift = false
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ event.preventDefault()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 鼠标移动事件
|
|
|
+ */
|
|
|
+ const onMouseMove = (event) => {
|
|
|
+ if (!isActive.value || !isPointerLocked.value) return
|
|
|
+
|
|
|
+ const Cesium = getCesium()
|
|
|
+ const viewer = props.viewer || window.viewer
|
|
|
+ if (!Cesium || !viewer) return
|
|
|
+
|
|
|
+ const sensitivity = props.rotationSensitivity
|
|
|
+ const deltaX = event.movementX * sensitivity
|
|
|
+ const deltaY = event.movementY * sensitivity
|
|
|
+
|
|
|
+ // 更新相机朝向
|
|
|
+ let newPitch = viewer.camera.pitch + deltaY
|
|
|
+ newPitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, newPitch))
|
|
|
+
|
|
|
+ viewer.camera.setView({
|
|
|
+ orientation: {
|
|
|
+ heading: viewer.camera.heading + deltaX,
|
|
|
+ pitch: newPitch,
|
|
|
+ roll: 0
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * PointerLock 状态变化
|
|
|
+ */
|
|
|
+ const onPointerLockChange = () => {
|
|
|
+ const viewer = props.viewer || window.viewer
|
|
|
+ const element = document.pointerLockElement
|
|
|
+ isPointerLocked.value = viewer && element === viewer.container
|
|
|
+
|
|
|
+ if (!isPointerLocked.value && isActive.value) {
|
|
|
+ console.log('PointerLock 已退出')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * PointerLock 错误
|
|
|
+ */
|
|
|
+ const onPointerLockError = () => {
|
|
|
+ emit('error', 'PointerLock 获取失败')
|
|
|
+ ElMessage.warning('无法锁定鼠标,请点击画面重试')
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 请求 PointerLock
|
|
|
+ */
|
|
|
+ const requestPointerLock = () => {
|
|
|
+ if (!isActive.value) return
|
|
|
+
|
|
|
+ const viewer = props.viewer || window.viewer
|
|
|
+ console.log('尝试锁定鼠标, viewer:', viewer)
|
|
|
+
|
|
|
+ // 尝试锁定 viewer.container 或整个文档 body
|
|
|
+ let targetElement = null
|
|
|
+
|
|
|
+ if (viewer && viewer.container) {
|
|
|
+ targetElement = viewer.container
|
|
|
+ console.log('使用 viewer.container:', targetElement)
|
|
|
+ } else {
|
|
|
+ // 使用 body 作为备选
|
|
|
+ targetElement = document.body
|
|
|
+ console.log('使用 document.body')
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ targetElement.requestPointerLock()
|
|
|
+ console.log('PointerLock 请求已发送')
|
|
|
+ } catch (error) {
|
|
|
+ console.error('PointerLock 请求失败:', error)
|
|
|
+ ElMessage.warning('无法锁定鼠标,请尝试按 ESC 后重新点击')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 退出 PointerLock
|
|
|
+ */
|
|
|
+ const exitPointerLock = () => {
|
|
|
+ document.exitPointerLock()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新跳跃状态
|
|
|
+ */
|
|
|
+ const updateJump = (Cesium, viewer, deltaTime) => {
|
|
|
+ if (!jumpState.value.isJumping) return
|
|
|
+
|
|
|
+ const gravity = -9.8 * 0.5
|
|
|
+ jumpState.value.jumpVelocity += gravity * deltaTime
|
|
|
+
|
|
|
+ const currentPos = viewer.camera.position
|
|
|
+ const cartographic = Cesium.Cartographic.fromCartesian(currentPos)
|
|
|
+ const lon = Cesium.Math.toDegrees(cartographic.longitude)
|
|
|
+ const lat = Cesium.Math.toDegrees(cartographic.latitude)
|
|
|
+
|
|
|
+ let newHeight = cartographic.height + jumpState.value.jumpVelocity * deltaTime
|
|
|
+ const groundHeight = jumpState.value.baseHeight
|
|
|
+
|
|
|
+ if (newHeight <= groundHeight + props.eyeHeight) {
|
|
|
+ newHeight = groundHeight + props.eyeHeight
|
|
|
+ jumpState.value.isJumping = false
|
|
|
+ jumpState.value.jumpVelocity = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ const newCartesian = Cesium.Cartesian3.fromDegrees(lon, lat, newHeight)
|
|
|
+ viewer.camera.setView({
|
|
|
+ destination: newCartesian,
|
|
|
+ orientation: {
|
|
|
+ heading: viewer.camera.heading,
|
|
|
+ pitch: viewer.camera.pitch,
|
|
|
+ roll: 0
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算移动方向
|
|
|
+ * Cesium 坐标系: heading=0 指北(Y正), π/2 指东(X正)
|
|
|
+ */
|
|
|
+ const calculateMoveDirection = (Cesium, viewer) => {
|
|
|
+ const keys = keyState.value
|
|
|
+
|
|
|
+ if (!keys.forward && !keys.backward && !keys.left && !keys.right) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ const heading = viewer.camera.heading
|
|
|
+
|
|
|
+ // 前方向(相机朝向)
|
|
|
+ // heading=0(北): forward=(0,1,0)
|
|
|
+ // heading=π/2(东): forward=(1,0,0)
|
|
|
+ const forward = new Cesium.Cartesian3(
|
|
|
+ Math.sin(heading), // X分量
|
|
|
+ Math.cos(heading), // Y分量
|
|
|
+ 0
|
|
|
+ )
|
|
|
+ Cesium.Cartesian3.normalize(forward, forward)
|
|
|
+
|
|
|
+ // 右方向(垂直于前方向,指向右侧)
|
|
|
+ // heading=0(北): right=(1,0,0) 东
|
|
|
+ // heading=π/2(东): right=(0,-1,0) 南
|
|
|
+ const right = new Cesium.Cartesian3(
|
|
|
+ Math.cos(heading), // X分量
|
|
|
+ -Math.sin(heading), // Y分量
|
|
|
+ 0
|
|
|
+ )
|
|
|
+ Cesium.Cartesian3.normalize(right, right)
|
|
|
+
|
|
|
+ // 计算移动方向
|
|
|
+ const moveDirection = new Cesium.Cartesian3(0, 0, 0)
|
|
|
+
|
|
|
+ if (keys.forward) {
|
|
|
+ Cesium.Cartesian3.add(moveDirection, forward, moveDirection)
|
|
|
+ }
|
|
|
+ if (keys.backward) {
|
|
|
+ Cesium.Cartesian3.subtract(moveDirection, forward, moveDirection)
|
|
|
+ }
|
|
|
+ if (keys.right) {
|
|
|
+ Cesium.Cartesian3.add(moveDirection, right, moveDirection)
|
|
|
+ }
|
|
|
+ if (keys.left) {
|
|
|
+ Cesium.Cartesian3.subtract(moveDirection, right, moveDirection)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Cesium.Cartesian3.magnitude(moveDirection) > 0) {
|
|
|
+ Cesium.Cartesian3.normalize(moveDirection, moveDirection)
|
|
|
+ return moveDirection
|
|
|
+ }
|
|
|
+
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新相机位置
|
|
|
+ * 使用经纬度计算移动,避免 Cartesian3 直接相加的问题
|
|
|
+ */
|
|
|
+ const updateCameraPosition = (Cesium, viewer, deltaTime) => {
|
|
|
+ const moveDirection = calculateMoveDirection(Cesium, viewer)
|
|
|
+ if (!moveDirection) return
|
|
|
+
|
|
|
+ const moveDistance = actualMoveSpeed.value * deltaTime
|
|
|
+
|
|
|
+ // 获取当前位置
|
|
|
+ const currentPos = viewer.camera.position
|
|
|
+ const currentCartographic = Cesium.Cartographic.fromCartesian(currentPos)
|
|
|
+ const lon = Cesium.Math.toDegrees(currentCartographic.longitude)
|
|
|
+ const lat = Cesium.Math.toDegrees(currentCartographic.latitude)
|
|
|
+ const height = currentCartographic.height
|
|
|
+
|
|
|
+ // 将移动方向转换为经纬度增量
|
|
|
+ // moveDirection 是单位向量,需要转换为米/度
|
|
|
+ // 1度经度 ≈ 111km * cos(lat),1度纬度 ≈ 111km
|
|
|
+ const metersPerDegreeLat = 111000 // 纬度每度约111km
|
|
|
+ const metersPerDegreeLon = 111000 * Math.cos(Cesium.Math.toRadians(lat)) // 经度每度随纬度变化
|
|
|
+
|
|
|
+ // moveDirection.x 对应经度变化(东/西)
|
|
|
+ // moveDirection.y 对应纬度变化(北/南)
|
|
|
+ const deltaLon = (moveDirection.x * moveDistance) / metersPerDegreeLon
|
|
|
+ const deltaLat = (moveDirection.y * moveDistance) / metersPerDegreeLat
|
|
|
+
|
|
|
+ // 计算新位置
|
|
|
+ const newLon = lon + deltaLon
|
|
|
+ const newLat = lat + deltaLat
|
|
|
+
|
|
|
+ // 更新相机位置
|
|
|
+ const finalPosition = Cesium.Cartesian3.fromDegrees(newLon, newLat, height)
|
|
|
+
|
|
|
+ viewer.camera.setView({
|
|
|
+ destination: finalPosition,
|
|
|
+ orientation: {
|
|
|
+ heading: viewer.camera.heading,
|
|
|
+ pitch: viewer.camera.pitch,
|
|
|
+ roll: 0
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 发射位置变化事件
|
|
|
+ emit('position-change', {
|
|
|
+ lon: newLon,
|
|
|
+ lat: newLat,
|
|
|
+ height: height
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启动更新循环
|
|
|
+ */
|
|
|
+ const startUpdateLoop = (Cesium, viewer) => {
|
|
|
+ _lastUpdateTime = performance.now()
|
|
|
+
|
|
|
+ const update = () => {
|
|
|
+ if (!isActive.value) return
|
|
|
+
|
|
|
+ const currentTime = performance.now()
|
|
|
+ const deltaTime = (currentTime - _lastUpdateTime) / 1000
|
|
|
+ _lastUpdateTime = currentTime
|
|
|
+
|
|
|
+ // 更新位置
|
|
|
+ updateCameraPosition(Cesium, viewer, deltaTime)
|
|
|
+
|
|
|
+ // 触发渲染
|
|
|
+ viewer.scene.requestRender()
|
|
|
+
|
|
|
+ _animationFrameId = requestAnimationFrame(update)
|
|
|
+ }
|
|
|
+
|
|
|
+ _animationFrameId = requestAnimationFrame(update)
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止更新循环
|
|
|
+ */
|
|
|
+ const stopUpdateLoop = () => {
|
|
|
+ if (_animationFrameId) {
|
|
|
+ cancelAnimationFrame(_animationFrameId)
|
|
|
+ _animationFrameId = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 销毁控制器
|
|
|
+ */
|
|
|
+ const destroyController = () => {
|
|
|
+ console.log('🗑️ 销毁第一人称控制器...')
|
|
|
+
|
|
|
+ stopUpdateLoop()
|
|
|
+ exitPointerLock()
|
|
|
+
|
|
|
+ const viewer = props.viewer || window.viewer
|
|
|
+ if (viewer) {
|
|
|
+ unregisterEventListeners(viewer)
|
|
|
+
|
|
|
+ // 恢复默认相机控制
|
|
|
+ viewer.scene.screenSpaceCameraController.enableRotate = true
|
|
|
+ viewer.scene.screenSpaceCameraController.enableTranslate = true
|
|
|
+ viewer.scene.screenSpaceCameraController.enableZoom = true
|
|
|
+ viewer.scene.screenSpaceCameraController.enableTilt = true
|
|
|
+ viewer.scene.screenSpaceCameraController.enableLook = true
|
|
|
+
|
|
|
+ // 恢复原始相机状态
|
|
|
+ if (_originalCameraState) {
|
|
|
+ try {
|
|
|
+ viewer.camera.setView({
|
|
|
+ destination: _originalCameraState.position,
|
|
|
+ orientation: {
|
|
|
+ heading: _originalCameraState.heading,
|
|
|
+ pitch: _originalCameraState.pitch,
|
|
|
+ roll: _originalCameraState.roll
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('恢复相机状态失败:', e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ isActive.value = false
|
|
|
+ emit('exit')
|
|
|
+
|
|
|
+ console.log('✅ 第一人称控制器已销毁')
|
|
|
+ }
|
|
|
+
|
|
|
+ // 监听 enabled 属性变化
|
|
|
+ watch(() => props.enabled, (newVal) => {
|
|
|
+ if (newVal && !isActive.value) {
|
|
|
+ nextTick(() => {
|
|
|
+ initController()
|
|
|
+ })
|
|
|
+ } else if (!newVal && isActive.value) {
|
|
|
+ destroyController()
|
|
|
+ }
|
|
|
+ }, { immediate: true })
|
|
|
+
|
|
|
+ // 组件卸载
|
|
|
+ onUnmounted(() => {
|
|
|
+ if (isActive.value) {
|
|
|
+ destroyController()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ isActive,
|
|
|
+ isPointerLocked,
|
|
|
+ requestPointerLock,
|
|
|
+ exitPointerLock
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.first-person-controller {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ pointer-events: none;
|
|
|
+ z-index: 1000;
|
|
|
+}
|
|
|
+
|
|
|
+.instructions-overlay {
|
|
|
+ position: fixed;
|
|
|
+ top: 50%;
|
|
|
+ left: 50%;
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
+ background: rgba(0, 0, 0, 0.85);
|
|
|
+ color: #fff;
|
|
|
+ padding: 30px 40px;
|
|
|
+ border-radius: 12px;
|
|
|
+ text-align: center;
|
|
|
+ pointer-events: auto;
|
|
|
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
|
+ max-width: 400px;
|
|
|
+}
|
|
|
+
|
|
|
+.instructions-content h3 {
|
|
|
+ margin: 0 0 20px 0;
|
|
|
+ font-size: 22px;
|
|
|
+ color: #4fc3f7;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.instructions-content p {
|
|
|
+ margin: 10px 0;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #e0e0e0;
|
|
|
+}
|
|
|
+
|
|
|
+.instructions-content p strong {
|
|
|
+ color: #fff;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.click-hint {
|
|
|
+ margin-top: 25px;
|
|
|
+ padding-top: 15px;
|
|
|
+ border-top: 1px solid rgba(255, 255, 255, 0.2);
|
|
|
+ color: #ffd54f;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+</style>
|