{T}

交互场景案例

概述

本章将介绍如何使用 Three.js 创建一个完整的交互式 3D 场景,涵盖场景导航、物体交互、UI 集成、音效反馈等高级特性。适用于游戏开发、虚拟展厅、教育培训等场景。

案例目标

创建一个沉浸式的交互场景应用,具备以下特性:

  • 场景导航:第一人称/第三人称视角切换,键盘和鼠标控制
  • 物体交互:拾取、放置、组合物品,丰富的交互反馈
  • 环境系统:昼夜循环、天气效果,增强沉浸感
  • 角色系统:NPC、对话系统,支持剧情演绎
  • 任务系统:引导用户完成任务,追踪进度
  • 音效系统:3D 空间音效,增强临场感

系统架构

plaintext
┌─────────────────────────────────────────────────────────────┐
│                        用户界面层 (UI)                        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │  背包系统  │ │ 对话面板  │ │ 任务追踪  │ │ 提示信息  │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                     游戏系统层 (Game Systems)                 │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │ Navigation│ │Interaction│ │ Inventory │ │ Dialogue │       │
│  │  导航系统  │ │  交互系统  │ │  背包系统  │ │  对话系统  │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
│  ┌──────────┐ ┌──────────┐                                 │
│  │  Quest   │ │  Audio   │                                 │
│  │  任务系统  │ │  音效系统  │                                 │
│  └──────────┘ └──────────┘                                 │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                     渲染引擎层 (Three.js)                     │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │  Scene   │ │  Camera  │ │ Renderer │ │ Controls │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘

核心模块说明

模块职责关键特性
Navigation角色移动与视角控制第一/第三人称切换、碰撞检测、路径移动
Interaction场景物体交互射线检测、高亮反馈、交互类型分发
Inventory物品存储管理堆叠、使用、拖拽排序
DialogueNPC 对话系统多分支对话、条件触发、历史记录
Quest任务追踪系统目标追踪、进度显示、奖励发放
Audio3D 音效系统空间音效、音量控制、背景音乐

项目结构

plaintext
interactive-scene/
├── index.html              # 主页面
├── css/
│   └── style.css          # 样式文件
├── js/
│   ├── main.js            # 主入口
│   ├── SceneManager.js    # 场景管理
│   ├── Navigation.js      # 导航系统
│   ├── Interaction.js     # 交互系统
│   ├── Inventory.js       # 背包系统
│   ├── Dialogue.js        # 对话系统
│   ├── Audio.js           # 音效系统
│   └── Quest.js           # 任务系统
├── models/
│   ├── scene.glb          # 场景模型
│   ├── character.glb      # 角色模型
│   └── items/             # 物品模型
└── audio/
    └── ...                # 音效资源

核心代码实现

1. 导航系统(Navigation.js)

javascript
import * as THREE from 'three';
 
export class Navigation {
  constructor(sceneManager) {
    this.sceneManager = sceneManager;
    this.mode = 'third-person'; // 'first-person' | 'third-person'
    this.character = null;
    this.targetPosition = new THREE.Vector3();
    this.isMoving = false;
    this.moveSpeed = 0.1;
    this.rotateSpeed = 0.05;
    
    this.keys = {
      forward: false,
      backward: false,
      left: false,
      right: false,
      run: false
    };
    
    this.bindEvents();
  }
  
  bindEvents() {
    document.addEventListener('keydown', this.onKeyDown.bind(this));
    document.addEventListener('keyup', this.onKeyUp.bind(this));
  }
  
  onKeyDown(event) {
    switch (event.code) {
      case 'KeyW':
      case 'ArrowUp':
        this.keys.forward = true;
        break;
      case 'KeyS':
      case 'ArrowDown':
        this.keys.backward = true;
        break;
      case 'KeyA':
      case 'ArrowLeft':
        this.keys.left = true;
        break;
      case 'KeyD':
      case 'ArrowRight':
        this.keys.right = true;
        break;
      case 'ShiftLeft':
      case 'ShiftRight':
        this.keys.run = true;
        break;
    }
  }
  
  onKeyUp(event) {
    switch (event.code) {
      case 'KeyW':
      case 'ArrowUp':
        this.keys.forward = false;
        break;
      case 'KeyS':
      case 'ArrowDown':
        this.keys.backward = false;
        break;
      case 'KeyA':
      case 'ArrowLeft':
        this.keys.left = false;
        break;
      case 'KeyD':
      case 'ArrowRight':
        this.keys.right = false;
        break;
      case 'ShiftLeft':
      case 'ShiftRight':
        this.keys.run = false;
        break;
    }
  }
  
  setCharacter(character) {
    this.character = character;
  }
  
  // 切换视角模式
  toggleViewMode() {
    this.mode = this.mode === 'first-person' ? 'third-person' : 'first-person';
    this.updateCameraPosition();
    this.onViewModeChange?.(this.mode);
  }
  
  // 设置目标位置(点击移动)
  setTargetPosition(position) {
    this.targetPosition.copy(position);
    this.isMoving = true;
  }
  
  update(delta) {
    if (!this.character) return;
    
    const speed = this.keys.run ? this.moveSpeed * 2 : this.moveSpeed;
    const direction = new THREE.Vector3();
    
    // 键盘移动
    if (this.keys.forward) direction.z -= 1;
    if (this.keys.backward) direction.z += 1;
    if (this.keys.left) direction.x -= 1;
    if (this.keys.right) direction.x += 1;
    
    if (direction.length() > 0) {
      direction.normalize();
      
      // 根据相机方向调整移动方向
      const cameraDirection = new THREE.Vector3();
      this.sceneManager.camera.getWorldDirection(cameraDirection);
      cameraDirection.y = 0;
      cameraDirection.normalize();
      
      const quaternion = new THREE.Quaternion();
      quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, -1), cameraDirection);
      direction.applyQuaternion(quaternion);
      
      // 移动角色
      this.character.position.x += direction.x * speed;
      this.character.position.z += direction.z * speed;
      
      // 旋转角色朝向移动方向
      const angle = Math.atan2(direction.x, direction.z);
      const targetQuaternion = new THREE.Quaternion();
      targetQuaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
      this.character.quaternion.slerp(targetQuaternion, 0.1);
      
      // 触发移动动画
      this.onCharacterMove?.(this.keys.run);
    } else if (this.isMoving) {
      // 点击移动逻辑
      const distance = this.character.position.distanceTo(this.targetPosition);
      
      if (distance > 0.1) {
        const direction = this.targetPosition.clone()
          .sub(this.character.position)
          .normalize();
        
        this.character.position.add(direction.multiplyScalar(speed));
        
        // 旋转角色
        const angle = Math.atan2(direction.x, direction.z);
        const targetQuaternion = new THREE.Quaternion();
        targetQuaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), angle);
        this.character.quaternion.slerp(targetQuaternion, 0.1);
        
        this.onCharacterMove?.(false);
      } else {
        this.isMoving = false;
        this.onCharacterStop?.();
      }
    } else {
      this.onCharacterStop?.();
    }
    
    // 更新相机位置
    this.updateCameraPosition();
    
    // 碰撞检测
    this.checkCollisions();
  }
  
  updateCameraPosition() {
    if (!this.character) return;
    
    const camera = this.sceneManager.camera;
    
    if (this.mode === 'first-person') {
      // 第一人称视角
      const headPosition = this.character.position.clone();
      headPosition.y += 1.6; // 眼睛高度
      
      camera.position.copy(headPosition);
      
      // 相机旋转跟随鼠标
      // 这部分通常与鼠标控制结合
    } else {
      // 第三人称视角
      const offset = new THREE.Vector3(0, 3, 5);
      offset.applyQuaternion(this.character.quaternion);
      
      const targetPosition = this.character.position.clone().add(offset);
      camera.position.lerp(targetPosition, 0.1);
      
      this.sceneManager.controls.target.copy(this.character.position);
    }
  }
  
  checkCollisions() {
    // 简单的碰撞检测示例
    // 实际项目中应该使用更复杂的碰撞系统
    const raycaster = new THREE.Raycaster();
    const directions = [
      new THREE.Vector3(1, 0, 0),
      new THREE.Vector3(-1, 0, 0),
      new THREE.Vector3(0, 0, 1),
      new THREE.Vector3(0, 0, -1)
    ];
    
    directions.forEach(direction => {
      raycaster.set(this.character.position, direction);
      
      const intersects = raycaster.intersectObjects(
        this.sceneManager.scene.children,
        true
      );
      
      if (intersects.length > 0 && intersects[0].distance < 0.5) {
        // 检测到碰撞,阻止移动
        const pushBack = direction.clone().multiplyScalar(-0.1);
        this.character.position.add(pushBack);
      }
    });
  }
  
  dispose() {
    document.removeEventListener('keydown', this.onKeyDown);
    document.removeEventListener('keyup', this.onKeyUp);
  }
}

2. 交互系统(Interaction.js)

javascript
import * as THREE from 'three';
 
export class Interaction {
  constructor(sceneManager) {
    this.sceneManager = sceneManager;
    this.raycaster = new THREE.Raycaster();
    this.mouse = new THREE.Vector2();
    this.interactiveObjects = [];
    this.hoveredObject = null;
    this.selectedObject = null;
    this.inventory = null;
    
    this.bindEvents();
  }
  
  bindEvents() {
    const canvas = this.sceneManager.renderer.domElement;
    
    canvas.addEventListener('click', this.onClick.bind(this));
    canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
    canvas.addEventListener('dblclick', this.onDoubleClick.bind(this));
  }
  
  // 注册可交互物体
  registerInteractive(object, config) {
    object.userData.interactive = true;
    object.userData.interactionConfig = {
      type: config.type || 'default', // 'pickup' | 'use' | 'examine' | 'talk'
      name: config.name || 'Object',
      description: config.description || '',
      action: config.action || null,
      highlightColor: config.highlightColor || 0x00ff00,
      ...config
    };
    
    this.interactiveObjects.push(object);
  }
  
  // 取消注册
  unregisterInteractive(object) {
    const index = this.interactiveObjects.indexOf(object);
    if (index > -1) {
      this.interactiveObjects.splice(index, 1);
    }
  }
  
  onClick(event) {
    this.updateMouse(event);
    
    const intersect = this.getIntersection();
    
    if (intersect) {
      const object = intersect.object;
      const config = object.userData.interactionConfig;
      
      if (config) {
        this.handleInteraction(object, config, intersect.point);
      }
    } else {
      // 点击地面,移动角色
      this.onGroundClick?.(intersect.point);
    }
  }
  
  onMouseMove(event) {
    this.updateMouse(event);
    
    const intersect = this.getIntersection();
    
    if (intersect) {
      const object = intersect.object;
      
      if (object.userData.interactive) {
        if (this.hoveredObject !== object) {
          // 鼠标离开上一个对象
          if (this.hoveredObject) {
            this.unhighlightObject(this.hoveredObject);
          }
          
          // 鼠标进入新对象
          this.hoveredObject = object;
          this.highlightObject(object);
          this.showTooltip(object.userData.interactionConfig);
          
          this.sceneManager.renderer.domElement.style.cursor = 'pointer';
        }
      } else {
        this.clearHover();
      }
    } else {
      this.clearHover();
    }
  }
  
  onDoubleClick(event) {
    this.updateMouse(event);
    
    const intersect = this.getIntersection();
    
    if (intersect && intersect.object.userData.interactive) {
      this.onObjectDoubleClick?.(intersect.object);
    }
  }
  
  handleInteraction(object, config, point) {
    switch (config.type) {
      case 'pickup':
        this.handlePickup(object, config);
        break;
      case 'use':
        this.handleUse(object, config);
        break;
      case 'examine':
        this.handleExamine(object, config);
        break;
      case 'talk':
        this.handleTalk(object, config);
        break;
      default:
        this.handleDefault(object, config);
    }
  }
  
  handlePickup(object, config) {
    // 添加到背包
    if (this.inventory) {
      this.inventory.addItem({
        id: object.uuid,
        name: config.name,
        description: config.description,
        mesh: object.clone()
      });
      
      // 从场景中移除
      this.unregisterInteractive(object);
      this.sceneManager.remove(object);
      
      // 触发事件
      this.onItemPickup?.(object, config);
    }
  }
  
  handleUse(object, config) {
    if (config.action) {
      config.action(object);
    }
    
    this.onItemUse?.(object, config);
  }
  
  handleExamine(object, config) {
    this.onItemExamine?.(object, config);
    
    // 相机聚焦到物体
    const box = new THREE.Box3().setFromObject(object);
    const center = box.getCenter(new THREE.Vector3());
    
    this.focusCameraOn(center);
  }
  
  handleTalk(object, config) {
    if (config.dialogue) {
      this.onNPCDialogue?.(object, config.dialogue);
    }
  }
  
  handleDefault(object, config) {
    console.log(`与 ${config.name} 交互`);
    this.onDefaultInteraction?.(object, config);
  }
  
  highlightObject(object) {
    object.traverse((child) => {
      if (child.isMesh) {
        child.userData.originalMaterial = child.material;
        child.material = child.material.clone();
        child.material.emissive = new THREE.Color(
          object.userData.interactionConfig.highlightColor
        );
        child.material.emissiveIntensity = 0.3;
      }
    });
  }
  
  unhighlightObject(object) {
    object.traverse((child) => {
      if (child.isMesh && child.userData.originalMaterial) {
        child.material.dispose();
        child.material = child.userData.originalMaterial;
      }
    });
  }
  
  showTooltip(config) {
    this.onShowTooltip?.(config.name, config.description);
  }
  
  hideTooltip() {
    this.onHideTooltip?.();
  }
  
  clearHover() {
    if (this.hoveredObject) {
      this.unhighlightObject(this.hoveredObject);
      this.hoveredObject = null;
    }
    
    this.hideTooltip();
    this.sceneManager.renderer.domElement.style.cursor = 'default';
  }
  
  focusCameraOn(position, duration = 1000) {
    const camera = this.sceneManager.camera;
    const targetPosition = position.clone().add(new THREE.Vector3(0, 1, 3));
    
    const startPosition = camera.position.clone();
    const startTime = Date.now();
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      const eased = this.easeInOutCubic(progress);
      
      camera.position.lerpVectors(startPosition, targetPosition, eased);
      camera.lookAt(position);
      
      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };
    
    animate();
  }
  
  updateMouse(event) {
    const rect = this.sceneManager.renderer.domElement.getBoundingClientRect();
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  }
  
  getIntersection() {
    this.raycaster.setFromCamera(this.mouse, this.sceneManager.camera);
    
    // 检测与地面的交点
    const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
    const groundPoint = new THREE.Vector3();
    this.raycaster.ray.intersectPlane(groundPlane, groundPoint);
    
    // 检测与可交互物体的交点
    const intersects = this.raycaster.intersectObjects(
      this.interactiveObjects,
      true
    );
    
    if (intersects.length > 0) {
      return intersects[0];
    }
    
    // 返回地面交点
    return groundPoint ? { point: groundPoint, object: null } : null;
  }
  
  easeInOutCubic(t) {
    return t < 0.5
      ? 4 * t * t * t
      : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }
  
  dispose() {
    const canvas = this.sceneManager.renderer.domElement;
    canvas.removeEventListener('click', this.onClick);
    canvas.removeEventListener('mousemove', this.onMouseMove);
    canvas.removeEventListener('dblclick', this.onDoubleClick);
  }
}

3. 背包系统(Inventory.js)

javascript
export class Inventory {
  constructor() {
    this.items = [];
    this.maxSlots = 20;
    this.selectedItem = null;
  }
  
  addItem(item) {
    if (this.items.length >= this.maxSlots) {
      console.log('背包已满');
      return false;
    }
    
    // 检查是否可堆叠
    const existingItem = this.items.find(
      i => i.id === item.id && i.stackable
    );
    
    if (existingItem) {
      existingItem.quantity = (existingItem.quantity || 1) + 1;
    } else {
      this.items.push({ ...item, quantity: 1 });
    }
    
    this.onInventoryUpdate?.(this.items);
    return true;
  }
  
  removeItem(itemId) {
    const index = this.items.findIndex(i => i.id === itemId);
    
    if (index > -1) {
      if (this.items[index].quantity > 1) {
        this.items[index].quantity--;
      } else {
        this.items.splice(index, 1);
      }
      
      this.onInventoryUpdate?.(this.items);
      return true;
    }
    
    return false;
  }
  
  getItem(itemId) {
    return this.items.find(i => i.id === itemId);
  }
  
  selectItem(itemId) {
    this.selectedItem = this.getItem(itemId);
    this.onItemSelect?.(this.selectedItem);
  }
  
  useSelected(target) {
    if (this.selectedItem && this.selectedItem.onUse) {
      this.selectedItem.onUse(target);
      this.onItemUse?.(this.selectedItem, target);
    }
  }
  
  hasItem(itemId) {
    return this.items.some(i => i.id === itemId);
  }
  
  getItems() {
    return this.items;
  }
  
  clear() {
    this.items = [];
    this.selectedItem = null;
    this.onInventoryUpdate?.(this.items);
  }
  
  render(container) {
    container.innerHTML = `
      <div class="inventory">
        <h3>背包</h3>
        <div class="inventory-grid">
          ${Array(this.maxSlots).fill(0).map((_, index) => {
            const item = this.items[index];
            return `
              <div class="inventory-slot ${item ? 'has-item' : ''}" 
                   data-slot="${index}"
                   ${item ? `data-item-id="${item.id}"` : ''}>
                ${item ? `
                  <div class="item-icon">${item.name[0]}</div>
                  ${item.quantity > 1 ? `<span class="item-quantity">${item.quantity}</span>` : ''}
                ` : ''}
              </div>
            `;
          }).join('')}
        </div>
        ${this.selectedItem ? `
          <div class="item-details">
            <h4>${this.selectedItem.name}</h4>
            <p>${this.selectedItem.description}</p>
          </div>
        ` : ''}
      </div>
    `;
    
    // 绑定事件
    container.querySelectorAll('.inventory-slot.has-item').forEach(slot => {
      slot.addEventListener('click', () => {
        const itemId = slot.dataset.itemId;
        this.selectItem(itemId);
      });
    });
  }
}

4. 对话系统(Dialogue.js)

javascript
export class Dialogue {
  constructor() {
    this.currentDialogue = null;
    this.currentNode = null;
    this.history = [];
  }
  
  start(dialogueData, character) {
    this.currentDialogue = dialogueData;
    this.currentNode = dialogueData.start;
    this.history = [];
    
    this.onDialogueStart?.(character);
    this.showCurrentNode();
  }
  
  showCurrentNode() {
    if (!this.currentNode) return;
    
    const node = this.currentDialogue.nodes[this.currentNode];
    
    if (!node) {
      this.end();
      return;
    }
    
    const dialogueInfo = {
      speaker: node.speaker || this.currentDialogue.defaultSpeaker,
      text: node.text,
      choices: node.choices || [],
      portrait: node.portrait
    };
    
    this.onShowDialogue?.(dialogueInfo);
  }
  
  selectChoice(choiceIndex) {
    const node = this.currentDialogue.nodes[this.currentNode];
    
    if (!node || !node.choices || !node.choices[choiceIndex]) {
      return;
    }
    
    const choice = node.choices[choiceIndex];
    
    // 记录历史
    this.history.push({
      node: this.currentNode,
      choice: choiceIndex
    });
    
    // 触发选择回调
    if (choice.action) {
      this.onChoiceAction?.(choice.action);
    }
    
    // 跳转到下一个节点
    if (choice.next) {
      this.currentNode = choice.next;
      this.showCurrentNode();
    } else {
      this.end();
    }
  }
  
  next() {
    const node = this.currentDialogue.nodes[this.currentNode];
    
    if (node && node.next) {
      this.currentNode = node.next;
      this.showCurrentNode();
    } else {
      this.end();
    }
  }
  
  end() {
    this.currentDialogue = null;
    this.currentNode = null;
    
    this.onDialogueEnd?.();
  }
  
  isActive() {
    return this.currentDialogue !== null;
  }
  
  render(container, dialogueInfo) {
    container.innerHTML = `
      <div class="dialogue-box">
        <div class="dialogue-header">
          <span class="speaker-name">${dialogueInfo.speaker}</span>
        </div>
        <div class="dialogue-content">
          <p class="dialogue-text">${dialogueInfo.text}</p>
          ${dialogueInfo.choices.length > 0 ? `
            <div class="dialogue-choices">
              ${dialogueInfo.choices.map((choice, index) => `
                <button class="choice-button" data-index="${index}">
                  ${choice.text}
                </button>
              `).join('')}
            </div>
          ` : `
            <button class="continue-button">继续</button>
          `}
        </div>
      </div>
    `;
    
    // 绑定选择事件
    container.querySelectorAll('.choice-button').forEach(button => {
      button.addEventListener('click', () => {
        const index = parseInt(button.dataset.index);
        this.selectChoice(index);
      });
    });
    
    // 绑定继续事件
    const continueButton = container.querySelector('.continue-button');
    if (continueButton) {
      continueButton.addEventListener('click', () => {
        this.next();
      });
    }
  }
}

5. 任务系统(Quest.js)

javascript
export class Quest {
  constructor() {
    this.quests = new Map();
    this.activeQuest = null;
    this.completedQuests = [];
  }
  
  addQuest(questData) {
    this.quests.set(questData.id, {
      ...questData,
      status: 'available',
      progress: 0,
      objectives: questData.objectives.map(obj => ({
        ...obj,
        completed: false
      }))
    });
  }
  
  startQuest(questId) {
    const quest = this.quests.get(questId);
    
    if (!quest || quest.status !== 'available') {
      return false;
    }
    
    quest.status = 'active';
    this.activeQuest = quest;
    
    this.onQuestStart?.(quest);
    return true;
  }
  
  updateObjective(objectiveType, targetId, amount = 1) {
    if (!this.activeQuest) return;
    
    this.activeQuest.objectives.forEach(objective => {
      if (
        objective.type === objectiveType &&
        objective.targetId === targetId &&
        !objective.completed
      ) {
        objective.currentAmount = (objective.currentAmount || 0) + amount;
        
        if (objective.currentAmount >= objective.requiredAmount) {
          objective.completed = true;
          this.onObjectiveComplete?.(objective);
        }
        
        this.onQuestProgress?.(this.activeQuest);
      }
    });
    
    // 检查任务是否完成
    this.checkQuestCompletion();
  }
  
  checkQuestCompletion() {
    if (!this.activeQuest) return;
    
    const allCompleted = this.activeQuest.objectives.every(
      obj => obj.completed
    );
    
    if (allCompleted) {
      this.activeQuest.status = 'completed';
      this.completedQuests.push(this.activeQuest);
      
      this.onQuestComplete?.(this.activeQuest);
      this.activeQuest = null;
    }
  }
  
  getQuest(questId) {
    return this.quests.get(questId);
  }
  
  getActiveQuest() {
    return this.activeQuest;
  }
  
  getAvailableQuests() {
    return Array.from(this.quests.values()).filter(
      q => q.status === 'available'
    );
  }
  
  render(container) {
    if (!this.activeQuest) {
      container.innerHTML = '<p class="no-quest">暂无进行中的任务</p>';
      return;
    }
    
    const quest = this.activeQuest;
    
    container.innerHTML = `
      <div class="quest-panel">
        <h3>${quest.name}</h3>
        <p class="quest-description">${quest.description}</p>
        <ul class="objectives-list">
          ${quest.objectives.map(obj => `
            <li class="${obj.completed ? 'completed' : ''}">
              ${obj.completed ? '✓' : '○'} ${obj.description}
              ${obj.requiredAmount > 1 ? 
                `(${obj.currentAmount || 0}/${obj.requiredAmount})` : ''}
            </li>
          `).join('')}
        </ul>
        <div class="quest-rewards">
          <span>奖励: ${quest.rewards.experience} 经验</span>
        </div>
      </div>
    `;
  }
}

6. 音效系统(Audio.js)

javascript
import * as THREE from 'three';
 
export class Audio {
  constructor(sceneManager) {
    this.sceneManager = sceneManager;
    this.listener = new THREE.AudioListener();
    this.sceneManager.camera.add(this.listener);
    
    this.sounds = new Map();
    this.ambientSounds = [];
    this.musicTrack = null;
    
    this.masterVolume = 1.0;
    this.sfxVolume = 1.0;
    this.musicVolume = 0.5;
    this.ambientVolume = 0.3;
    
    this.audioLoader = new THREE.AudioLoader();
  }
  
  async loadSound(name, url, type = 'sfx') {
    return new Promise((resolve, reject) => {
      this.audioLoader.load(url, (buffer) => {
        let sound;
        
        if (type === 'ambient' || type === 'music') {
          sound = new THREE.Audio(this.listener);
        } else {
          sound = new THREE.PositionalAudio(this.listener);
        }
        
        sound.setBuffer(buffer);
        
        this.sounds.set(name, {
          sound,
          type,
          volume: type === 'music' ? this.musicVolume : 
                  type === 'ambient' ? this.ambientVolume : this.sfxVolume
        });
        
        resolve(sound);
      }, undefined, reject);
    });
  }
  
  play(name, options = {}) {
    const soundData = this.sounds.get(name);
    
    if (!soundData) {
      console.warn(`Sound not found: ${name}`);
      return;
    }
    
    const { sound, type, volume } = soundData;
    
    if (sound.isPlaying) {
      sound.stop();
    }
    
    // 设置音量
    const finalVolume = volume * this.masterVolume * (options.volume || 1);
    sound.setVolume(finalVolume);
    
    // 设置循环
    sound.setLoop(options.loop || type === 'ambient' || type === 'music');
    
    // 设置位置(仅对位置音频)
    if (sound instanceof THREE.PositionalAudio && options.position) {
      sound.position.copy(options.position);
    }
    
    sound.play();
    
    return sound;
  }
  
  stop(name) {
    const soundData = this.sounds.get(name);
    
    if (soundData && soundData.sound.isPlaying) {
      soundData.sound.stop();
    }
  }
  
  stopAll() {
    this.sounds.forEach(({ sound }) => {
      if (sound.isPlaying) {
        sound.stop();
      }
    });
  }
  
  setMasterVolume(value) {
    this.masterVolume = value;
    this.updateAllVolumes();
  }
  
  setSFXVolume(value) {
    this.sfxVolume = value;
    this.updateVolumesByType('sfx');
  }
  
  setMusicVolume(value) {
    this.musicVolume = value;
    this.updateVolumesByType('music');
  }
  
  setAmbientVolume(value) {
    this.ambientVolume = value;
    this.updateVolumesByType('ambient');
  }
  
  updateAllVolumes() {
    this.sounds.forEach(({ sound, volume }) => {
      sound.setVolume(volume * this.masterVolume);
    });
  }
  
  updateVolumesByType(type) {
    const volume = type === 'music' ? this.musicVolume :
                   type === 'ambient' ? this.ambientVolume : this.sfxVolume;
    
    this.sounds.forEach((soundData) => {
      if (soundData.type === type) {
        soundData.volume = volume;
        soundData.sound.setVolume(volume * this.masterVolume);
      }
    });
  }
  
  playAmbient(name) {
    const sound = this.play(name, { loop: true });
    if (sound) {
      this.ambientSounds.push(name);
    }
  }
  
  stopAmbient(name) {
    this.stop(name);
    const index = this.ambientSounds.indexOf(name);
    if (index > -1) {
      this.ambientSounds.splice(index, 1);
    }
  }
  
  playMusic(name) {
    if (this.musicTrack) {
      this.stop(this.musicTrack);
    }
    
    this.play(name, { loop: true });
    this.musicTrack = name;
  }
  
  stopMusic() {
    if (this.musicTrack) {
      this.stop(this.musicTrack);
      this.musicTrack = null;
    }
  }
  
  dispose() {
    this.stopAll();
    this.sounds.clear();
    
    if (this.listener.parent) {
      this.listener.parent.remove(this.listener);
    }
  }
}

主入口文件(main.js)

javascript
import * as THREE from 'three';
import { SceneManager } from './SceneManager.js';
import { Navigation } from './Navigation.js';
import { Interaction } from './Interaction.js';
import { Inventory } from './Inventory.js';
import { Dialogue } from './Dialogue.js';
import { Quest } from './Quest.js';
import { Audio } from './Audio.js';
 
class InteractiveScene {
  constructor() {
    this.container = document.getElementById('app');
    this.clock = new THREE.Clock();
    
    this.init();
  }
  
  async init() {
    // 初始化各个系统
    this.sceneManager = new SceneManager(this.container);
    this.navigation = new Navigation(this.sceneManager);
    this.interaction = new Interaction(this.sceneManager);
    this.inventory = new Inventory();
    this.dialogue = new Dialogue();
    this.quest = new Quest();
    this.audio = new Audio(this.sceneManager);
    
    // 设置回调
    this.setupCallbacks();
    
    // 加载资源
    await this.loadAssets();
    
    // 初始化UI
    this.initUI();
    
    // 开始游戏循环
    this.animate();
  }
  
  setupCallbacks() {
    // 交互系统回调
    this.interaction.onItemPickup = (object, config) => {
      this.audio.play('pickup');
      console.log(`拾取了 ${config.name}`);
    };
    
    this.interaction.onNPCDialogue = (npc, dialogueData) => {
      this.dialogue.start(dialogueData, npc);
    };
    
    this.interaction.onShowTooltip = (name, description) => {
      this.showTooltip(name, description);
    };
    
    this.interaction.onHideTooltip = () => {
      this.hideTooltip();
    };
    
    // 导航系统回调
    this.navigation.onCharacterMove = (isRunning) => {
      // 播放脚步声
      if (!this.footstepInterval) {
        this.footstepInterval = setInterval(() => {
          this.audio.play('footstep', { volume: isRunning ? 1.0 : 0.5 });
        }, isRunning ? 300 : 500);
      }
    };
    
    this.navigation.onCharacterStop = () => {
      clearInterval(this.footstepInterval);
      this.footstepInterval = null;
    };
    
    // 对话系统回调
    this.dialogue.onShowDialogue = (dialogueInfo) => {
      this.showDialogueUI(dialogueInfo);
    };
    
    this.dialogue.onDialogueEnd = () => {
      this.hideDialogueUI();
    };
    
    // 任务系统回调
    this.quest.onQuestStart = (quest) => {
      this.showNotification(`新任务: ${quest.name}`);
    };
    
    this.quest.onQuestComplete = (quest) => {
      this.showNotification(`任务完成: ${quest.name}`);
      this.audio.play('quest-complete');
    };
  }
  
  async loadAssets() {
    // 加载场景模型
    // await this.loadScene();
    
    // 加载角色
    // await this.loadCharacter();
    
    // 加载音效
    await this.audio.loadSound('footstep', '/audio/footstep.mp3', 'sfx');
    await this.audio.loadSound('pickup', '/audio/pickup.mp3', 'sfx');
    await this.audio.loadSound('ambient', '/audio/ambient.mp3', 'ambient');
    await this.audio.loadSound('music', '/audio/music.mp3', 'music');
    
    // 播放背景音乐
    this.audio.playMusic('music');
    this.audio.playAmbient('ambient');
  }
  
  initUI() {
    // 创建UI容器
    this.createUIContainers();
    
    // 渲染初始UI
    this.inventory.render(document.getElementById('inventory-container'));
    this.quest.render(document.getElementById('quest-container'));
  }
  
  createUIContainers() {
    const uiContainer = document.createElement('div');
    uiContainer.id = 'ui-container';
    uiContainer.innerHTML = `
      <div id="tooltip" class="tooltip hidden"></div>
      <div id="notification-container"></div>
      <div id="dialogue-container" class="hidden"></div>
      <div id="inventory-container"></div>
      <div id="quest-container"></div>
      <div id="controls-hint">
        <p>WASD - 移动 | 鼠标 - 视角 | E - 交互 | Tab - 背包</p>
      </div>
    `;
    
    this.container.appendChild(uiContainer);
  }
  
  showTooltip(name, description) {
    const tooltip = document.getElementById('tooltip');
    tooltip.textContent = `${name}: ${description}`;
    tooltip.classList.remove('hidden');
  }
  
  hideTooltip() {
    const tooltip = document.getElementById('tooltip');
    tooltip.classList.add('hidden');
  }
  
  showDialogueUI(dialogueInfo) {
    const container = document.getElementById('dialogue-container');
    container.classList.remove('hidden');
    this.dialogue.render(container, dialogueInfo);
  }
  
  hideDialogueUI() {
    const container = document.getElementById('dialogue-container');
    container.classList.add('hidden');
  }
  
  showNotification(message) {
    const container = document.getElementById('notification-container');
    const notification = document.createElement('div');
    notification.className = 'notification';
    notification.textContent = message;
    
    container.appendChild(notification);
    
    setTimeout(() => {
      notification.remove();
    }, 3000);
  }
  
  animate() {
    requestAnimationFrame(this.animate.bind(this));
    
    const delta = this.clock.getDelta();
    
    // 更新各个系统
    this.navigation.update(delta);
    this.sceneManager.controls.update();
    
    // 渲染场景
    this.sceneManager.renderer.render(
      this.sceneManager.scene,
      this.sceneManager.camera
    );
  }
  
  dispose() {
    this.navigation.dispose();
    this.interaction.dispose();
    this.audio.dispose();
  }
}
 
// 启动应用
const app = new InteractiveScene();

最佳实践

1. 性能优化

javascript
// 使用 LOD 系统
const lod = new THREE.LOD();
lod.addLevel(highDetail, 0);
lod.addLevel(mediumDetail, 10);
lod.addLevel(lowDetail, 20);
 
// 延迟加载
async function loadArea(areaId) {
  if (!loadedAreas.has(areaId)) {
    const area = await loadAreaAssets(areaId);
    loadedAreas.set(areaId, area);
  }
}
 
// 对象池
class ObjectPool {
  constructor(createFn, maxSize = 100) {
    this.pool = [];
    this.createFn = createFn;
    this.maxSize = maxSize;
  }
  
  get() {
    return this.pool.pop() || this.createFn();
  }
  
  release(obj) {
    if (this.pool.length < this.maxSize) {
      this.pool.push(obj);
    }
  }
}

2. 状态管理

javascript
// 使用状态机管理游戏状态
class GameStateMachine {
  constructor() {
    this.states = {
      idle: new IdleState(),
      exploring: new ExploringState(),
      dialogue: new DialogueState(),
      inventory: new InventoryState()
    };
    
    this.currentState = this.states.idle;
  }
  
  transition(stateName) {
    this.currentState.exit();
    this.currentState = this.states[stateName];
    this.currentState.enter();
  }
}

3. 数据驱动设计

javascript
// 使用配置文件定义交互逻辑
const interactionConfig = {
  chest: {
    type: 'use',
    action: 'openChest',
    requirements: ['key'],
    rewards: { gold: 100 }
  },
  door: {
    type: 'use',
    action: 'openDoor',
    requirements: [],
    transitionTo: 'room2'
  }
};

API 接口说明

方法参数返回值说明
setCharacter(character)character: THREE.Object3Dvoid设置控制角色
toggleViewMode()-void切换第一/第三人称视角
setTargetPosition(position)position: THREE.Vector3void设置点击移动目标
update(delta)delta: numbervoid更新移动状态

Interaction API

方法参数返回值说明
registerInteractive(object, config)object: THREE.Object3D, config: InteractionConfigvoid注册可交互物体
unregisterInteractive(object)object: THREE.Object3Dvoid取消注册交互物体
focusCameraOn(position, duration)position: THREE.Vector3, duration?: numbervoid相机聚焦动画

Inventory API

方法参数返回值说明
addItem(item)item: InventoryItemboolean添加物品(返回是否成功)
removeItem(itemId)itemId: stringboolean移除物品
getItem(itemId)itemId: stringInventoryItem获取物品信息
hasItem(itemId)itemId: stringboolean检查是否拥有物品
selectItem(itemId)itemId: stringvoid选中物品
useSelected(target)target: THREE.Object3Dvoid使用选中物品

Dialogue API

方法参数返回值说明
start(dialogueData, character)dialogueData: DialogueData, character: THREE.Object3Dvoid开始对话
selectChoice(index)index: numbervoid选择对话选项
next()-void下一段对话
end()-void结束对话
isActive()-boolean对话是否进行中

Quest API

方法参数返回值说明
addQuest(questData)questData: QuestDatavoid添加任务
startQuest(questId)questId: stringboolean开始任务
updateObjective(type, targetId, amount)type: string, targetId: string, amount?: numbervoid更新任务目标
getActiveQuest()-QuestData获取当前任务
getAvailableQuests()-QuestData[]获取可接任务

配置参数详解

导航系统配置

javascript
const navigationConfig = {
  // 移动参数
  moveSpeed: 0.1,         // 移动速度
  runSpeed: 0.2,          // 奔跑速度
  rotateSpeed: 0.05,      // 旋转速度
  
  // 视角参数
  viewMode: 'third-person', // 'first-person' | 'third-person'
  cameraOffset: [0, 3, 5],  // 第三人称相机偏移
  headHeight: 1.6,          // 第一人称眼睛高度
  
  // 碰撞参数
  collisionRadius: 0.5,     // 碰撞检测半径
  collisionLayers: ['wall', 'furniture'] // 碰撞层级
};

交互类型配置

javascript
const interactionTypes = {
  pickup: {
    type: 'pickup',
    highlightColor: 0x00ff00,
    description: '拾取'
  },
  use: {
    type: 'use',
    highlightColor: 0x0088ff,
    description: '使用'
  },
  examine: {
    type: 'examine',
    highlightColor: 0xffaa00,
    description: '查看'
  },
  talk: {
    type: 'talk',
    highlightColor: 0xff00ff,
    description: '交谈'
  }
};

对话数据结构

javascript
const dialogueData = {
  id: 'npc_dialogue_01',
  defaultSpeaker: 'NPC',
  start: 'node_1',
  nodes: {
    node_1: {
      speaker: '老者',
      text: '欢迎来到这个神秘的世界...',
      portrait: '/portraits/elder.png',
      choices: [
        { text: '请告诉我更多', next: 'node_2' },
        { text: '我该怎么做?', next: 'node_3', action: 'trigger_tutorial' },
        { text: '再见', next: 'node_end' }
      ]
    },
    node_2: {
      text: '这个世界充满了未知...',
      next: 'node_1'  // 返回上一节点
    },
    node_end: {
      text: '祝你好运!'
      // 无 next 表示对话结束
    }
  }
};

任务数据结构

javascript
const questData = {
  id: 'quest_001',
  name: '初识世界',
  description: '与村民交谈,了解这个世界',
  status: 'available', // 'available' | 'active' | 'completed'
  objectives: [
    {
      id: 'obj_1',
      type: 'talk',         // 目标类型
      targetId: 'npc_elder', // 目标ID
      description: '与老者交谈',
      requiredAmount: 1,
      currentAmount: 0
    },
    {
      id: 'obj_2',
      type: 'collect',
      targetId: 'item_herb',
      description: '采集草药',
      requiredAmount: 5,
      currentAmount: 0
    }
  ],
  rewards: {
    experience: 100,
    gold: 50,
    items: ['sword_wooden']
  }
};

使用示例

创建可交互物品

javascript
// 创建一个可拾取的物品
const chest = new THREE.Mesh(geometry, material);
chest.position.set(5, 0, 3);
 
// 注册交互
interaction.registerInteractive(chest, {
  type: 'pickup',
  name: '宝箱',
  description: '一个神秘的宝箱',
  highlightColor: 0xffd700,
  stackable: false,
  onUse: (item) => {
    // 打开宝箱逻辑
    openChestAnimation(item);
  }
});

触发对话

javascript
// NPC 交互配置
interaction.registerInteractive(npc, {
  type: 'talk',
  name: '村长',
  description: '看起来很有智慧的老人',
  dialogue: dialogueData
});
 
// 对话回调
dialogue.onChoiceAction = (action) => {
  if (action === 'trigger_tutorial') {
    quest.startQuest('quest_tutorial');
  }
};

任务系统集成

javascript
// 添加任务
quest.addQuest(questData);
 
// 玩家与 NPC 对话后
quest.updateObjective('talk', 'npc_elder', 1);
 
// 采集物品后
quest.updateObjective('collect', 'item_herb', 1);
 
// 监听任务完成
quest.onQuestComplete = (completedQuest) => {
  // 发放奖励
  player.addExperience(completedQuest.rewards.experience);
  player.addGold(completedQuest.rewards.gold);
  
  // 显示完成提示
  showNotification(`任务完成: ${completedQuest.name}`);
};

常见问题解答

Q1: 碰撞检测不准确怎么办?

A: 建议采用以下优化方案:

  1. 使用简化的碰撞体(Box/Sphere)代替精确网格
  2. 对静态物体使用 BVH 加速结构
  3. 减少每帧的射线检测次数
javascript
// 使用简化的碰撞检测
import { Octree } from 'three/examples/jsm/math/Octree';
 
const worldOctree = new Octree();
scene.traverse((object) => {
  if (object.userData.collidable) {
    worldOctree.fromGraphNode(object);
  }
});
 
// 高效碰撞查询
const result = worldOctree.capsuleIntersect(playerCapsule);

Q2: 如何实现平滑的角色移动?

A: 使用插值和动画系统:

javascript
// 使用 Vector3.lerp 进行位置插值
const targetPosition = new THREE.Vector3(x, y, z);
character.position.lerp(targetPosition, 0.1);
 
// 使用四元数球面插值进行旋转
const targetQuaternion = new THREE.Quaternion();
targetQuaternion.setFromAxisAngle(axis, angle);
character.quaternion.slerp(targetQuaternion, 0.1);

Q3: 背包系统如何实现拖拽功能?

A: 结合 HTML5 拖拽 API:

javascript
// 添加拖拽事件
slot.draggable = true;
slot.addEventListener('dragstart', (e) => {
  e.dataTransfer.setData('text/plain', itemId);
});
slot.addEventListener('drop', (e) => {
  const itemId = e.dataTransfer.getData('text/plain');
  const targetSlot = e.target.dataset.slot;
  inventory.moveItem(itemId, targetSlot);
});

Q4: 如何优化大量 NPC 的渲染性能?

A: 推荐使用实例化渲染:

javascript
// 使用 InstancedMesh 渲染相同模型的 NPC
const geometry = new THREE.BoxGeometry(0.5, 1.8, 0.3);
const material = new THREE.MeshStandardMaterial();
const npcMesh = new THREE.InstancedMesh(geometry, material, 100);
 
// 更新每个 NPC 的变换矩阵
const matrix = new THREE.Matrix4();
npcs.forEach((npc, i) => {
  matrix.setPosition(npc.position.x, npc.position.y, npc.position.z);
  npcMesh.setMatrixAt(i, matrix);
});
npcMesh.instanceMatrix.needsUpdate = true;

Q5: 如何实现昼夜循环效果?

A: 通过动态调整光照和环境:

javascript
let timeOfDay = 0; // 0-24 小时制
 
function updateDayNightCycle(delta) {
  timeOfDay = (timeOfDay + delta * 0.1) % 24;
  
  // 太阳位置
  const sunAngle = (timeOfDay / 24) * Math.PI * 2 - Math.PI / 2;
  sunLight.position.set(
    Math.cos(sunAngle) * 100,
    Math.sin(sunAngle) * 100,
    0
  );
  
  // 天空颜色
  const dayColor = new THREE.Color(0x87ceeb);
  const nightColor = new THREE.Color(0x0a0a2e);
  const t = Math.max(0, Math.sin(sunAngle)); // 0-1
  
  scene.background = dayColor.clone().lerp(nightColor, 1 - t);
  
  // 光照强度
  sunLight.intensity = t * 1.5;
}

相关链接