Skip to content

237. 综合实战:开放世界(三十八)

Published:

上节加上了跳舞的女孩:

2026-03-08 22.19.00.gif

这节让玩家也跟着一起跳舞。

首先在 mian.js 加上一个对话的过程:

image.png

和之前与 npc 对话一样。

控制下对话框的隐藏、显示:

image.png

image.png

import './style.css';
import * as THREE from 'three';
import {
    OrbitControls
} from 'three/addons/controls/OrbitControls.js';
import { CSS3DRenderer, CSS2DRenderer } from 'three/examples/jsm/Addons.js';
import { loadCompletePromise } from './loading.js';
import mesh, { characterModel, playerBody, playerHeight, walkSound, setSoundEffectEnabled } from './mesh.js';
import car, { carModel, carBody, stopCarSound, setCarState } from './car.js';
import plane, { planeModel, planeBody, stopPlaneSound, setPlaneState } from './plane.js';
import house, { doorMesh, doorBody, setDoorState } from './house.js';
import person, { personModel, personBody } from './person.js';
import dancingMirrorHut, { updateDancingMirrorHut, dancingMirrorHutPosition } from './dancingMirrorHut.js';
import { isNearComputer, enterComputerView, exitComputerView } from './computer.js';
import { mapSystem, updateMapMarkers, toggleFullMap } from './map.js';
import { initWeatherSystem, updateWeather, setWeather, WeatherType, getWeatherSystem } from './weather.js';
import { saveGame, loadGame, hasSave, getSaveTimestamp } from './save.js';
import { setPlayerState } from './mesh.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);

scene.add(mesh);
scene.add(car);
scene.add(plane);
scene.add(house);
scene.add(person);
scene.add(dancingMirrorHut);

scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(20, 30, 10);
sun.castShadow = true;
sun.shadow.camera.left = -30;
sun.shadow.camera.right = 30;
sun.shadow.camera.top = 30;
sun.shadow.camera.bottom = -30;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
scene.add(sun);

const width = window.innerWidth;
const height = window.innerHeight;

const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 200);
camera.position.set(0, 1.6, 5);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({
  antialias: true
});
renderer.setSize(width, height)
renderer.shadowMap.enabled = true;

// 初始化天气系统
initWeatherSystem(scene, camera, renderer);

// 背景音乐
const backgroundMusic = new Audio(`${import.meta.env.BASE_URL}秋日私语.mp3`);
backgroundMusic.loop = true;
backgroundMusic.volume = 0.3; // 背景音乐音量设置为30%

// 在用户第一次交互时播放背景音乐(浏览器自动播放限制)
let musicStarted = false;
function startBackgroundMusic() {
  if (!musicStarted) {
    backgroundMusic.play().catch(err => {
      console.log('播放背景音乐失败:', err);
    });
    musicStarted = true;
  }
}

// 监听用户交互事件来启动背景音乐
document.addEventListener('click', startBackgroundMusic, { once: true });
document.addEventListener('keydown', startBackgroundMusic, { once: true });

// 设置面板控制
export let isSettingsOpen = false;
export let isManualOpen = false;
let soundEffectEnabled = true;
let backgroundMusicEnabled = true;
let miniMapEnabled = true;

function getSettings() {
  return { soundEffectEnabled, backgroundMusicEnabled, miniMapEnabled };
}
function setSettingsFromSave(s) {
  if (!s) return;
  soundEffectEnabled = s.soundEffectEnabled !== false;
  backgroundMusicEnabled = s.backgroundMusicEnabled !== false;
  miniMapEnabled = s.miniMapEnabled !== false;
  setSoundEffectEnabled(soundEffectEnabled);
  const bgMusicEl = document.getElementById('bgMusicToggle');
  const soundEl = document.getElementById('soundEffectToggle');
  const miniMapEl = document.getElementById('miniMapToggle');
  const miniMapEl2 = document.getElementById('miniMap');
  if (bgMusicEl) bgMusicEl.checked = backgroundMusicEnabled;
  if (soundEl) soundEl.checked = soundEffectEnabled;
  if (miniMapEl) miniMapEl.checked = miniMapEnabled;
  if (miniMapEl2) miniMapEl2.style.display = miniMapEnabled ? 'flex' : 'none';
  if (!backgroundMusicEnabled && musicStarted) backgroundMusic.pause();
  else if (backgroundMusicEnabled && musicStarted) backgroundMusic.play().catch(() => {});
}

function toggleSettings() {
  const settingsPanel = document.getElementById('settingsPanel');
  if (!settingsPanel) return;
  
  isSettingsOpen = !isSettingsOpen;
  settingsPanel.style.display = isSettingsOpen ? 'flex' : 'none';
  
  // 打开设置时更新存档状态提示
  if (isSettingsOpen) {
    const statusEl = document.getElementById('saveStatus');
    if (statusEl && !statusEl.textContent) {
      const ts = getSaveTimestamp();
      statusEl.textContent = ts ? `上次存档: ${new Date(ts).toLocaleString('zh-CN')}` : '暂无存档';
    }
  }
  
  // 当打开设置面板时,退出指针锁定模式
  if (isSettingsOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }
  
  // 更新当前天气按钮状态
  if (isSettingsOpen) {
    updateWeatherButtonStates();
  }
}

function toggleManual() {
  const manualPanel = document.getElementById('manualPanel');
  if (!manualPanel) return;
  isManualOpen = !isManualOpen;
  manualPanel.style.display = isManualOpen ? 'flex' : 'none';
  if (isManualOpen && document.pointerLockElement) {
    document.exitPointerLock();
  }
}

function updateWeatherButtonStates() {
  const weatherButtons = document.querySelectorAll('.weather-btn');
  const weatherSystem = getWeatherSystem();
  const currentWeather = weatherSystem ? weatherSystem.getCurrentWeather() : WeatherType.CLEAR;
  
  weatherButtons.forEach(btn => {
    const weather = btn.getAttribute('data-weather');
    let weatherType;
    
    switch(weather) {
      case 'clear':
        weatherType = WeatherType.CLEAR;
        break;
      case 'rain':
        weatherType = WeatherType.RAIN;
        break;
      case 'snow':
        weatherType = WeatherType.SNOW;
        break;
      case 'fog':
        weatherType = WeatherType.FOG;
        break;
      default:
        weatherType = WeatherType.CLEAR;
    }
    
    btn.classList.toggle('active', weatherType === currentWeather);
  });
}

// 强制退出载具/电脑,回到玩家步行状态(读档时使用)
function forceExitToPlayer() {
  if (isComputerView) {
    isComputerView = false;
    exitComputerView(camera, characterModel, css3Renderer);
  }
  if (isCarView) {
    isCarView = false;
    stopCarSound();
    if (carModel && characterModel && carBody && playerBody) {
      carModel.remove(camera);
      characterModel.visible = true;
      const carPosition = carBody.position;
      const forward = new THREE.Vector3();
      carModel.getWorldDirection(forward);
      forward.y = 0;
      forward.normalize();
      const left = new THREE.Vector3(forward.z, 0, -forward.x);
      const leftOffset = 2;
      playerBody.position.set(
        carPosition.x + left.x * leftOffset,
        playerHeight / 2,
        carPosition.z + left.z * leftOffset
      );
      playerBody.velocity.set(0, 0, 0);
      characterModel.position.copy(playerBody.position);
      characterModel.position.y -= playerHeight / 2;
      characterModel.rotation.y = carModel.rotation.y;
      characterModel.add(camera);
      camera.position.set(0, 1.5, 2.5);
      camera.rotation.set(0, 0, 0);
      camera.up.set(0, 1, 0);
    }
  }
  if (isPlaneView) {
    isPlaneView = false;
    stopPlaneSound();
    if (planeModel && characterModel && planeBody && playerBody) {
      planeModel.remove(camera);
      characterModel.visible = true;
      const planePosition = planeBody.position;
      const forward = new THREE.Vector3();
      planeModel.getWorldDirection(forward);
      forward.y = 0;
      forward.normalize();
      const left = new THREE.Vector3(forward.z, 0, -forward.x);
      const leftOffset = 2;
      playerBody.position.set(
        planePosition.x + left.x * leftOffset,
        playerHeight / 2,
        planePosition.z + left.z * leftOffset
      );
      playerBody.velocity.set(0, 0, 0);
      characterModel.position.copy(playerBody.position);
      characterModel.position.y -= playerHeight / 2;
      characterModel.rotation.y = planeModel.rotation.y;
      characterModel.add(camera);
      camera.position.set(0, 1.5, 2.5);
      camera.rotation.set(0, 0, 0);
      camera.up.set(0, 1, 0);
    }
  }
  isTalking = false;
  dialogueIndex = 0;
}

// 存档
function doSave() {
  if (isCarView || isPlaneView || isComputerView) {
    showSaveStatus('请先下车/下飞机/退出电脑后再存档', 'warning');
    return;
  }
  if (!confirm('确定要存档吗?')) return;
  const getters = {
    playerBody,
    carBody,
    planeBody,
    doorBody,
    characterModel,
    weather: () => getWeatherSystem()?.getCurrentWeather(),
    settings: getSettings
  };
  const result = saveGame(getters);
  if (result.success) {
    const time = new Date(result.timestamp).toLocaleString('zh-CN');
    showSaveStatus(`存档成功 (${time})`, 'success');
  } else {
    showSaveStatus('存档失败', 'error');
  }
}

// 读档
function doLoad() {
  if (!hasSave()) {
    showSaveStatus('暂无存档', 'error');
    return;
  }
  if (!confirm('确定要读档吗?当前进度将被覆盖。')) return;
  forceExitToPlayer();
  const setters = {
    setPlayerState,
    setCarState,
    setPlaneState,
    setDoorState,
    setWeather,
    setSettings: setSettingsFromSave
  };
  const result = loadGame(setters);
  if (result.success) {
    if (isSettingsOpen) updateWeatherButtonStates();
    const time = result.timestamp ? new Date(result.timestamp).toLocaleString('zh-CN') : '';
    showSaveStatus(`读档成功 (存档于 ${time})`, 'success');
  } else {
    showSaveStatus(result.reason || '读档失败', 'error');
  }
}

function showSaveStatus(msg, type = 'info') {
  // 设置面板内的状态
  const statusEl = document.getElementById('saveStatus');
  if (statusEl) {
    statusEl.textContent = msg;
    statusEl.className = 'save-status save-status-' + (type || 'info');
    if (msg) setTimeout(() => { statusEl.textContent = ''; }, 3000);
  }
  // 屏幕中央 Toast 提示(始终可见)
  const toast = document.getElementById('saveLoadToast');
  if (toast && msg) {
    toast.textContent = msg;
    toast.className = 'save-load-toast show ' + (type || 'info');
    clearTimeout(showSaveStatus._toastTimer);
    showSaveStatus._toastTimer = setTimeout(() => {
      toast.classList.remove('show');
    }, 2500);
  }
}

// 初始化设置面板
document.addEventListener('DOMContentLoaded', () => {
  const settingsBtn = document.getElementById('settingsBtn');
  const closeSettingsBtn = document.getElementById('closeSettingsBtn');
  const bgMusicToggle = document.getElementById('bgMusicToggle');
  const soundEffectToggle = document.getElementById('soundEffectToggle');
  const miniMapToggle = document.getElementById('miniMapToggle');
  const weatherButtons = document.querySelectorAll('.weather-btn');
  const settingsPanel = document.getElementById('settingsPanel');
  const miniMap = document.getElementById('miniMap');
  
  // 阻止设置面板内的点击事件冒泡,防止触发指针锁定
  if (settingsPanel) {
    settingsPanel.addEventListener('mousedown', (e) => {
      e.stopPropagation();
    });
    settingsPanel.addEventListener('click', (e) => {
      e.stopPropagation();
    });
  }
  
  if (settingsBtn) {
    settingsBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleSettings();
    });
  }
  
  if (closeSettingsBtn) {
    closeSettingsBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      toggleSettings();
    });
  }
  
  // 背景音乐开关
  if (bgMusicToggle) {
    bgMusicToggle.addEventListener('change', (e) => {
      backgroundMusicEnabled = e.target.checked;
      if (backgroundMusicEnabled && musicStarted) {
        backgroundMusic.play().catch(err => {
          console.log('播放背景音乐失败:', err);
        });
      } else {
        backgroundMusic.pause();
      }
    });
  }
  
  // 音效开关
  if (soundEffectToggle) {
    soundEffectToggle.addEventListener('change', (e) => {
      soundEffectEnabled = e.target.checked;
      setSoundEffectEnabled(soundEffectEnabled);
      // 如果关闭音效,立即停止所有音效
      if (!soundEffectEnabled) {
        stopCarSound();
        stopPlaneSound();
      }
    });
  }
  
  // 小地图开关
  if (miniMapToggle && miniMap) {
    // 初始化小地图显示状态
    miniMap.style.display = miniMapEnabled ? 'flex' : 'none';
    
    miniMapToggle.addEventListener('change', (e) => {
      miniMapEnabled = e.target.checked;
      miniMap.style.display = miniMapEnabled ? 'flex' : 'none';
    });
  }
  
  // 存档/读档按钮
  const saveBtn = document.getElementById('saveGameBtn');
  const loadBtn = document.getElementById('loadGameBtn');
  if (saveBtn) saveBtn.addEventListener('click', () => { doSave(); });
  if (loadBtn) loadBtn.addEventListener('click', () => { doLoad(); });

  // 使用手册
  const manualBtn = document.getElementById('manualBtn');
  const closeManualBtn = document.getElementById('closeManualBtn');
  const manualPanel = document.getElementById('manualPanel');
  if (manualBtn) manualBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleManual(); });
  if (closeManualBtn) closeManualBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleManual(); });
  if (manualPanel) {
    manualPanel.addEventListener('mousedown', (e) => e.stopPropagation());
    manualPanel.addEventListener('click', (e) => e.stopPropagation());
  }

  // 天气切换按钮
  weatherButtons.forEach(btn => {
    btn.addEventListener('click', () => {
      const weather = btn.getAttribute('data-weather');
      let weatherType;
      
      switch(weather) {
        case 'clear':
          weatherType = WeatherType.CLEAR;
          break;
        case 'rain':
          weatherType = WeatherType.RAIN;
          break;
        case 'snow':
          weatherType = WeatherType.SNOW;
          break;
        case 'fog':
          weatherType = WeatherType.FOG;
          break;
        default:
          weatherType = WeatherType.CLEAR;
      }
      
      setWeather(weatherType);
      
      // 更新按钮状态
      updateWeatherButtonStates();
    });
  });
});

const css3Renderer = new CSS3DRenderer();
css3Renderer.setSize(width, height);
css3Renderer.domElement.style.position = 'absolute';
css3Renderer.domElement.style.top = '0px';
css3Renderer.domElement.style.left = '0px';
css3Renderer.domElement.style.pointerEvents = 'none';

const css2Renderer = new CSS2DRenderer();
  css2Renderer.setSize(width, height);
  css2Renderer.domElement.style.position = 'absolute';
  css2Renderer.domElement.style.top = '0px';
  css2Renderer.domElement.style.left = '0px';
  css2Renderer.domElement.style.pointerEvents = 'none';
  css2Renderer.domElement.style.zIndex = '1';

document.body.append(renderer.domElement);
document.body.append(css3Renderer.domElement);
document.body.append(css2Renderer.domElement);

// const controls = new OrbitControls(camera, renderer.domElement);

// 车辆视角切换
export let isCarView = false;

// 飞机视角切换
export let isPlaneView = false;

// 电脑视角切换
export let isComputerView = false;

// 对话状态
export let isTalking = false;
export let isTalkingToDancer = false;

// 对话系统
const dialogueData = [
    { player: "你好!", npc: "哦,你好!看起来你是个战士?" },
    { player: "是的,我在执行任务。你坐在这里做什么?", npc: "哈哈,我在思考人生。这个桶很舒服,你要不要也坐坐?" },
    { player: "不了,我还有任务要完成。", npc: "任务?听起来很严肃。不过你知道吗,有时候停下来看看风景也不错。" },
    { player: "也许你说得对...但我得走了。", npc: "好吧,祝你好运!如果累了,随时可以回来找我聊天。" },
    { player: "谢谢!再见!", npc: "再见,战士!" }
];

let dialogueIndex = 0;

const dancerDialogueData = [
    { player: "你好!", npc: "嗨~你也来看镜子呀?" },
    { player: "嗯,你跳得真好。", npc: "谢谢!我天天在这儿练,你要不要也试试?" },
    { player: "我也可以跳吗?", npc: "当然呀,我教你!很简单的。" },
    { player: "好啊,教教我。", npc: "那你准备好哦,按 D 开始跳舞~" }
];

let dancerDialogueIndex = 0;

// 检查是否靠近镜屋女孩
function isNearDancer() {
    if (!characterModel || !dancingMirrorHutPosition) return false;
    const p = characterModel.position;
    const dx = p.x - dancingMirrorHutPosition.x;
    const dz = p.z - dancingMirrorHutPosition.z;
    return Math.sqrt(dx * dx + dz * dz) < 3;
}

// 检查人物是否在车辆附近
function isNearCar() {
    if (!characterModel || !carBody) return false;

    const characterPos = characterModel.position;
    const carPos = carBody.position;

    const distance = Math.sqrt(
        Math.pow(characterPos.x - carPos.x, 2) +
        Math.pow(characterPos.z - carPos.z, 2)
    );

    return distance < 3; // 3米范围内
}

// 检查人物是否在飞机附近
function isNearPlane() {
    if (!characterModel || !planeBody) return false;

    const characterPos = characterModel.position;
    const planePos = planeBody.position;

    const distance = Math.sqrt(
        Math.pow(characterPos.x - planePos.x, 2) +
        Math.pow(characterPos.z - planePos.z, 2)
    );

    return distance < 3; // 3米范围内
}

// 检查玩家是否靠近NPC人物
function isNearPerson() {
    if (!characterModel || !personBody) return false;

    const characterPos = characterModel.position;
    const personPos = personBody.position;

    const distance = Math.sqrt(
        Math.pow(characterPos.x - personPos.x, 2) +
        Math.pow(characterPos.z - personPos.z, 2)
    );

    return distance < 3; // 3米范围内
}

// 更新提示文本(仅显示当前情境下的操作提示)
function updateViewTip() {
    const tipElement = document.getElementById('viewTip');
    if (!tipElement) return;

    if (isComputerView) {
        tipElement.textContent = '按 E 退出';
    } else if (isCarView) {
        tipElement.textContent = '按 X 下车';
    } else if (isPlaneView) {
        if (planeBody && planeBody.position.y > 2.15) {
            tipElement.textContent = '空格上升 Shift下降 · 先降落再按 C 下飞机';
        } else {
            tipElement.textContent = '空格上升 Shift下降 · 按 C 下飞机';
        }
    } else if (isNearCar()) {
        tipElement.textContent = '按 X 上车';
    } else if (isNearPlane()) {
        tipElement.textContent = '按 C 上飞机';
    } else if (isNearPerson()) {
        if (isTalking) {
            tipElement.textContent = dialogueIndex < dialogueData.length ? '按 H 继续 按 K 结束' : '按 H 重新开始 按 K 结束';
        } else {
            tipElement.textContent = '按 H 对话';
        }
    } else if (isNearDancer()) {
        if (isTalkingToDancer) {
            if (dancerDialogueIndex < dancerDialogueData.length) {
                tipElement.textContent = '按 H 继续 按 K 结束';
            } else {
                tipElement.textContent = '按 D 开始跳舞 | 按 K 结束对话';
            }
        } else {
            tipElement.textContent = '按 H 对话';
        }
    } else if (isNearComputer(characterModel)) {
        tipElement.textContent = '按 E 使用电脑';
    } else {
        tipElement.textContent = '';
    }
    tipElement.style.display = tipElement.textContent ? 'block' : 'none';
}

// 控制对话框显示和内容
function updateDialogs() {
    const personDialog = document.getElementById('personDialog');
    const playerDialog = document.getElementById('playerDialog');
    
    const inPersonTalk = isTalking && isNearPerson() && !isCarView && !isPlaneView && !isComputerView;
    const inDancerTalk = isTalkingToDancer && isNearDancer() && !isCarView && !isPlaneView && !isComputerView;

    if (inPersonTalk) {
        if (personDialog && playerDialog) {
            if (dialogueIndex < dialogueData.length) {
                personDialog.textContent = dialogueData[dialogueIndex].npc;
                playerDialog.textContent = dialogueData[dialogueIndex].player;
                personDialog.style.display = 'block';
                playerDialog.style.display = 'block';
            } else {
                personDialog.style.display = 'none';
                playerDialog.style.display = 'none';
            }
        }
    } else if (inDancerTalk) {
        const dancerDialog = document.getElementById('dancerDialog');
        if (dancerDialog && playerDialog) {
            if (dancerDialogueIndex < dancerDialogueData.length) {
                dancerDialog.textContent = dancerDialogueData[dancerDialogueIndex].npc;
                playerDialog.textContent = dancerDialogueData[dancerDialogueIndex].player;
                dancerDialog.style.display = 'block';
                playerDialog.style.display = 'block';
            } else {
                dancerDialog.style.display = 'none';
                playerDialog.style.display = 'none';
            }
        }
    } else {
        if (personDialog) personDialog.style.display = 'none';
        const dancerDialog = document.getElementById('dancerDialog');
        if (dancerDialog) dancerDialog.style.display = 'none';
        if (playerDialog) playerDialog.style.display = 'none';
        if (!isNearPerson()) {
            isTalking = false;
            dialogueIndex = 0;
        }
        if (!isNearDancer()) {
            isTalkingToDancer = false;
            dancerDialogueIndex = 0;
        }
    }
}

// 渲染循环
function render() {
    renderer.render(scene, camera);
    css3Renderer.render(scene, camera);
    css2Renderer.render(scene, camera);
    updateViewTip();
    updateDialogs();
    
    // 同步门的视觉和物理状态(保留物理效果)
    if (doorMesh && doorBody) {
        doorMesh.position.copy(doorBody.position);
        doorMesh.quaternion.copy(doorBody.quaternion);
    }
    
    // 更新地图标记和绘制
    updateMapMarkers(characterModel, carModel, planeModel, personModel, camera, isCarView, isPlaneView);
    updateDancingMirrorHut();
    mapSystem.update();
    
    // 更新天气系统
    updateWeather();
    
    requestAnimationFrame(render);
}

// 全部加载完成后再显示游戏并开始渲染
loadCompletePromise.then(() => {
    const overlay = document.getElementById('loadingOverlay');
    if (overlay) overlay.classList.add('hidden');
    render();
});

// 窗口大小调整处理
window.addEventListener('resize', () => {
  const width = window.innerWidth;
  const height = window.innerHeight;
  
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  
  renderer.setSize(width, height);
  css3Renderer.setSize(width, height);
  css2Renderer.setSize(width, height);
});

// 监听键盘事件
window.addEventListener('keydown', (event) => {
    if (event.key === 'x' || event.key === 'X') {
        if (isCarView) {
            // 在车辆视角时,可以随时下车
            isCarView = false;
            stopCarSound(); // 停止开车音效
            if (carModel && characterModel && carBody && playerBody) {
                carModel.remove(camera);

                // 显示人物模型
                characterModel.visible = true;

                // 将人物放置在车辆左边
                const carPosition = carBody.position;

                // 获取车辆的前进方向
                const forward = new THREE.Vector3();
                carModel.getWorldDirection(forward);
                forward.y = 0;
                forward.normalize();

                // 计算车辆左侧方向(前进方向顺时针旋转90度)
                const left = new THREE.Vector3(forward.z, 0, -forward.x);

                // 计算车辆左侧的位置(距离车辆2米)
                const leftOffset = 2;
                const leftX = carPosition.x + left.x * leftOffset;
                const leftZ = carPosition.z + left.z * leftOffset;

                // 更新人物物理体和模型位置
                playerBody.position.set(leftX, playerHeight / 2, leftZ);
                playerBody.velocity.set(0, 0, 0);
                characterModel.position.copy(playerBody.position);
                characterModel.position.y -= playerHeight / 2;

                // 设置人物朝向与车辆一致
                characterModel.rotation.y = carModel.rotation.y;

                characterModel.add(camera);
                // 恢复为人物行走时的相机设置
                camera.position.set(0, 1.5, 2.5);
                camera.rotation.set(0, 0, 0);
                camera.up.set(0, 1, 0);
            }
        } else if (isNearCar()) {
            // 只有在车辆附近才能上车
            isCarView = true;
            if (carModel && characterModel) {
                characterModel.remove(camera);

                // 隐藏人物模型
                characterModel.visible = false;

                carModel.add(camera);
                // 相机在车后面,看向车辆前进方向
                camera.position.set(0, 3, -6);
                camera.rotation.set(-0.1, Math.PI, 0); // 稍微向下看约6度,主要朝向前方
            }
        }
    } else if (event.key === 'c' || event.key === 'C') {
        if (isPlaneView) {
            // 在飞机视角时,需要先降落再下飞机
            if (planeModel && characterModel && planeBody && playerBody) {
                const planeHeight = planeBody.position.y;
                const groundHeight = 1.15; // 飞机在地面时的高度

                // 如果飞机在空中(高于地面1米以上),提示需要先降落
                if (planeHeight > groundHeight + 1) {
                    return;
                }

                // 飞机在地面或接近地面时,可以下飞机
                isPlaneView = false;
                stopPlaneSound(); // 停止开飞机音效
                planeModel.remove(camera);

                // 显示人物模型
                characterModel.visible = true;

                // 将人物放置在飞机左边
                const planePosition = planeBody.position;

                // 获取飞机的前进方向
                const forward = new THREE.Vector3();
                planeModel.getWorldDirection(forward);
                forward.y = 0;
                forward.normalize();

                // 计算飞机左侧方向(前进方向顺时针旋转90度)
                const left = new THREE.Vector3(forward.z, 0, -forward.x);

                // 计算飞机左侧的位置(距离飞机2米)
                const leftOffset = 2;
                const leftX = planePosition.x + left.x * leftOffset;
                const leftZ = planePosition.z + left.z * leftOffset;

                // 更新人物物理体和模型位置
                playerBody.position.set(leftX, playerHeight / 2, leftZ);
                playerBody.velocity.set(0, 0, 0);
                characterModel.position.copy(playerBody.position);
                characterModel.position.y -= playerHeight / 2;

                // 设置人物朝向与飞机一致
                characterModel.rotation.y = planeModel.rotation.y;

                characterModel.add(camera);
                // 恢复为人物行走时的相机设置
                camera.position.set(0, 1.5, 2.5);
                camera.rotation.set(0, 0, 0);
                camera.up.set(0, 1, 0);
            }
        } else if (isNearPlane()) {
            // 只有在飞机附近才能上飞机
            isPlaneView = true;
            if (planeModel && characterModel) {
                characterModel.remove(camera);

                // 隐藏人物模型
                characterModel.visible = false;

                planeModel.add(camera);
                // 相机在飞机后面,看向飞机前进方向
                camera.position.set(0, 3, -6);
                camera.rotation.set(-0.1, Math.PI, 0); // 稍微向下看约6度,主要朝向前方
            }
        }
    } else if (event.key === 'e' || event.key === 'E') {
        if (isComputerView) {
            // 退出电脑模式
            isComputerView = false;
            exitComputerView(camera, characterModel, css3Renderer);
        } else if (isNearComputer(characterModel)) {
            // 进入电脑模式
            isComputerView = true;
            enterComputerView(camera, scene, css3Renderer, characterModel);
        }
    } else if (event.key === 'h' || event.key === 'H') {
        if (isNearPerson() && !isNearDancer() && !isPlaneView && !isCarView && !isComputerView) {
            if (!isTalking) {
                isTalking = true;
                dialogueIndex = 0;
            } else {
                if (dialogueIndex < dialogueData.length - 1) {
                    dialogueIndex++;
                } else {
                    dialogueIndex = 0;
                }
            }
        } else if (isNearDancer() && !isPlaneView && !isCarView && !isComputerView) {
            if (!isTalkingToDancer) {
                isTalkingToDancer = true;
                dancerDialogueIndex = 0;
            } else {
                if (dancerDialogueIndex < dancerDialogueData.length - 1) {
                    dancerDialogueIndex++;
                } else {
                    dancerDialogueIndex = 0;
                }
            }
        }
    } else if (event.key === 'k' || event.key === 'K') {
        if (isNearPerson() && isTalking && !isPlaneView && !isCarView && !isComputerView) {
            isTalking = false;
            dialogueIndex = 0;
        }
        if (isNearDancer() && isTalkingToDancer && !isPlaneView && !isCarView && !isComputerView) {
            isTalkingToDancer = false;
            dancerDialogueIndex = 0;
        }
    } else if (event.key === 'm' || event.key === 'M') {
        // M 键处理:切换全屏地图(在非电脑模式下)
        if (!isComputerView) {
            toggleFullMap();
        }
    } else if (event.key === '1') {
        // 数字键1:晴天
        setWeather(WeatherType.CLEAR);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.key === '2') {
        // 数字键2:雨天
        setWeather(WeatherType.RAIN);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.key === '3') {
        // 数字键3:雪天
        setWeather(WeatherType.SNOW);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.key === '4') {
        // 数字键4:雾天
        setWeather(WeatherType.FOG);
        if (isSettingsOpen) updateWeatherButtonStates();
    } else if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 's') {
        event.preventDefault();
        doSave();
    } else if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'l') {
        event.preventDefault();
        doLoad();
    } else if (event.key === '?' || event.key === '?') {
        toggleManual();
    } else if (event.key === 'p' || event.key === 'P') {
        if (!isComputerView) toggleSettings();
    } else if (event.key === 'Escape') {
        if (isManualOpen) toggleManual();
        else if (isSettingsOpen) toggleSettings();
    }
});

export { camera }

在 index.html 加一下这个对话框:

image.png

<div id="dancerDialog" style="display:none;" class="dialog"></div>

在 dancingMiorrorHut.js 根据人物设置下对话框的位置:

image.png

  const dancerDialogEl = document.getElementById('dancerDialog');
  if (dancerDialogEl) {
    const dancerDialogObj = new CSS2DObject(dancerDialogEl);
    dancerDialogObj.position.set(0, dancerHeight + 0.3, 0);
    dancer.add(dancerDialogObj);
    dancerDialogEl.style.display = 'none';
  }

试一下:

2026-03-08 22.33.08.gif

这样,对话完成后按 D 就可以跳舞了,具体跳舞逻辑下节再做。

案例代码上传了小册仓库

总结

这节我们实现了和跳舞女孩的对话,具体实现和之前与 npc 对话一样。

对话之后就可以跳舞了,下节实现跳舞。

评论