语音控制的太空射击游戏开发笔记

语音控制的太空射击游戏开发笔记

项目背景

最近在研究 Rokid AR 眼镜的开发,想做点有意思的东西。看了一圈案例,发现大家都在做一些比较"正经"的应用------导航、信息展示之类的。我就想,能不能整点不一样的?

游戏!而且是用语音控制的游戏

这篇文章记录了我从零开始,用 JSAR 框架开发一款太空射击小游戏的全过程。代码不复杂,效果还挺酷的。

技术选型

  • 框架: JSAR (Rokid 官方的空间应用运行时)
  • 3D 引擎: Babylon.js
  • 目标设备: Rokid AR 智能眼镜
  • 开发环境: VSCode + JSAR 扩展

游戏设计

在写代码之前,先把游戏玩法想清楚:

|------|---------------|
| 元素 | 设计 |
| 玩家角色 | 三角形飞船,可左右移动 |
| 敌人 | 随机生成的陨石,从上往下掉 |
| 战斗方式 | 发射能量弹击毁陨石 |
| 得分规则 | 每击毁一个陨石+10分 |
| 失败条件 | 被陨石撞击3次游戏结束 |
| 难度曲线 | 分数越高,陨石速度越快 |

控制方式设计成双模式

  • 键盘模式:电脑上调试用(方向键移动,空格射击)
  • 语音模式:Rokid 眼镜实际使用(说"左"、"右"、"发射")

核心技术实现

1. 3D 场景搭建

1.1 相机视角

采用 45 度俯视角,让玩家能同时看到自己的飞船和前方飞来的陨石:

javascript 复制代码
const camera = new BABYLON.ArcRotateCamera(
  'camera',
  0,
  Math.PI / 4,  // 45度俯视角
  80,           // 距离场景中心80单位
  new BABYLON.Vector3(0, 0, 0),
  scene
);

1.2 网格地板

为了增强 3D 空间感,添加了蓝色网格地板:

javascript 复制代码
// 用线条绘制网格
const gridSize = 150;
const gridDivisions = 15;
const lineColor = new BABYLON.Color3(0, 0.5, 1);

// 横向线
for (let i = 0; i <= gridDivisions; i++) {
  const z = (i / gridDivisions) * 200;
  const points = [
    new BABYLON.Vector3(-gridSize / 2, -20, z - 50),
    new BABYLON.Vector3(gridSize / 2, -20, z - 50)
  ];
  const line = BABYLON.MeshBuilder.CreateLines('gridLineH' + i, { points }, scene);
  line.color = lineColor;
  line.alpha = 0.5;
}

// 纵向线(类似代码)

网格的透视效果让玩家能清晰感知深度和速度。

1.3 星空背景

用点云系统创建 300 颗随机分布的星星:

javascript 复制代码
const starCount = 300;
const stars = new BABYLON.PointsCloudSystem('stars', 3, scene);

stars.addPoints(starCount, (particle, i) => {
  particle.position = new BABYLON.Vector3(
    Math.random() * 200 - 100,
    Math.random() * 200 - 100,
    Math.random() * 100 + 30  // Z轴分布增强深度
  );
  particle.color = new BABYLON.Color4(1, 1, 1, Math.random() * 0.8 + 0.2);
});

stars.buildMeshAsync();

2. 飞船系统

2.1 飞船模型

飞船由三角形机身 + 机翼 + 引擎光效组成。为了在 AR 眼镜中看得清楚,所有尺寸都放大了 5 倍:

javascript 复制代码
// 三角形机身
const ship = BABYLON.MeshBuilder.CreateCylinder('player', {
  height: 10,        // 放大5倍后的尺寸
  diameterTop: 0,
  diameterBottom: 7.5,
  tessellation: 3    // 三条边形成三角形
}, scene);

ship.rotation.x = Math.PI / 2;  // 旋转90度,让尖端朝上
ship.position.y = -15;

// 添加机翼增强3D效果
const wingLeft = BABYLON.MeshBuilder.CreateBox('wingLeft', {
  width: 3,
  height: 0.5,
  depth: 4
}, scene);
wingLeft.position = new BABYLON.Vector3(-4, -2, 0);
wingLeft.parent = ship;

// 引擎光效
const engineGlow = BABYLON.MeshBuilder.CreateSphere('engineGlow', {
  diameter: 2.5
}, scene);
engineGlow.position = new BABYLON.Vector3(0, -6, 0);
engineGlow.parent = ship;

const glowMaterial = new BABYLON.StandardMaterial('glowMat', scene);
glowMaterial.emissiveColor = new BABYLON.Color3(1, 0.5, 0);  // 橙色发光
engineGlow.material = glowMaterial;

2.2 流畅移动控制

使用按键状态追踪实现流畅的连续移动:

javascript 复制代码
this.keys = {};

window.addEventListener('keydown', (event) => {
  this.keys[event.key] = true;
  if (event.key === ' ' || event.key === 'Enter') {
    this.shoot();
  }
});

window.addEventListener('keyup', (event) => {
  this.keys[event.key] = false;
});

// 在渲染循环中处理移动
scene.registerBeforeRender(() => {
  if (this.keys['ArrowLeft'] || this.keys['a'] || this.keys['A']) {
    this.movePlayer(-1);
  }
  if (this.keys['ArrowRight'] || this.keys['d'] || this.keys['D']) {
    this.movePlayer(1);
  }
});

这样按住方向键就能持续移动,没有系统按键延迟的顿挫感。


3. 武器系统

3.1 子弹设计

子弹采用圆柱体造型,带有尾迹效果:

javascript 复制代码
shoot() {
  const bullet = BABYLON.MeshBuilder.CreateCylinder('bullet', {
    height: 3,
    diameter: 1
  }, this.scene);

  bullet.position = this.player.position.clone();
  bullet.position.y += 5;
  bullet.rotation.x = Math.PI / 2;

  // 黄色发光材质
  const material = new BABYLON.StandardMaterial('bulletMat', this.scene);
  material.emissiveColor = new BABYLON.Color3(1, 1, 0);
  bullet.material = material;

  // 添加尾迹
  const trail = BABYLON.MeshBuilder.CreateCylinder('trail', {
    height: 2,
    diameter: 0.5
  }, this.scene);
  trail.position = new BABYLON.Vector3(0, -2.5, 0);
  trail.parent = bullet;
  const trailMat = new BABYLON.StandardMaterial('trailMat', this.scene);
  trailMat.emissiveColor = new BABYLON.Color3(1, 0.8, 0);
  trailMat.alpha = 0.6;
  trail.material = trailMat;

  this.bullets.push(bullet);
}

3.2 子弹更新逻辑

javascript 复制代码
scene.registerBeforeRender(() => {
  for (let i = this.bullets.length - 1; i >= 0; i--) {
    this.bullets[i].position.y += 8;  // 速度也放大了

    // 飞出屏幕后销毁
    if (this.bullets[i].position.y > 50) {
      this.bullets[i].dispose();
      this.bullets.splice(i, 1);
    }
  }
});

4. 敌人系统

4.1 陨石生成

陨石使用 Babylon.js 的多面体,有 3 种不同形状:

javascript 复制代码
spawnAsteroid() {
  const asteroid = BABYLON.MeshBuilder.CreatePolyhedron('asteroid', {
    type: Math.floor(Math.random() * 3),  // 0-2随机形状
    size: Math.random() * 4 + 3           // 放大5倍
  }, this.scene);

  asteroid.position = new BABYLON.Vector3(
    Math.random() * 80 - 40,  // X: -40到40随机位置
    50,                       // Y: 从顶部出现
    Math.random() * 10 - 5    // Z: -5到5增加深度变化
  );

  // 岩石材质
  const material = new BABYLON.StandardMaterial('asteroidMat', this.scene);
  material.diffuseColor = new BABYLON.Color3(0.6, 0.4, 0.3);
  material.emissiveColor = new BABYLON.Color3(0.2, 0.1, 0.05);
  asteroid.material = material;

  // 随机旋转速度
  asteroid.rotationSpeed = new BABYLON.Vector3(
    Math.random() * 0.05,
    Math.random() * 0.05,
    Math.random() * 0.05
  );

  this.asteroids.push(asteroid);
}

4.2 定时生成

javascript 复制代码
startAsteroidSpawner() {
  this.asteroidSpawnTimer = setInterval(() => {
    this.spawnAsteroid();
  }, 2000);  // 每2秒生成一个
}

5. 碰撞检测与爆炸效果

5.1 碰撞检测

使用 Babylon.js 内置的 intersectsMesh 方法:

javascript 复制代码
for (let i = this.asteroids.length - 1; i >= 0; i--) {
  const asteroid = this.asteroids[i];

  // 检测子弹碰撞
  let hit = false;
  for (let j = this.bullets.length - 1; j >= 0; j--) {
    const bullet = this.bullets[j];
    if (asteroid.intersectsMesh(bullet, false)) {
      this.createExplosion(asteroid.position);
      asteroid.dispose();
      bullet.dispose();
      this.asteroids.splice(i, 1);
      this.bullets.splice(j, 1);
      this.score += 10;
      this.updateUI();
      hit = true;
      break;
    }
  }

  // 检测飞船碰撞
  if (!hit && asteroid.intersectsMesh(this.player, false)) {
    this.createExplosion(asteroid.position);
    asteroid.dispose();
    this.asteroids.splice(i, 1);
    this.lives--;
    this.updateUI();
    if (this.lives <= 0) {
      this.endGame();
    }
  }
}

5.2 粒子爆炸效果

javascript 复制代码
createExplosion(position) {
  const particleSystem = new BABYLON.ParticleSystem('explosion', 100, this.scene);

  particleSystem.particleTexture = new BABYLON.Texture('', this.scene);
  particleSystem.emitter = position;

  particleSystem.minSize = 0.5;
  particleSystem.maxSize = 1.5;
  particleSystem.minLifeTime = 0.3;
  particleSystem.maxLifeTime = 0.6;
  particleSystem.emitRate = 200;

  particleSystem.createSphereEmitter(1);

  particleSystem.color1 = new BABYLON.Color4(1, 0.5, 0, 1);
  particleSystem.color2 = new BABYLON.Color4(1, 0.8, 0, 1);
  particleSystem.colorDead = new BABYLON.Color4(0.5, 0.5, 0.5, 0);

  particleSystem.minEmitPower = 1;
  particleSystem.maxEmitPower = 3;

  particleSystem.start();

  setTimeout(() => {
    particleSystem.stop();
    setTimeout(() => particleSystem.dispose(), 1000);
  }, 200);
}

6. UI 系统

6.1 得分和生命值显示

UI 使用 DynamicTexture 在 3D 平面上绘制文字:

javascript 复制代码
createUI() {
  // 得分显示
  this.scoreText = BABYLON.MeshBuilder.CreatePlane('scoreText', {
    width: 60,
    height: 12
  }, this.scene);
  this.scoreText.position = new BABYLON.Vector3(-50, 40, 0);

  const scoreTexture = new BABYLON.DynamicTexture('scoreTexture',
    { width: 1024, height: 256 }, this.scene, true);

  const scoreMaterial = new BABYLON.StandardMaterial('scoreMat', this.scene);
  scoreMaterial.diffuseTexture = scoreTexture;
  scoreMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
  this.scoreText.material = scoreMaterial;
  this.scoreText.renderingGroupId = 1;  // 确保UI在最上层

  this.scoreTexture = scoreTexture;

  // 生命值显示(类似代码)
}

updateUI() {
  // 更新得分
  const ctx = this.scoreTexture.getContext();
  ctx.clearRect(0, 0, 1024, 256);
  ctx.fillStyle = '#FFD700';
  ctx.font = 'bold 120px Arial';
  ctx.textAlign = 'left';
  ctx.fillText('得分: ' + this.score, 50, 160);
  this.scoreTexture.update();

  // 更新生命值(类似代码)
}

canvas 尺寸是 1024x256,而 plane 是 60x12,这样宽高比一致,文字不会变形。

6.2 游戏结束界面

javascript 复制代码
endGame() {
  this.gameOver = true;
  clearInterval(this.asteroidSpawnTimer);

  const gameOverText = BABYLON.MeshBuilder.CreatePlane('gameOverText', {
    width: 120,
    height: 40
  }, this.scene);
  gameOverText.position = new BABYLON.Vector3(0, 0, 0);

  const texture = new BABYLON.DynamicTexture('gameOverTexture', {
    width: 1024,
    height: 512
  }, this.scene, true);

  const ctx = texture.getContext();
  ctx.fillStyle = 'rgba(0, 0, 0, 0.9)';
  ctx.fillRect(0, 0, 1024, 512);

  ctx.fillStyle = '#FF3333';
  ctx.font = 'bold 100px Arial';
  ctx.textAlign = 'center';
  ctx.fillText('游戏结束', 512, 150);

  ctx.fillStyle = '#FFD700';
  ctx.font = 'bold 80px Arial';
  ctx.fillText('最终得分: ' + this.score, 512, 280);

  ctx.fillStyle = '#FFFFFF';
  ctx.font = '50px Arial';
  ctx.fillText('按 R 重新开始', 512, 400);

  texture.update();

  const material = new BABYLON.StandardMaterial('gameOverMat', this.scene);
  material.diffuseTexture = texture;
  material.emissiveColor = new BABYLON.Color3(0.8, 0.8, 0.8);
  gameOverText.material = material;
  gameOverText.renderingGroupId = 2;  // 最上层
}

7. 语音控制

7.1 Rokid 语音 API

JSAR 提供了 rokid.voice API 用于语音识别:

javascript 复制代码
setupVoiceControl() {
  if (typeof rokid !== 'undefined' && rokid.voice) {
    rokid.voice.on('command', (command) => {
      switch(command) {
        case '左':
          this.movePlayer(-1);
          break;
        case '右':
          this.movePlayer(1);
          break;
        case '发射':
        case '开火':
          this.shoot();
          break;
      }
    });
  }
}

7.2 键盘模拟语音(调试用)

在电脑上调试时,用 J/K/L 键模拟语音命令:

javascript 复制代码
window.addEventListener('keydown', (event) => {
  // 语音模拟
  if (event.key === 'j' || event.key === 'J') {
    console.log('🎤 语音命令: 左');
    this.movePlayer(-1);
  } else if (event.key === 'l' || event.key === 'L') {
    console.log('🎤 语音命令: 右');
    this.movePlayer(1);
  } else if (event.key === 'k' || event.key === 'K') {
    console.log('🎤 语音命令: 发射');
    this.shoot();
  }
});

8. 游戏难度递增

根据得分提升游戏难度:

javascript 复制代码
// 在碰撞检测中,击中陨石后
this.score += 10;
this.updateUI();

// 难度递增
if (this.score % 50 === 0 && this.difficulty < 2) {
  this.difficulty += 0.1;
  console.log('难度提升至: ' + this.difficulty.toFixed(1));
}

// 在陨石更新时应用难度
asteroid.position.y -= GAME_CONFIG.asteroidSpeed * this.difficulty;

每得 50 分,陨石速度提升 10%,最高提升到 2 倍速。


性能优化

对象管理

游戏中会频繁创建和销毁子弹、陨石,必须及时清理:

javascript 复制代码
// 子弹飞出屏幕
if (bullet.position.y > 50) {
  bullet.dispose();  // 销毁3D对象
  this.bullets.splice(i, 1);  // 从数组删除
}

// 陨石飞出屏幕
if (asteroid.position.y < -40) {
  asteroid.dispose();
  this.asteroids.splice(i, 1);
}

渲染分组

使用 renderingGroupId 控制渲染顺序,确保 UI 始终在最上层:

javascript 复制代码
this.scoreText.renderingGroupId = 1;
this.livesText.renderingGroupId = 1;
gameOverText.renderingGroupId = 2;

控制说明

键盘模式(电脑调试)

|---------|------|
| 按键 | 功能 |
| ← / A | 向左移动 |
| → / D | 向右移动 |
| 空格 / 回车 | 发射 |
| R | 重新开始 |

语音模拟(键盘)

|----|------|
| 按键 | 模拟语音 |
| J | "左" |
| L | "右" |
| K | "发射" |

语音控制(Rokid 眼镜)

直接说:

  • "左" - 飞船左移
  • "右" - 飞船右移
  • "发射" / "开火" - 射击

开发心得

踩过的坑

  1. 忘记 dispose 对象 → 内存泄漏,游戏越来越卡
  2. 碰撞检测死循环 → 删除元素时索引出错,要倒序遍历
  3. 粒子系统没清理 → 爆炸效果叠加导致卡顿
  4. 相机角度调试 → 最初是正面视角太平面,改成 45 度俯视后立体感大增
  5. 物体尺寸太小 → 第一版看不清细节,放大 5-10 倍后体验好很多

经验总结

  • 3D 游戏要注重空间感:相机角度、参考物(网格)、Z 轴变化
  • 尺寸和速度要成比例调整:放大物体后速度也要相应提升
  • 按键状态追踪:比 keydown 事件更流畅,没有系统延迟
  • UI 宽高比:canvas 和 plane 的宽高比要一致,避免文字变形
  • 及时清理对象:dispose + 数组 splice 两步都要做
  • 多用 console.log 调试:特别是碰撞检测部分

写在最后

这个项目从构思到完成大概用了一个周末的时间。代码量不大,但该有的功能都有了。

最重要的是,它证明了 AR 3D 游戏可以很简单,也可以很好玩。你不需要 3A 大作的预算,也不需要庞大的团队,一个人一台电脑,周末两天,就能做出一个有立体感的 AR 游戏原型。

关键是要注重 3D 空间感的营造:合适的相机角度、参考物、物体在 Z 轴的变化,以及清晰可见的物体尺寸。