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 提供了两个主要的事件:
deviceorientation事件:提供设备相对于地球坐标系的物理方向信息,包括:alpha:设备绕 z 轴旋转的角度(0°-360°)beta:设备绕 x 轴旋转的角度(-180°-180°)gamma:设备绕 y 轴旋转的角度(-90°-90°)absolute:布尔值,表示设备方向是否是绝对的(相对于地球坐标系)
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 性能优化
限制事件频率
- 使用
requestAnimationFrame来更新 UI,而不是直接在事件回调中更新 - 考虑使用防抖或节流来处理频繁的事件
- 只在需要时监听事件
- 使用
优化渲染
- 使用 CSS transforms 进行动画,而不是修改 top/left 属性
- 避免在事件回调中进行复杂计算
- 使用 Vue 的 computed 属性缓存计算结果
合理使用权限
- 只在必要时请求用户权限
- 明确告知用户为什么需要权限
- 处理权限被拒绝的情况
4.2 代码组织
使用组合式函数封装
- 将设备方向逻辑封装到可复用的组合式函数中
- 提供清晰的 API 接口
- 自动处理组件生命周期
状态管理
- 集中管理设备方向状态
- 使用 Vue 的响应式系统更新 UI
- 考虑使用 Pinia 或 Vuex 管理全局状态
错误处理
- 处理设备方向 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 深入学习
- Using the DeviceOrientation API
- Building a Web-based Augmented Reality Experience
- Device Orientation Best Practices
6.3 相关库
- Three.js - 用于 3D 可视化
- A-Frame - 用于构建 AR/VR 体验
- DeviceOrientationControls - Three.js 的设备方向控制器
7. 实践练习
7.1 练习 1:实现指南针应用
目标:创建一个基于设备方向的指南针应用
要求:
- 使用 Device Orientation API 获取设备方向数据
- 实现指南针指针,根据 alpha 值旋转
- 显示当前方向角度
- 实现校准功能
- 提供清晰的 UI 设计
7.2 练习 2:实现水平仪应用
目标:创建一个基于设备方向的水平仪应用
要求:
- 使用 Device Orientation API 获取设备倾斜数据
- 实现水平仪可视化,显示设备的倾斜程度
- 当设备水平时显示提示
- 支持不同的灵敏度设置
- 实现数据平滑处理
7.3 练习 3:实现增强现实 (AR) 简单应用
目标:创建一个简单的 AR 应用,使用设备方向跟踪
要求:
- 使用 Device Orientation API 获取设备方向
- 实现简单的 AR 场景,如显示 3D 模型
- 模型随设备方向变化而旋转
- 支持模型选择和交互
- 实现良好的用户体验
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. 后续学习建议
- 学习 WebGL 和 Three.js,实现更复杂的 3D 可视化
- 探索 A-Frame,构建完整的 AR/VR 体验
- 学习如何结合 Geolocation API,实现导航应用
- 研究设备姿态数据的过滤和平滑算法
- 探索如何使用机器学习优化设备姿态交互
通过综合运用这些技术,你将能够构建出更复杂、更交互的 Vue 3 应用,为用户提供独特的设备姿态交互体验。