交互场景案例
概述
本章将介绍如何使用 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 | 物品存储管理 | 堆叠、使用、拖拽排序 |
| Dialogue | NPC 对话系统 | 多分支对话、条件触发、历史记录 |
| Quest | 任务追踪系统 | 目标追踪、进度显示、奖励发放 |
| Audio | 3D 音效系统 | 空间音效、音量控制、背景音乐 |
项目结构
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 接口说明
Navigation API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
setCharacter(character) | character: THREE.Object3D | void | 设置控制角色 |
toggleViewMode() | - | void | 切换第一/第三人称视角 |
setTargetPosition(position) | position: THREE.Vector3 | void | 设置点击移动目标 |
update(delta) | delta: number | void | 更新移动状态 |
Interaction API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
registerInteractive(object, config) | object: THREE.Object3D, config: InteractionConfig | void | 注册可交互物体 |
unregisterInteractive(object) | object: THREE.Object3D | void | 取消注册交互物体 |
focusCameraOn(position, duration) | position: THREE.Vector3, duration?: number | void | 相机聚焦动画 |
Inventory API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
addItem(item) | item: InventoryItem | boolean | 添加物品(返回是否成功) |
removeItem(itemId) | itemId: string | boolean | 移除物品 |
getItem(itemId) | itemId: string | InventoryItem | 获取物品信息 |
hasItem(itemId) | itemId: string | boolean | 检查是否拥有物品 |
selectItem(itemId) | itemId: string | void | 选中物品 |
useSelected(target) | target: THREE.Object3D | void | 使用选中物品 |
Dialogue API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
start(dialogueData, character) | dialogueData: DialogueData, character: THREE.Object3D | void | 开始对话 |
selectChoice(index) | index: number | void | 选择对话选项 |
next() | - | void | 下一段对话 |
end() | - | void | 结束对话 |
isActive() | - | boolean | 对话是否进行中 |
Quest API
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
addQuest(questData) | questData: QuestData | void | 添加任务 |
startQuest(questId) | questId: string | boolean | 开始任务 |
updateObjective(type, targetId, amount) | type: string, targetId: string, amount?: number | void | 更新任务目标 |
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: 建议采用以下优化方案:
- 使用简化的碰撞体(Box/Sphere)代替精确网格
- 对静态物体使用 BVH 加速结构
- 减少每帧的射线检测次数
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;
}