Skip to content

221. 综合实战:开放世界(二十二)

Published:

上节加上了一个 npc:

2026-02-01 21.18.34.gif

并做了物理效果。

这节加上对话功能。

对话效果和之前这个类似:

2025-04-08 18.40.03.gif

用 CSS2DRenderer 就行。

改下 mian.js,创建 CSS2DRenderer

image.png

它也是用 div + css 渲染,把它渲染的内容加到 body 下

加一个对话状态的标识:

image.png

和之前打电脑、走近汽车、飞机一样,加一个状态:

image.png

image.png

走近的时候提示按 H 开始、结束对话。

然后加一下那个对话框的显示、隐藏逻辑:

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 mesh, { characterModel, playerBody, playerHeight } from './mesh.js';
import car, { carModel, carBody } from './car.js';
import plane, { planeModel, planeBody } from './plane.js';
import house, { doorMesh, doorBody } from './house.js';
import person, { personModel, personBody } from './person.js';
import { isNearComputer, enterComputerView, exitComputerView } from './computer.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(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;

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;

// 检查人物是否在车辆附近
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) {
            const planeHeight = planeBody.position.y;
            const groundHeight = 1.15;
            if (planeHeight > groundHeight + 1) {
                tipElement.textContent = '空格键上升 | Shift键下降 | 请先降落再按 C 下飞机';
            } else {
                tipElement.textContent = '空格键上升 | Shift键下降 | 按 C 下飞机';
            }
        } else {
            tipElement.textContent = '按 C 下飞机';
        }
    } else if (isNearCar()) {
        tipElement.textContent = '按 X 上车';
    } else if (isNearPlane()) {
        tipElement.textContent = '按 C 上飞机';
    } else if (isNearPerson()) {
        if (isTalking) {
            tipElement.textContent = '按 H 键结束对话';
        } else {
            tipElement.textContent = '按 H 键开始对话';
        }
    } else if (isNearComputer(characterModel)) {
        tipElement.textContent = '按 E 打电脑';
    } else {
        tipElement.textContent = '靠近车辆按 X 上车 | 靠近飞机按 C 上飞机 | 靠近电脑按 E 打电脑';
    }
}

// 控制对话框显示
function updateDialogs() {
    const personDialog = document.getElementById('personDialog');
    const playerDialog = document.getElementById('playerDialog');
    
    // 只有在对话状态且靠近人物时才显示对话框
    if (isTalking && isNearPerson() && !isCarView && !isPlaneView && !isComputerView) {
        if (personDialog) {
            personDialog.style.display = 'block';
        }
        if (playerDialog) {
            playerDialog.style.display = 'block';
        }
    } else {
        // 其他情况隐藏对话框
        if (personDialog) {
            personDialog.style.display = 'none';
        }
        if (playerDialog) {
            playerDialog.style.display = 'none';
        }
        // 如果远离人物,结束对话状态
        if (!isNearPerson()) {
            isTalking = false;
        }
    }
}

// 渲染循环
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);
    }
    
    requestAnimationFrame(render);
}

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;
            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;
                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') {
        // H 键处理:如果靠近人物,则用于对话
        if (isNearPerson() && !isPlaneView && !isCarView && !isComputerView) {
            isTalking = !isTalking; // 切换对话状态
        }
    }
});

export { camera }

之后在 person.js 创建一个对话框:

image.png

personModel = gltf.scene;

// 创建对话框
const dialogElement = document.getElementById('personDialog');
if (dialogElement) {
    const dialogObject = new CSS2DObject(dialogElement);
    dialogObject.position.set(0.5, personHeight + 0.5, 0); // 在人物头顶上方,向右调整
    gltf.scene.add(dialogObject);
    dialogElement.style.display = 'none'; // 默认隐藏
}

console.log('Person model loaded:', gltf);

mesh.js 创建另外一个:

image.png

// 创建玩家对话框
const playerDialogElement = document.getElementById('playerDialog');
if (playerDialogElement) {
    const playerDialogObject = new CSS2DObject(playerDialogElement);
    playerDialogObject.position.set(0, playerHeight + 0.2, 0); // 在玩家头顶上方,向下调整
    characterModel.add(playerDialogObject);
    playerDialogElement.style.display = 'none'; // 默认隐藏
}

在 index.html 加一下对话框的内容,也就是刚才两个对话框的 CSS2DObject 的内容

image.png

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>open-world</title>
    <style>
      .dialog {
        line-height: 40px;
        text-align: center;
        font-size: 20px;
        padding: 10px;
        border: 1px solid #000;
        background: #fff;
        border-radius: 4px;
        white-space: nowrap;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <div id="viewTip">靠近车辆,按 X 上车</div>
    <div id="personDialog" style="display:none;" class="dialog">
      你好,我是NPC!
    </div>
    <div id="playerDialog" style="display:none;" class="dialog">
      你好!
    </div>
    <div id="desktop" style="display: none;">
      <img class="bg" src="./bg.png"/>
      <div class="app">
        <div class="logo"></div>
        <div class="name">浏览器</div>
      </div>
      <iframe class="browser" style="display: none;" src="/assets/threejs/b5c7c27c462211378dfee368618551a67209cb7c.image"></iframe>
    </div>
    <script type="module" src="/src/main.js"></script>
    <script>
      document.addEventListener('DOMContentLoaded', function() {
        const appElement = document.querySelector('#desktop .app');
        const browserElement = document.querySelector('#desktop .browser');
        if (appElement && browserElement) {
          appElement.addEventListener('dblclick', function() {
            browserElement.style.display = browserElement.style.display === 'none' ? 'block' : 'none';
          });
        }
      });
    </script>
  </body>
</html>

试一下:

2026-02-01 22.22.52.gif

这样人物和 npc 就可以对话了。

案例代码上传了小册仓库

总结

这节我们用 CSS2DRenderer + CSS2DObject 实现了对话框功能。

当玩家走近 npc 的时候,按 h 开始对话、结束对话。

这样可以和 npc 交流了,当然现在只是一次对话,实际上应该可以持续多次,我们下节继续完善。

评论