Skip to content

1. 第一人称射击 ​

loading

游戏开发对地图框架的性能是一种考验,也是最好的测试。游戏具有极高的实时性,任何卡顿在游戏中将会暴露无遗。

  • three-tile 的地图支持全球海量地形,所以它不能像常规游戏那样在启动时进行数据预加载和预处理,所有地图数据都是实时加载的。在鼠标的移动中,地图视角会以极快速度切换,大量地图瓦片实时下载、解析、渲染,十分考验框架性能,如果游戏能流畅地在地图中运行,那框架性能就足以应付绝大多数应用了。

  • 编写 three-tile 最初的目标其实是开发游戏,地图只能算是 three-tile 的一个扩展,但目前地图的应用场景远多于游戏,所以现在框架的重点已经偏向于地图开发了。

1. 更换控制器 ​

第一人称 FPS 游戏以玩家视角渲染地图,模拟玩家看到的真实世界,与常规地图相比,仅是使用的控制器不同。

为了方便用户使用,three-tile 对第一人称场景进行了封装:PLViewer,使用PLViewer初始化场景即可。

three-tile 的地图不依赖 camera、controls,所以可以任意更换控制器,第一人称视角,只需要将控制器更换为 threejs 的 PointerLockControls:

https://threejs.org/docs/?q=controls#examples/zh/controls/PointerLockControls

修改 GLViewer 将控制器换成 PointerLockControls,可参考:

https://threejs.org/examples/?q=controls#misc_controls_pointerlock

TIP

计划下个版本增加使用 PointerLockControls 控制器的 Viewer (V0.11.4 已内置)。

2. 射击 ​

使用 PointerLockControls 控制器,就能有一个第一人称视角的三维地图,只需要在鼠标单击时创建一个球飞出去就行了,不到 100 行代码。

监听地图容器的 pointerdown 事件,当鼠标单击地图时:

1. 创建一个球体作为炮弹加入地图
2. 球的起点为炮口坐标
3. 球的终点为屏幕中点(准星处)的地面坐标
4. 调用 Tween 动画,将球从起点动画移动到终点
ts
/**
 * 射击初始化选项
 */
export interface FireOptions {
    /** 点击开火回调 */
    onfire?: (obj: Object3D) => void;
    /** 命中回调 */
    onhit?: (obj: Object3D) => void;
    /** 获取炮口在相机局部空间的坐标(缺省为摄像机位置) */
    getMuzzleLocal?: () => Vector3;
}

/**
 * 第一人称射击初始化
 * @param viewer
 * @param map
 * @param options 射击选项
 */
export const fireInit = (viewer: PLViewer, map: tt.TileMap, options: FireOptions = {}) => {
    const { onfire, onhit, getMuzzleLocal } = options;
    const { camera, controls, renderer } = viewer;

    const bombGroup = new Group();
    map.add(bombGroup);

    // 创建一个球
    const ballTexture = new TextureLoader().load("../../images/ballTexture.jpg");
    ballTexture.colorSpace = "srgb";
    const ball = new Mesh(
        new SphereGeometry(400),
        new MeshStandardMaterial({
            map: ballTexture,
            roughness: 0.2,
            metalness: 0.5,
            transparent: true,
        })
    );

    // 监听pointerdown
    renderer.domElement.addEventListener("pointerdown", (evt) => {
        if (!controls.isLocked) {
            controls.lock();
            return;
        } else if (evt.button === 2) {
            controls.unlock();
            return;
        }
        // clone一个球
        const aBall = ball.clone();
        bombGroup.add(aBall);

        onfire?.(aBall);

        // 球起点坐标:从炮口开始(缺省为摄像机位置)
        const muzzleLocal = getMuzzleLocal?.() ?? new Vector3();
        const startPosition = map.worldToLocal(camera.localToWorld(muzzleLocal));
        aBall.position.copy(startPosition);

        // 取得视线与地面交点
        const crossPoint = map.getLocalInfoFromScreen(camera, new Vector2(0, 0));
        // 球终点坐标:视线与地面有交点则取交点,无交点则取摄像机前方100km
        const endPosition = crossPoint
            ? map.worldToLocal(crossPoint.point)
            : map.worldToLocal(camera.localToWorld(new Vector3(0, 0, -100 * 1000)));

        // 球飞行时间
        const delay = 800;
        // 球直线飞行动画
        new Tween(aBall.position).to(endPosition, delay).easing(Easing.Quadratic.Out).start();
        // 球减速下落动画(模拟重力作用)
        new Tween(aBall.position)
            .to({ z: endPosition.z }, delay)
            .easing(crossPoint ? Easing.Quadratic.In : Easing.Quartic.Out)
            .start()
            .onComplete(() => {
                // 动画完成延迟销毁球
                const dispose = () => {
                    aBall.removeFromParent();
                    aBall.geometry.dispose();
                    aBall.material.dispose();
                };
                if (crossPoint) {
                    setTimeout(dispose, 10 * delay);
                } else {
                    dispose();
                }
            });
    });
};

3. 炮管 ​

用 threejs 几何体渲染第一人称炮管:炮管位于画面底部正中、上仰指向准星,开火时播放后坐力动画。

核心思路是独立场景叠加渲染——炮管放在单独的 Scene + 独立相机中,在主场景渲染完成后叠加绘制,这样不受主相机近裁剪面(地图相机 near 较大)限制,也不会被地图物体遮挡:

ts
// 主场景渲染完成后叠加渲染炮管
const onUpdate = () => {
    const prevAutoClear = renderer.autoClear;
    renderer.autoClear = false;
    renderer.clearDepth();
    renderer.render(scene, cannonCamera);
    renderer.autoClear = prevAutoClear;
};
viewer.addEventListener("update", onUpdate);

炮管本体由圆柱(炮管、制退器、细节环)与方块(炮尾防盾)组成,整组缩小并绕 x 轴上仰约 0.305rad,朝向准星但炮口停在准星下方:

ts
const group = new Group();
group.position.set(0, -0.32, -0.4);
group.rotation.x = 0.305;
group.scale.setScalar(0.55);

const barrel = new Mesh(new CylinderGeometry(0.055, 0.07, 0.9, 24), barrelMaterial);
barrel.geometry.rotateX(Math.PI / 2);
barrel.position.z = -0.45;
group.add(barrel);

开火时在 onfire 回调里触发射管后坐力(沿-z 后移再回弹):

ts
const playRecoil = () => {
    recoilTween?.stop();
    group.position.z = CANNON_BASE_Z;
    recoilTween = new Tween(group.position)
        .to({ z: CANNON_BASE_Z + RECOIL_OFFSET }, 50)
        .easing(Easing.Quadratic.Out)
        .yoyo(true)
        .repeat(1)
        .start();
};

TIP

独立叠加场景的相机 near 可以设得很小(如 0.01),炮尾贴近相机也不会被裁剪;画布尺寸变化时同步 cannonCamera.aspect 即可。

Released under the MIT License.