Vue 3 与 Device Orientation API

1. 概述

Device Orientation API 是一个现代浏览器 API,用于获取设备的物理方向和运动状态信息。它允许 Web 应用访问设备的陀螺仪和加速计数据,从而实现各种基于设备姿态的交互效果和功能。

在 Vue 3 应用中,Device Orientation API 可以用于:

  • 实现基于设备倾斜的游戏控制
  • 创建增强现实 (AR) 体验
  • 开发响应式设计,根据设备方向调整布局
  • 实现基于设备运动的交互效果
  • 开发导航应用,如指南针或地图
  • 实现设备姿态检测和监控

2. 核心知识

2.1 Device Orientation API 基础

Device Orientation API 提供了两个主要的事件:

  1. deviceorientation 事件:提供设备相对于地球坐标系的物理方向信息,包括:

    • alpha:设备绕 z 轴旋转的角度(0°-360°)
    • beta:设备绕 x 轴旋转的角度(-180°-180°)
    • gamma:设备绕 y 轴旋转的角度(-90°-90°)
    • absolute:布尔值,表示设备方向是否是绝对的(相对于地球坐标系)
  2. devicemotion 事件:提供设备的运动状态信息,包括:

    • acceleration:设备在三个轴上的加速度(不包括重力)
    • accelerationIncludingGravity:设备在三个轴上的加速度(包括重力)
    • rotationRate:设备在三个轴上的旋转速率
    • interval:事件触发的时间间隔(毫秒)

2.2 坐标系说明

Device Orientation API 使用右手坐标系:

  • x 轴:从设备左侧指向右侧
  • y 轴:从设备底部指向顶部
  • z 轴:从设备背面指向正面

2.3 获取设备方向数据

// 监听设备方向变化
window.addEventListener('deviceorientation', (event) => {
  console.log('alpha:', event.alpha); // 绕 z 轴旋转角度
  console.log('beta:', event.beta);   // 绕 x 轴旋转角度
  console.log('gamma:', event.gamma); // 绕 y 轴旋转角度
  console.log('absolute:', event.absolute); // 是否是绝对方向
});

// 监听设备运动变化
window.addEventListener('devicemotion', (event) => {
  console.log('加速度(不含重力):', event.acceleration);
  console.log('加速度(含重力):', event.accelerationIncludingGravity);
  console.log('旋转速率:', event.rotationRate);
  console.log('事件间隔:', event.interval, 'ms');
});

2.4 请求设备方向权限

在某些浏览器和设备上,需要请求用户权限才能访问设备方向数据:

// 请求设备方向权限(如果需要)
if (DeviceOrientationEvent && DeviceOrientationEvent.requestPermission) {
  DeviceOrientationEvent.requestPermission()
    .then(permissionState => {
      if (permissionState === 'granted') {
        // 权限已授予,可以监听设备方向事件
        window.addEventListener('deviceorientation', handleOrientation);
      } else {
        // 权限被拒绝
        console.log('设备方向权限被拒绝');
      }
    })
    .catch(error => {
      console.error('请求设备方向权限失败:', error);
    });
} else {
  // 浏览器不支持权限请求,直接监听事件
  window.addEventListener('deviceorientation', handleOrientation);
}

3. Vue 3 与 Device Orientation API 集成

3.1 基础使用示例

<template>
  <div>
    <h2>Device Orientation API 示例</h2>
    
    <!-- 权限请求按钮 -->
    <div v-if="!permissionGranted && showPermissionButton" class="permission-section">
      <button @click="requestPermission" class="permission-btn">
        请求设备方向权限
      </button>
      <p class="permission-hint">
        需要您的权限才能访问设备方向数据
      </p>
    </div>
    
    <!-- 设备方向数据展示 -->
    <div v-if="permissionGranted || !showPermissionButton" class="orientation-container">
      <h3>当前设备方向</h3>
      <div class="orientation-data">
        <div class="data-item">
          <span class="label">Alpha (Z 轴旋转):</span>
          <span class="value">{{ orientationData.alpha.toFixed(2) }}°</span>
        </div>
        <div class="data-item">
          <span class="label">Beta (X 轴旋转):</span>
          <span class="value">{{ orientationData.beta.toFixed(2) }}°</span>
        </div>
        <div class="data-item">
          <span class="label">Gamma (Y 轴旋转):</span>
          <span class="value">{{ orientationData.gamma.toFixed(2) }}°</span>
        </div>
        <div class="data-item">
          <span class="label">绝对方向:</span>
          <span class="value">{{ orientationData.absolute ? '是' : '否' }}</span>
        </div>
      </div>
      
      <!-- 设备方向可视化 -->
      <div class="orientation-visualization">
        <h3>设备方向可视化</h3>
        <div class="device-model" :style="deviceStyle">
          <div class="device-face">
            <div class="device-screen">
              <div class="screen-content">
                <div class="arrow" :style="arrowStyle"></div>
              </div>
            </div>
          </div>
        </div>
      </div>
      
      <!-- 设备运动数据 -->
      <div class="motion-data">
        <h3>设备运动数据</h3>
        <div class="data-item">
          <span class="label">X 轴重力加速度:</span>
          <span class="value">{{ motionData.accelerationIncludingGravity.x.toFixed(2) }} m/s²</span>
        </div>
        <div class="data-item">
          <span class="label">Y 轴重力加速度:</span>
          <span class="value">{{ motionData.accelerationIncludingGravity.y.toFixed(2) }} m/s²</span>
        </div>
        <div class="data-item">
          <span class="label">Z 轴重力加速度:</span>
          <span class="value">{{ motionData.accelerationIncludingGravity.z.toFixed(2) }} m/s²</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';

// 权限状态
const permissionGranted = ref(false);
const showPermissionButton = ref(false);

// 设备方向数据
const orientationData = ref({
  alpha: 0,
  beta: 0,
  gamma: 0,
  absolute: false
});

// 设备运动数据
const motionData = ref({
  accelerationIncludingGravity: {
    x: 0,
    y: 0,
    z: 0
  }
});

// 处理设备方向事件
const handleOrientation = (event) => {
  orientationData.value = {
    alpha: event.alpha || 0,
    beta: event.beta || 0,
    gamma: event.gamma || 0,
    absolute: event.absolute || false
  };
};

// 处理设备运动事件
const handleMotion = (event) => {
  motionData.value = {
    accelerationIncludingGravity: event.accelerationIncludingGravity || {
      x: 0,
      y: 0,
      z: 0
    }
  };
};

// 请求权限
const requestPermission = () => {
  if (DeviceOrientationEvent && DeviceOrientationEvent.requestPermission) {
    DeviceOrientationEvent.requestPermission()
      .then(permissionState => {
        if (permissionState === 'granted') {
          permissionGranted.value = true;
          showPermissionButton.value = false;
          // 开始监听事件
          window.addEventListener('deviceorientation', handleOrientation);
          window.addEventListener('devicemotion', handleMotion);
        } else {
          permissionGranted.value = false;
          alert('需要您的权限才能访问设备方向数据');
        }
      })
      .catch(error => {
        console.error('请求权限失败:', error);
      });
  }
};

// 计算设备模型的旋转样式
const deviceStyle = computed(() => {
  return {
    transform: `rotateZ(${orientationData.value.alpha}deg) 
               rotateX(${orientationData.value.beta}deg) 
               rotateY(${-orientationData.value.gamma}deg)`
  };
});

// 计算箭头样式
const arrowStyle = computed(() => {
  return {
    transform: `rotateZ(${-orientationData.value.alpha}deg)`
  };
});

// 组件挂载时初始化
onMounted(() => {
  // 检查是否需要权限
  if (DeviceOrientationEvent && DeviceOrientationEvent.requestPermission) {
    showPermissionButton.value = true;
  } else {
    // 直接监听事件
    window.addEventListener('deviceorientation', handleOrientation);
    window.addEventListener('devicemotion', handleMotion);
    permissionGranted.value = true;
  }
});

// 组件卸载时清理
onUnmounted(() => {
  // 移除事件监听器
  window.removeEventListener('deviceorientation', handleOrientation);
  window.removeEventListener('devicemotion', handleMotion);
});
</script>

<style scoped>
.permission-section {
  margin: 20px 0;
  text-align: center;
  padding: 20px;
  background-color: #fff3cd;
  border: 1px solid #ffeeba;
  border-radius: 8px;
}

.permission-btn {
  padding: 12px 24px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.3s ease;
}

.permission-btn:hover {
  background-color: #35495e;
}

.permission-hint {
  margin-top: 10px;
  color: #856404;
  font-size: 14px;
}

.orientation-container {
  margin: 20px 0;
}

.orientation-data, .motion-data {
  margin: 20px 0;
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 8px;
}

.data-item {
  display: flex;
  justify-content: space-between;
  margin: 10px 0;
  padding: 8px 0;
  border-bottom: 1px solid #dee2e6;
}

.data-item:last-child {
  border-bottom: none;
}

.label {
  font-weight: 500;
  color: #6c757d;
}

.value {
  font-weight: bold;
  color: #495057;
}

.orientation-visualization {
  margin: 30px 0;
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 8px;
  text-align: center;
}

.device-model {
  width: 200px;
  height: 300px;
  margin: 20px auto;
  position: relative;
  transition: transform 0.1s ease;
  perspective: 1000px;
}

.device-face {
  width: 100%;
  height: 100%;
  background-color: #333;
  border-radius: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
}

.device-screen {
  width: 90%;
  height: 90%;
  background-color: white;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}

.screen-content {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: radial-gradient(circle at center, #e3f2fd 0%, #bbdefb 100%);
}

.arrow {
  width: 0;
  height: 0;
  border-left: 20px solid transparent;
  border-right: 20px solid transparent;
  border-bottom: 40px solid #2196f3;
  position: relative;
  transform-origin: center;
  transition: transform 0.1s ease;
}

.arrow::after {
  content: '';
  position: absolute;
  top: 40px;
  left: -5px;
  width: 0;
  height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 10px solid #2196f3;
}
</style>

3.2 创建可复用的 Device Orientation 组合式函数

// src/composables/useDeviceOrientation.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useDeviceOrientation(options = {}) {
  // 配置选项
  const { 
    requestPermission = true,
    listenToMotion = true 
  } = options;

  // 状态
  const permissionState = ref('unknown'); // 'unknown', 'granted', 'denied', 'error'
  const orientationData = ref({
    alpha: 0,
    beta: 0,
    gamma: 0,
    absolute: false
  });
  const motionData = ref({
    acceleration: null,
    accelerationIncludingGravity: {
      x: 0,
      y: 0,
      z: 0
    },
    rotationRate: null,
    interval: 0
  });

  // 处理设备方向事件
  const handleOrientation = (event) => {
    orientationData.value = {
      alpha: event.alpha || 0,
      beta: event.beta || 0,
      gamma: event.gamma || 0,
      absolute: event.absolute || false
    };
  };

  // 处理设备运动事件
  const handleMotion = (event) => {
    motionData.value = {
      acceleration: event.acceleration,
      accelerationIncludingGravity: event.accelerationIncludingGravity || {
        x: 0,
        y: 0,
        z: 0
      },
      rotationRate: event.rotationRate,
      interval: event.interval || 0
    };
  };

  // 请求权限
  const requestOrientationPermission = async () => {
    if (!DeviceOrientationEvent || !DeviceOrientationEvent.requestPermission) {
      permissionState.value = 'granted';
      return 'granted';
    }

    try {
      const permission = await DeviceOrientationEvent.requestPermission();
      permissionState.value = permission;
      return permission;
    } catch (error) {
      console.error('请求设备方向权限失败:', error);
      permissionState.value = 'error';
      return 'error';
    }
  };

  // 开始监听
  const startListening = () => {
    window.addEventListener('deviceorientation', handleOrientation);
    if (listenToMotion) {
      window.addEventListener('devicemotion', handleMotion);
    }
  };

  // 停止监听
  const stopListening = () => {
    window.removeEventListener('deviceorientation', handleOrientation);
    window.removeEventListener('devicemotion', handleMotion);
  };

  // 组件挂载时初始化
  onMounted(async () => {
    if (requestPermission) {
      const permission = await requestOrientationPermission();
      if (permission === 'granted') {
        startListening();
      }
    } else {
      startListening();
    }
  });

  // 组件卸载时清理
  onUnmounted(() => {
    stopListening();
  });

  return {
    permissionState,
    orientationData,
    motionData,
    requestOrientationPermission,
    startListening,
    stopListening
  };
}

3.3 使用组合式函数的示例

<template>
  <div>
    <h2>使用 useDeviceOrientation 组合式函数</h2>
    
    <!-- 权限状态显示 -->
    <div class="permission-status">
      <h3>权限状态</h3>
      <div class="status-badge" :class="permissionState">
        {{ permissionText }}
      </div>
      <button 
        v-if="permissionState === 'denied' || permissionState === 'unknown'"
        @click="requestPermission"
        class="request-btn"
      >
        {{ permissionState === 'denied' ? '重新请求权限' : '请求权限' }}
      </button>
    </div>
    
    <!-- 设备方向可视化 -->
    <div v-if="permissionState === 'granted'" class="visualization-section">
      <h3>设备方向可视化</h3>
      
      <!-- 3D 立方体可视化 -->
      <div class="cube-container">
        <div class="cube" :style="cubeStyle">
          <div class="cube-face front">前</div>
          <div class="cube-face back">后</div>
          <div class="cube-face right">右</div>
          <div class="cube-face left">左</div>
          <div class="cube-face top">上</div>
          <div class="cube-face bottom">下</div>
        </div>
      </div>
      
      <!-- 数据仪表盘 -->
      <div class="dashboard">
        <div class="dashboard-item">
          <div class="gauge-container">
            <div class="gauge-title">Alpha (Z 轴)</div>
            <div class="gauge">
              <div class="gauge-arc" :style="alphaGaugeStyle"></div>
              <div class="gauge-value">{{ orientationData.alpha.toFixed(1) }}°</div>
            </div>
          </div>
        </div>
        <div class="dashboard-item">
          <div class="gauge-container">
            <div class="gauge-title">Beta (X 轴)</div>
            <div class="gauge">
              <div class="gauge-arc" :style="betaGaugeStyle"></div>
              <div class="gauge-value">{{ orientationData.beta.toFixed(1) }}°</div>
            </div>
          </div>
        </div>
        <div class="dashboard-item">
          <div class="gauge-container">
            <div class="gauge-title">Gamma (Y 轴)</div>
            <div class="gauge">
              <div class="gauge-arc" :style="gammaGaugeStyle"></div>
              <div class="gauge-value">{{ orientationData.gamma.toFixed(1) }}°</div>
            </div>
          </div>
        </div>
      </div>
      
      <!-- 运动数据 -->
      <div class="motion-section">
        <h3>运动数据</h3>
        <div class="motion-grid">
          <div class="motion-item">
            <span class="motion-label">X 轴重力:</span>
            <span class="motion-value">{{ motionData.accelerationIncludingGravity.x.toFixed(2) }} m/s²</span>
          </div>
          <div class="motion-item">
            <span class="motion-label">Y 轴重力:</span>
            <span class="motion-value">{{ motionData.accelerationIncludingGravity.y.toFixed(2) }} m/s²</span>
          </div>
          <div class="motion-item">
            <span class="motion-label">Z 轴重力:</span>
            <span class="motion-value">{{ motionData.accelerationIncludingGravity.z.toFixed(2) }} m/s²</span>
          </div>
        </div>
      </div>
      
      <!-- 监听控制 -->
      <div class="controls">
        <button @click="toggleListening" class="control-btn">
          {{ isListening ? '停止监听' : '开始监听' }}
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { useDeviceOrientation } from './composables/useDeviceOrientation';

// 使用设备方向组合式函数
const {
  permissionState,
  orientationData,
  motionData,
  requestOrientationPermission,
  startListening,
  stopListening
} = useDeviceOrientation({
  requestPermission: true,
  listenToMotion: true
});

// 监听状态
const isListening = ref(true);

// 权限状态文本
const permissionText = computed(() => {
  switch (permissionState.value) {
    case 'granted': return '权限已授予';
    case 'denied': return '权限被拒绝';
    case 'error': return '请求出错';
    default: return '未知状态';
  }
});

// 3D 立方体样式
const cubeStyle = computed(() => {
  return {
    transform: `
      rotateX(${orientationData.value.beta}deg) 
      rotateY(${-orientationData.value.gamma}deg) 
      rotateZ(${orientationData.value.alpha}deg)
    `
  };
});

// 仪表盘样式
const alphaGaugeStyle = computed(() => {
  const percentage = ((orientationData.value.alpha % 360) + 360) % 360 / 360;
  return {
    transform: `rotate(${percentage * 360}deg)`
  };
});

const betaGaugeStyle = computed(() => {
  const percentage = (orientationData.value.beta + 180) / 360;
  return {
    transform: `rotate(${percentage * 360}deg)`
  };
});

const gammaGaugeStyle = computed(() => {
  const percentage = (orientationData.value.gamma + 90) / 180;
  return {
    transform: `rotate(${percentage * 360}deg)`
  };
});

// 请求权限
const requestPermission = async () => {
  await requestOrientationPermission();
};

// 切换监听状态
const toggleListening = () => {
  if (isListening.value) {
    stopListening();
  } else {
    startListening();
  }
  isListening.value = !isListening.value;
};
</script>

<style scoped>
.permission-status {
  margin: 20px 0;
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 8px;
}

.status-badge {
  display: inline-block;
  padding: 8px 16px;
  border-radius: 20px;
  font-weight: bold;
  margin: 10px 0;
}

.status-badge.granted {
  background-color: #d4edda;
  color: #155724;
}

.status-badge.denied {
  background-color: #f8d7da;
  color: #721c24;
}

.status-badge.unknown {
  background-color: #d1ecf1;
  color: #0c5460;
}

.status-badge.error {
  background-color: #fff3cd;
  color: #856404;
}

.request-btn {
  margin-top: 10px;
  padding: 8px 16px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.request-btn:hover {
  background-color: #35495e;
}

.visualization-section {
  margin: 20px 0;
}

.cube-container {
  width: 300px;
  height: 300px;
  margin: 30px auto;
  perspective: 1000px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 8px;
}

.cube {
  width: 150px;
  height: 150px;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.1s ease;
}

.cube-face {
  position: absolute;
  width: 150px;
  height: 150px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  font-size: 24px;
  border: 2px solid #333;
  opacity: 0.8;
}

.front {
  background-color: rgba(255, 0, 0, 0.6);
  transform: translateZ(75px);
}

.back {
  background-color: rgba(0, 255, 0, 0.6);
  transform: rotateY(180deg) translateZ(75px);
}

.right {
  background-color: rgba(0, 0, 255, 0.6);
  transform: rotateY(90deg) translateZ(75px);
}

.left {
  background-color: rgba(255, 255, 0, 0.6);
  transform: rotateY(-90deg) translateZ(75px);
}

.top {
  background-color: rgba(255, 0, 255, 0.6);
  transform: rotateX(90deg) translateZ(75px);
}

.bottom {
  background-color: rgba(0, 255, 255, 0.6);
  transform: rotateX(-90deg) translateZ(75px);
}

.dashboard {
  margin: 30px 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 20px;
}

.dashboard-item {
  background-color: #f8f9fa;
  padding: 20px;
  border-radius: 8px;
  border: 1px solid #e9ecef;
  text-align: center;
}

.gauge-container {
  margin: 0 auto;
}

.gauge-title {
  font-weight: bold;
  margin-bottom: 10px;
  color: #6c757d;
}

.gauge {
  width: 150px;
  height: 150px;
  margin: 0 auto;
  position: relative;
}

.gauge-arc {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: conic-gradient(#42b883 0deg, #e9ecef 0deg);
  position: relative;
}

.gauge-arc::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 80%;
  height: 80%;
  background-color: white;
  border-radius: 50%;
}

.gauge-value {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-weight: bold;
  font-size: 18px;
  color: #495057;
  z-index: 1;
}

.motion-section {
  margin: 30px 0;
  padding: 20px;
  background-color: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 8px;
}

.motion-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 15px;
  margin-top: 20px;
}

.motion-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px;
  background-color: white;
  border-radius: 4px;
  border: 1px solid #dee2e6;
}

.motion-label {
  color: #6c757d;
  font-weight: 500;
}

.motion-value {
  font-weight: bold;
  color: #495057;
}

.controls {
  margin: 20px 0;
  text-align: center;
}

.control-btn {
  padding: 10px 20px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.control-btn:hover {
  background-color: #35495e;
}
</style>

3.4 高级应用:倾斜控制游戏

<template>
  <div class="game-container">
    <h2>倾斜控制游戏</h2>
    
    <!-- 游戏说明 -->
    <div class="game-info">
      <p>通过倾斜设备来控制小球移动,收集所有绿色方块!</p>
      <p class="score">分数: {{ score }}</p>
      <p class="level">关卡: {{ level }}</p>
    </div>
    
    <!-- 游戏区域 -->
    <div class="game-area" ref="gameArea">
      <!-- 小球 -->
      <div class="ball" :style="ballStyle"></div>
      
      <!-- 目标方块 -->
      <div 
        v-for="(target, index) in targets" 
        :key="index"
        class="target"
        :class="{ collected: target.collected }"
        :style="getTargetStyle(target)"
      ></div>
    </div>
    
    <!-- 游戏控制 -->
    <div class="game-controls">
      <button @click="startGame" class="control-btn">开始游戏</button>
      <button @click="resetGame" class="control-btn">重置游戏</button>
    </div>
    
    <!-- 游戏结束提示 -->
    <div v-if="gameOver" class="game-over">
      <h3>游戏结束!</h3>
      <p>最终分数: {{ score }}</p>
      <button @click="resetGame" class="restart-btn">重新开始</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useDeviceOrientation } from './composables/useDeviceOrientation';

// 使用设备方向组合式函数
const {
  permissionState,
  orientationData
} = useDeviceOrientation({
  requestPermission: true,
  listenToMotion: false
});

// 游戏状态
const gameArea = ref(null);
const gameStarted = ref(false);
const gameOver = ref(false);
const score = ref(0);
const level = ref(1);
const gameAreaSize = ref({ width: 400, height: 400 });

// 小球位置
const ballPosition = ref({ x: 200, y: 200 });
const ballSpeed = ref(5);

// 目标方块
const targets = ref([]);

// 生成目标方块
const generateTargets = () => {
  const newTargets = [];
  const targetCount = 5 + level.value * 2;
  
  for (let i = 0; i < targetCount; i++) {
    newTargets.push({
      x: Math.random() * (gameAreaSize.value.width - 30) + 15,
      y: Math.random() * (gameAreaSize.value.height - 30) + 15,
      collected: false
    });
  }
  
  targets.value = newTargets;
};

// 小球样式
const ballStyle = computed(() => {
  return {
    left: `${ballPosition.value.x}px`,
    top: `${ballPosition.value.y}px`
  };
});

// 目标方块样式
const getTargetStyle = (target) => {
  return {
    left: `${target.x}px`,
    top: `${target.y}px`
  };
};

// 更新小球位置
const updateBallPosition = () => {
  if (!gameStarted.value || gameOver.value) return;
  
  // 根据设备方向计算小球移动
  const deltaX = orientationData.value.gamma * ballSpeed.value * 0.5;
  const deltaY = orientationData.value.beta * ballSpeed.value * 0.5;
  
  // 更新位置
  ballPosition.value.x += deltaX;
  ballPosition.value.y += deltaY;
  
  // 边界检测
  ballPosition.value.x = Math.max(15, Math.min(gameAreaSize.value.width - 15, ballPosition.value.x));
  ballPosition.value.y = Math.max(15, Math.min(gameAreaSize.value.height - 15, ballPosition.value.y));
  
  // 检测碰撞
  checkCollisions();
  
  // 检测游戏结束
  checkGameOver();
};

// 检测碰撞
const checkCollisions = () => {
  targets.value.forEach(target => {
    if (!target.collected) {
      const distance = Math.sqrt(
        Math.pow(ballPosition.value.x - target.x, 2) + 
        Math.pow(ballPosition.value.y - target.y, 2)
      );
      
      if (distance < 25) {
        target.collected = true;
        score.value += 10;
      }
    }
  });
};

// 检测游戏结束
const checkGameOver = () => {
  const allCollected = targets.value.every(target => target.collected);
  if (allCollected) {
    gameOver.value = true;
    gameStarted.value = false;
  }
};

// 开始游戏
const startGame = () => {
  if (permissionState.value !== 'granted') {
    alert('需要设备方向权限才能玩游戏');
    return;
  }
  
  gameStarted.value = true;
  gameOver.value = false;
  ballPosition.value = { x: 200, y: 200 };
  generateTargets();
};

// 重置游戏
const resetGame = () => {
  score.value = 0;
  level.value = 1;
  gameStarted.value = false;
  gameOver.value = false;
  ballPosition.value = { x: 200, y: 200 };
  targets.value = [];
};

// 游戏循环
let gameLoop = null;

// 开始游戏循环
const startGameLoop = () => {
  gameLoop = setInterval(updateBallPosition, 16); // ~60 FPS
};

// 停止游戏循环
const stopGameLoop = () => {
  if (gameLoop) {
    clearInterval(gameLoop);
    gameLoop = null;
  }
};

// 监听游戏区域大小
onMounted(() => {
  if (gameArea.value) {
    gameAreaSize.value = {
      width: gameArea.value.clientWidth,
      height: gameArea.value.clientHeight
    };
  }
  
  // 开始游戏循环
  startGameLoop();
  
  // 监听窗口大小变化
  window.addEventListener('resize', () => {
    if (gameArea.value) {
      gameAreaSize.value = {
        width: gameArea.value.clientWidth,
        height: gameArea.value.clientHeight
      };
    }
  });
});

// 组件卸载时清理
onUnmounted(() => {
  stopGameLoop();
  window.removeEventListener('resize', () => {});
});
</script>

<style scoped>
.game-container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background-color: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #e9ecef;
}

.game-info {
  margin: 20px 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 18px;
}

.score, .level {
  font-weight: bold;
  color: #42b883;
}

.game-area {
  width: 100%;
  height: 400px;
  background-color: #e3f2fd;
  border: 2px solid #bbdefb;
  border-radius: 8px;
  position: relative;
  overflow: hidden;
  margin: 20px 0;
}

.ball {
  width: 30px;
  height: 30px;
  background-color: #f44336;
  border-radius: 50%;
  position: absolute;
  transform: translate(-50%, -50%);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

.target {
  width: 30px;
  height: 30px;
  background-color: #4caf50;
  border-radius: 8px;
  position: absolute;
  transform: translate(-50%, -50%);
  transition: all 0.3s ease;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}

.target.collected {
  opacity: 0;
  transform: translate(-50%, -50%) scale(0);
}

.game-controls {
  display: flex;
  gap: 10px;
  margin: 20px 0;
  justify-content: center;
}

.control-btn {
  padding: 10px 20px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.control-btn:hover {
  background-color: #35495e;
}

.game-over {
  margin: 20px 0;
  padding: 30px;
  background-color: #fff3cd;
  border: 1px solid #ffeeba;
  border-radius: 8px;
  text-align: center;
}

.restart-btn {
  margin-top: 20px;
  padding: 12px 24px;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.3s ease;
}

.restart-btn:hover {
  background-color: #35495e;
}
</style>

4. 最佳实践

4.1 性能优化

  1. 限制事件频率

    • 使用 requestAnimationFrame 来更新 UI,而不是直接在事件回调中更新
    • 考虑使用防抖或节流来处理频繁的事件
    • 只在需要时监听事件
  2. 优化渲染

    • 使用 CSS transforms 进行动画,而不是修改 top/left 属性
    • 避免在事件回调中进行复杂计算
    • 使用 Vue 的 computed 属性缓存计算结果
  3. 合理使用权限

    • 只在必要时请求用户权限
    • 明确告知用户为什么需要权限
    • 处理权限被拒绝的情况

4.2 代码组织

  1. 使用组合式函数封装

    • 将设备方向逻辑封装到可复用的组合式函数中
    • 提供清晰的 API 接口
    • 自动处理组件生命周期
  2. 状态管理

    • 集中管理设备方向状态
    • 使用 Vue 的响应式系统更新 UI
    • 考虑使用 Pinia 或 Vuex 管理全局状态
  3. 错误处理

    • 处理设备方向 API 不可用的情况
    • 优雅处理权限被拒绝的情况
    • 提供回退方案

4.3 浏览器兼容性

Device Orientation API 具有良好的浏览器支持,但存在一些差异:

  • Chrome 7+ (需要 HTTPS)
  • Firefox 6+ (需要 HTTPS)
  • Safari 5+ (需要 HTTPS)
  • Edge 12+ (需要 HTTPS)

在移动设备上,支持情况也很好,但需要注意:

  • iOS 13+ 需要用户权限
  • 某些 Android 设备可能有不同的坐标系
  • 某些浏览器可能限制了 API 的精度

5. 常见问题与解决方案

5.1 权限请求失败

问题:无法请求设备方向权限或权限被拒绝

解决方案

  • 确保网站使用 HTTPS
  • 明确告知用户为什么需要权限
  • 提供回退方案,如手动控制
  • 处理权限被拒绝的情况,提供重新请求的选项

5.2 设备方向数据不准确

问题:设备方向数据与实际设备姿态不符

解决方案

  • 检查设备的坐标系设置
  • 考虑设备校准问题
  • 实现数据过滤,如平滑处理
  • 测试不同设备和浏览器

5.3 性能问题

问题:设备方向事件导致性能下降

解决方案

  • 使用 requestAnimationFrame 更新 UI
  • 实现事件节流或防抖
  • 只监听必要的事件
  • 优化渲染,使用 CSS transforms

5.4 浏览器兼容性问题

问题:在某些浏览器或设备上无法正常工作

解决方案

  • 使用特性检测
  • 提供回退方案
  • 测试不同浏览器和设备
  • 考虑使用 polyfill

6. 高级学习资源

6.1 官方文档

6.2 深入学习

6.3 相关库

7. 实践练习

7.1 练习 1:实现指南针应用

目标:创建一个基于设备方向的指南针应用

要求

  1. 使用 Device Orientation API 获取设备方向数据
  2. 实现指南针指针,根据 alpha 值旋转
  3. 显示当前方向角度
  4. 实现校准功能
  5. 提供清晰的 UI 设计

7.2 练习 2:实现水平仪应用

目标:创建一个基于设备方向的水平仪应用

要求

  1. 使用 Device Orientation API 获取设备倾斜数据
  2. 实现水平仪可视化,显示设备的倾斜程度
  3. 当设备水平时显示提示
  4. 支持不同的灵敏度设置
  5. 实现数据平滑处理

7.3 练习 3:实现增强现实 (AR) 简单应用

目标:创建一个简单的 AR 应用,使用设备方向跟踪

要求

  1. 使用 Device Orientation API 获取设备方向
  2. 实现简单的 AR 场景,如显示 3D 模型
  3. 模型随设备方向变化而旋转
  4. 支持模型选择和交互
  5. 实现良好的用户体验

8. 总结

Device Orientation API 是一个强大的工具,用于获取和监控设备的物理方向和运动状态。在 Vue 3 应用中,它可以帮助我们实现各种基于设备姿态的交互效果和功能。

通过创建可复用的组合式函数,我们可以将 Device Orientation API 的复杂性封装起来,提供简洁的 API 供组件使用。同时,我们需要注意性能优化、合理处理组件生命周期和用户权限,以确保应用的高效运行和良好的用户体验。

在实际开发中,Device Orientation API 可以与其他现代浏览器 API(如 WebGL、Three.js 等)结合使用,实现更复杂的 AR/VR 体验和交互效果。

通过深入学习和实践 Device Orientation API,你将能够构建更智能、更交互的 Vue 3 应用,为用户提供独特的设备姿态交互体验。

9. 代码示例下载

10. 后续学习建议

  1. 学习 WebGL 和 Three.js,实现更复杂的 3D 可视化
  2. 探索 A-Frame,构建完整的 AR/VR 体验
  3. 学习如何结合 Geolocation API,实现导航应用
  4. 研究设备姿态数据的过滤和平滑算法
  5. 探索如何使用机器学习优化设备姿态交互

通过综合运用这些技术,你将能够构建出更复杂、更交互的 Vue 3 应用,为用户提供独特的设备姿态交互体验。

« 上一篇 Vue 3 与 Battery Status API 下一篇 » Vue 3 与 Geolocation API 高级应用