chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json. Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser Stand als normaler Ordner im Repo, ohne Verweis auf GitHub. dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher lokal gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -1,3 +0,0 @@
|
||||
[submodule "three-player-controller"]
|
||||
path = three-player-controller
|
||||
url = https://github.com/hh-hang/three-player-controller.git
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "three-player-controller",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"url": "http://localhost:5173"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build examples
|
||||
run: npm run build:example
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,19 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# build output
|
||||
dist/
|
||||
docs/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# editor
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,28 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in [three-player-controller](https://github.com/hh-hang/three-player-controller). Bug reports, ideas, and pull requests are welcome.
|
||||
|
||||
Before larger changes, please open or search an [Issue](https://github.com/hh-hang/three-player-controller/issues) first.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/hh-hang/three-player-controller.git
|
||||
cd three-player-controller
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173/three-player-controller/`.
|
||||
|
||||
- Library code: `src/`
|
||||
- Demos: `example/`
|
||||
|
||||
Use `npm run build` to verify the library builds.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Branch from `master` and keep the PR focused
|
||||
2. Make sure related demos work under `npm run dev`, and `npm run build` succeeds
|
||||
3. Update `README.md` / `README_En.md` if public APIs change
|
||||
4. Describe the change and how you tested it
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 屈航
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>three-player-controller</title>
|
||||
<link rel="stylesheet" href="./styles/common.css" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#container {
|
||||
overflow: hidden !important;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
<script src="./loader.js" data-demo="3DGS Scene"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
<div class="hud" role="note" aria-label="Controls">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Move</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Boost</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Jump</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>F</kbd></span>
|
||||
<span class="hint-text">Toggle Fly</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Z</kbd></span>
|
||||
<span class="hint-text">Scale Character</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-credit">
|
||||
3DGS:
|
||||
<a href="https://huggingface.co/datasets/spatialverse/InteriorGS/tree/main/0267_840790" target="_blank" rel="noopener noreferrer">Huggingface</a>
|
||||
</div>
|
||||
|
||||
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/3dgs.js" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./img/source.svg" alt="" />
|
||||
</a>
|
||||
|
||||
<script type="module" src="./control-hints.js"></script>
|
||||
<script type="module" src="./3dgs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
ACESFilmicToneMapping,
|
||||
AmbientLight,
|
||||
EquirectangularReflectionMapping,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { MapControls } from "three/examples/jsm/Addons.js";
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js";
|
||||
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
|
||||
import { playerController } from "../src/PlayerController";
|
||||
import { SplatMesh, SparkRenderer } from "@sparkjsdev/spark";
|
||||
import Stats from 'three/examples/jsm/libs/stats.module.js';
|
||||
|
||||
let player;
|
||||
const scene = new Scene();
|
||||
|
||||
let camera;
|
||||
let renderer;
|
||||
let controls;
|
||||
let gltfLoader;
|
||||
let stats;
|
||||
|
||||
const pos = new Vector3(1.235, 1.21, -3.9);
|
||||
|
||||
const scaleNormal = 0.01;
|
||||
const scaleSmall = scaleNormal / 9;
|
||||
let isSmallScale = false;
|
||||
let scaleAnimFrame = null;
|
||||
let isScaling = false;
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
const cont = document.querySelector("#container");
|
||||
|
||||
// 渲染器
|
||||
renderer = new WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(cont.clientWidth, cont.clientHeight);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.toneMapping = ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1;
|
||||
renderer.setAnimationLoop(animate);
|
||||
cont.appendChild(renderer.domElement);
|
||||
|
||||
// 相机
|
||||
camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000);
|
||||
camera.position.copy(pos);
|
||||
camera.lookAt(pos.x, pos.y, pos.z + 1);
|
||||
|
||||
// 控制器
|
||||
controls = new MapControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.maxDistance = 2000;
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.rotateSpeed = 1;
|
||||
controls.maxPolarAngle = Math.PI / 2;
|
||||
controls.target.set(pos.x, pos.y, pos.z + 1);
|
||||
|
||||
// 环境光
|
||||
const ambient = new AmbientLight(0xffffff, 5);
|
||||
scene.add(ambient);
|
||||
|
||||
// 背景
|
||||
new HDRLoader().load(
|
||||
"./img/env.hdr",
|
||||
(texture) => {
|
||||
texture.mapping = EquirectangularReflectionMapping;
|
||||
scene.background = texture;
|
||||
},
|
||||
undefined,
|
||||
(err) => console.warn("HDR 加载失败:", err)
|
||||
);
|
||||
|
||||
// 帧率
|
||||
stats = new Stats();
|
||||
Object.assign(stats.dom.style, {
|
||||
position: "fixed",
|
||||
bottom: "0px",
|
||||
left: "0px",
|
||||
top: "auto",
|
||||
zIndex: "9998",
|
||||
display: "block",
|
||||
});
|
||||
document.body.appendChild(stats.dom);
|
||||
|
||||
// 加载3DGS场景
|
||||
initGltfLoader();
|
||||
const spark = new SparkRenderer({ renderer });
|
||||
scene.add(spark);
|
||||
const splatURL = './3DGS/3DGS.sog'
|
||||
const splat = new SplatMesh({
|
||||
url: splatURL,
|
||||
onProgress: (e) => window.setLoaderProgress?.(e.loaded, e.total),
|
||||
onLoad: (mesh) => { mesh.rotateX(-Math.PI / 2); },
|
||||
});
|
||||
scene.add(splat);
|
||||
|
||||
// 加载碰撞体
|
||||
const colliderGltf = await gltfLoader.loadAsync("./glb/3dgs_collider.glb");
|
||||
const playerGltf = await gltfLoader.loadAsync("./glb/josh.glb");
|
||||
|
||||
// 人物控制器
|
||||
player = new playerController();
|
||||
await player.init({
|
||||
scene,
|
||||
camera,
|
||||
controls,
|
||||
playerModelConfig: {
|
||||
model: playerGltf.scene,
|
||||
animations: playerGltf.animations,
|
||||
scale: scaleNormal,
|
||||
idleAnim: "idle1",
|
||||
walkAnim: "walk",
|
||||
runAnim: "run",
|
||||
jumpAnim: ["jump", "Jump_Loop", "Jump_Land"],
|
||||
flyAnim: "fly",
|
||||
flyIdleAnim: "flyIdle",
|
||||
flyHoverForwardAnim: "flyHoverForward",
|
||||
flyHoverBackAnim: "flyHoverBack",
|
||||
flyHoverLeftAnim: "flyHoverLeft",
|
||||
flyHoverRightAnim: "flyHoverRight",
|
||||
flyHoverUpAnim: "flyHoverUp",
|
||||
flyHoverDownAnim: "flyHoverDown",
|
||||
drivingAnim: "Driving_Loop",
|
||||
headBoneName: "mixamorigHead",
|
||||
speed: 150,
|
||||
flySpeed: 1000,
|
||||
jumpHeight: 400,
|
||||
},
|
||||
initPos: pos,
|
||||
minCamDistance: 50,
|
||||
maxCamDistance: 180,
|
||||
camLookAtHeightRatio: 0.8,
|
||||
colliders: [
|
||||
{ motion: "static", shape: { kind: "mesh", source: colliderGltf.scene } },
|
||||
],
|
||||
enableOverShoulderView: true,
|
||||
});
|
||||
|
||||
window.addEventListener("resize", onWindowResize, false);
|
||||
// 监听按键
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.code !== "KeyZ") return;
|
||||
if (isScaling) return; // 缩放中不运行
|
||||
isSmallScale = !isSmallScale;
|
||||
animateToScale(isSmallScale ? scaleSmall : scaleNormal, 1);
|
||||
});
|
||||
// 等待高斯泼溅模型加载完毕再隐藏 loader
|
||||
await splat.initialized.catch(() => { });
|
||||
window.hideLoader();
|
||||
}
|
||||
|
||||
function animateToScale(targetScale, duration = 0.5) {
|
||||
if (scaleAnimFrame !== null) {
|
||||
cancelAnimationFrame(scaleAnimFrame);
|
||||
scaleAnimFrame = null;
|
||||
}
|
||||
|
||||
isScaling = true;
|
||||
const fromScale = player.getScale?.() ?? (isSmallScale ? scaleNormal : scaleSmall);
|
||||
const startTime = performance.now();
|
||||
|
||||
const tick = (now) => {
|
||||
const t = Math.min((now - startTime) / (duration * 1000), 1);
|
||||
const current = fromScale + (targetScale - fromScale) * t;
|
||||
player.setPlayerScale(current);
|
||||
|
||||
if (t < 1) {
|
||||
scaleAnimFrame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
scaleAnimFrame = null;
|
||||
isScaling = false;
|
||||
}
|
||||
};
|
||||
|
||||
scaleAnimFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// 初始化glb加载器
|
||||
function initGltfLoader() {
|
||||
gltfLoader = new GLTFLoader();
|
||||
const dracoLoader = new DRACOLoader();
|
||||
dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/");
|
||||
gltfLoader.setDRACOLoader(dracoLoader);
|
||||
const ktx2Loader = new KTX2Loader();
|
||||
ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/");
|
||||
ktx2Loader.detectSupport(renderer);
|
||||
gltfLoader.setKTX2Loader(ktx2Loader);
|
||||
}
|
||||
|
||||
|
||||
// 每帧调用
|
||||
function animate() {
|
||||
if (player) {
|
||||
player.update();
|
||||
} else {
|
||||
controls.update();
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
stats?.update();
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(window.devicePixelRatio * 1);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>three-player-controller</title>
|
||||
<link rel="stylesheet" href="./styles/common.css" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#container {
|
||||
overflow: hidden !important;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#google-attributions {
|
||||
position: fixed;
|
||||
left: 88px;
|
||||
bottom: 0;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
max-width: min(520px, calc(100vw - 160px));
|
||||
color: #e6eef6;
|
||||
font: 12px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#google-attributions img {
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
<script src="./loader.js" data-demo="3DTiles Scene"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
<div class="hud" role="note" aria-label="Controls">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Move</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Sprint</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Jump</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>F</kbd></span>
|
||||
<span class="hint-text">Toggle Fly</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="top-notice">
|
||||
<div class="credit-line">
|
||||
Powered by:
|
||||
<a href="https://mapsplatform.google.com/resources/blog/create-immersive-3d-map-experiences-photorealistic-3d-tiles/" target="_blank" rel="noopener noreferrer">Google Photorealistic 3D Tiles</a>
|
||||
&
|
||||
<a href="https://ion.cesium.com/" target="_blank" rel="noopener noreferrer">Cesium Ion</a>
|
||||
</div>
|
||||
<div class="credit-line">
|
||||
3D Tiles Loader:
|
||||
<a href="https://github.com/NASA-AMMOS/3DTilesRendererJS" target="_blank" rel="noopener noreferrer">3DTilesRendererJS</a>
|
||||
·
|
||||
Globe:
|
||||
<a href="https://github.com/cyanfish-x/tellux" target="_blank" rel="noopener noreferrer">Tellux</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="google-attributions" aria-label="Map data attributions"></aside>
|
||||
|
||||
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/3dtilesScene.js" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./img/source.svg" alt="" />
|
||||
</a>
|
||||
|
||||
<script type="module" src="./control-hints.js"></script>
|
||||
<script type="module" src="./3dtilesScene.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,574 @@
|
||||
import tellux from "tellux";
|
||||
import {
|
||||
AxesHelper,
|
||||
Box3,
|
||||
Group,
|
||||
Matrix4,
|
||||
Quaternion,
|
||||
Sphere,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
|
||||
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||
import { playerController } from "../src/PlayerController";
|
||||
|
||||
// Google Photorealistic 3D Tiles
|
||||
const GOOGLE_PHOTOREALISTIC_ASSET_ID = 2275207;
|
||||
const CESIUM_ION_TOKEN =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJmMzgwMGY3ZS1jOTMwLTQyNmQtOTkyNS03MDE4ZjlkYmY0MTYiLCJpZCI6MjIzMDk3LCJpYXQiOjE3MTg3NjgwNTN9.FcpK7jiFPzWZL8m6VxRbG7ly8LMecpXnDAMZJX_UehM";
|
||||
|
||||
// 出生城市
|
||||
const CITIES = {
|
||||
paris: { longitude: 2.2945, latitude: 48.8584, height: 100, hourUTC: 12 },
|
||||
tokyo: { longitude: 139.7671, latitude: 35.6812, height: 40, hourUTC: 4 },
|
||||
newyork: { longitude: -74.006, latitude: 40.7128, height: 20, hourUTC: 17 },
|
||||
london: { longitude: -0.1246, latitude: 51.5007, height: 40, hourUTC: 14 },
|
||||
sydney: { longitude: 151.2153, latitude: -33.8568, height: 40, hourUTC: 3 },
|
||||
dubai: { longitude: 55.2744, latitude: 25.1972, height: 40, hourUTC: 8 },
|
||||
rome: { longitude: 12.4922, latitude: 41.8902, height: 40, hourUTC: 11 },
|
||||
saopaulo: { longitude: -46.6559, latitude: -23.5615, height: 1000, hourUTC: 16 },
|
||||
};
|
||||
const CITY_OPTIONS = {
|
||||
"New York": "newyork",
|
||||
Paris: "paris",
|
||||
Tokyo: "tokyo",
|
||||
London: "london",
|
||||
Sydney: "sydney",
|
||||
Dubai: "dubai",
|
||||
Rome: "rome",
|
||||
"São Paulo": "saopaulo",
|
||||
};
|
||||
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
const RAD2DEG = 180 / Math.PI;
|
||||
|
||||
// 动态碰撞:靠近才建,远离再拆
|
||||
const TILE_ENTER_RADIUS = 100;
|
||||
const TILE_EXIT_RADIUS = 150;
|
||||
const MAX_TILE_ADDS_PER_UPDATE = 1;
|
||||
const MAX_TILE_REMOVES_PER_UPDATE = 4;
|
||||
|
||||
// 人物速度;飞行时按住 Shift 进入极速
|
||||
const BASE_WALK_SPEED = 200;
|
||||
const BASE_RUN_SPEED = 600;
|
||||
const BASE_FLY_SPEED = 2100;
|
||||
const TURBO_SPEED_SCALE = 3;
|
||||
|
||||
const container = document.querySelector("#container");
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
throw new Error("Missing #container");
|
||||
}
|
||||
|
||||
const attributionsElement = document.querySelector("#google-attributions");
|
||||
|
||||
const guiParams = {
|
||||
city: "newyork",
|
||||
playerCapsuleDebug: false,
|
||||
colliderDebug: false,
|
||||
localAxes: false,
|
||||
};
|
||||
|
||||
// Tellux 视图
|
||||
const viewer = new tellux.Viewer(container, {
|
||||
dracoDecoderPath: "https://unpkg.com/three@0.184.0/examples/jsm/libs/draco/gltf/",
|
||||
useDefaultRenderLoop: false,
|
||||
camera: {
|
||||
longitude: CITIES.newyork.longitude,
|
||||
latitude: CITIES.newyork.latitude,
|
||||
height: 80,
|
||||
heading: 0,
|
||||
pitch: -20,
|
||||
near: 0.1,
|
||||
},
|
||||
scene: {
|
||||
atmosphere: {
|
||||
show: true,
|
||||
lighting: {
|
||||
mode: "post-process",
|
||||
sunLight: true,
|
||||
skyLight: true,
|
||||
},
|
||||
sky: {
|
||||
sun: true,
|
||||
moon: true,
|
||||
stars: true,
|
||||
},
|
||||
},
|
||||
clouds: {
|
||||
show: true,
|
||||
coverage: 0.35,
|
||||
},
|
||||
postProcess: {
|
||||
lensFlare: { enabled: false },
|
||||
smaa: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 关闭默认地球交互,改由人物控制器接管相机
|
||||
viewer.controls.enabled = false;
|
||||
viewer.camera.allowUnderground = true;
|
||||
viewer.tileset.group.visible = false;
|
||||
|
||||
// 控制器
|
||||
const orbitControls = new OrbitControls(viewer.camera.threeCamera, viewer.renderer.domElement);
|
||||
orbitControls.enablePan = false;
|
||||
|
||||
// 局部坐标轴
|
||||
const localAxes = new AxesHelper(5000);
|
||||
localAxes.name = "local-axes";
|
||||
localAxes.visible = false;
|
||||
localAxes.renderOrder = 10;
|
||||
localAxes.material.depthTest = false;
|
||||
localAxes.material.depthWrite = false;
|
||||
viewer.scene.threeScene.add(localAxes);
|
||||
|
||||
// 帧率
|
||||
const stats = new Stats();
|
||||
Object.assign(stats.dom.style, {
|
||||
position: "fixed",
|
||||
left: "0",
|
||||
bottom: "0",
|
||||
top: "auto",
|
||||
zIndex: "9998",
|
||||
});
|
||||
document.body.appendChild(stats.dom);
|
||||
|
||||
let activeLayer = null;
|
||||
let localFrame = null;
|
||||
let tileCollision = null;
|
||||
let player = null;
|
||||
let skipCollisionAfterRebase = false;
|
||||
let running = true;
|
||||
let turboMode = false;
|
||||
|
||||
// 局部坐标系下每帧把世界到 ECEF 的矩阵写进大气。
|
||||
function syncAtmosphereFrame() {
|
||||
const atmosphere = viewer.atmosphere;
|
||||
atmosphere.aerialPerspectiveEffect.worldToECEFMatrix.copy(localFrame.localToEcef);
|
||||
atmosphere.cloudsEffect.worldToECEFMatrix.copy(localFrame.localToEcef);
|
||||
}
|
||||
|
||||
// 以人物附近 ECEF 点建立局部 Y-up 坐标系,走远后 rebase。
|
||||
class LocalFrameManager {
|
||||
// 建局部 ENU 根节点,并按出生点设原点
|
||||
constructor(ellipsoid, origin) {
|
||||
this.ellipsoid = ellipsoid;
|
||||
this.rebaseDistance = 10_000;
|
||||
this.group = new Group();
|
||||
this.group.name = "local-frame";
|
||||
this.group.matrixAutoUpdate = false;
|
||||
this.localToEcef = new Matrix4();
|
||||
this.ecefToLocal = new Matrix4();
|
||||
this.deltaMatrix = new Matrix4();
|
||||
this.deltaQuat = new Quaternion();
|
||||
this.scratchEcef = new Vector3();
|
||||
this.scratchCartographic = { lat: 0, lon: 0, height: 0 };
|
||||
this.setOrigin(origin);
|
||||
}
|
||||
|
||||
// 按经纬高重设局部原点
|
||||
setOrigin(origin) {
|
||||
const height = origin.height ?? 0;
|
||||
this.ellipsoid.getObjectFrame(
|
||||
origin.latitude * DEG2RAD,
|
||||
origin.longitude * DEG2RAD,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
this.localToEcef
|
||||
);
|
||||
this.ecefToLocal.copy(this.localToEcef).invert();
|
||||
this.group.matrix.copy(this.ecefToLocal);
|
||||
this.group.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
// 人物离原点太远时平移局部系,并同步胶囊、相机和速度
|
||||
maybeRebase(target) {
|
||||
const position = target.getPosition();
|
||||
if (!position || position.length() < this.rebaseDistance) return false;
|
||||
|
||||
this.scratchEcef.copy(position).applyMatrix4(this.localToEcef);
|
||||
this.ellipsoid.getPositionToCartographic(this.scratchEcef, this.scratchCartographic);
|
||||
|
||||
this.deltaMatrix.copy(this.localToEcef);
|
||||
this.setOrigin({
|
||||
latitude: this.scratchCartographic.lat * RAD2DEG,
|
||||
longitude: this.scratchCartographic.lon * RAD2DEG,
|
||||
height: this.scratchCartographic.height,
|
||||
});
|
||||
this.deltaMatrix.premultiply(this.ecefToLocal);
|
||||
this.deltaQuat.setFromRotationMatrix(this.deltaMatrix);
|
||||
|
||||
const capsule = target.getPlayerCapsule();
|
||||
if (capsule) this.applyDelta(capsule, this.deltaMatrix);
|
||||
if (!this.isDescendant(target.camera, capsule)) {
|
||||
this.applyDelta(target.camera, this.deltaMatrix);
|
||||
}
|
||||
target.controls?.target.applyMatrix4(this.deltaMatrix);
|
||||
target.playerVelocity?.applyQuaternion(this.deltaQuat);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 判断 object 是否挂在 ancestor 下面
|
||||
isDescendant(object, ancestor) {
|
||||
if (!ancestor) return false;
|
||||
let current = object;
|
||||
while (current) {
|
||||
if (current === ancestor) return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 用 rebase 增量改物体位姿
|
||||
applyDelta(object, delta) {
|
||||
object.updateMatrixWorld(true);
|
||||
object.matrix.premultiply(delta);
|
||||
object.matrix.decompose(object.position, object.quaternion, object.scale);
|
||||
object.updateMatrixWorld(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 登记已加载瓦片,只给当前可见且靠近人物的瓦片建运动学 mesh 碰撞。
|
||||
class TileCollisionManager {
|
||||
// 绑定瓦片集和人物,并备好事件回调
|
||||
constructor(tileset, targetPlayer) {
|
||||
this.tileset = tileset;
|
||||
this.player = targetPlayer;
|
||||
this.attached = false;
|
||||
this.tracked = new Map();
|
||||
this.scratchCenter = new Vector3();
|
||||
this.scratchScale = new Vector3();
|
||||
this.scratchBox = new Box3();
|
||||
this.scratchSphere = new Sphere();
|
||||
this.onLoadModel = (event) => this.track(event.scene, this.isTileVisible(event.tile, event.scene));
|
||||
this.onDisposeModel = (event) => this.untrack(event.scene);
|
||||
this.onVisibilityChange = (event) => this.setVisible(event.scene, event.visible);
|
||||
}
|
||||
|
||||
// 监听加载 / 卸载 / 可见性,并补登记已在场的瓦片
|
||||
attach() {
|
||||
if (this.attached) return;
|
||||
this.attached = true;
|
||||
this.tileset.addEventListener("load-model", this.onLoadModel);
|
||||
this.tileset.addEventListener("dispose-model", this.onDisposeModel);
|
||||
this.tileset.addEventListener("tile-visibility-change", this.onVisibilityChange);
|
||||
this.tileset.forEachLoadedModel((scene, tile) => this.track(scene, this.isTileVisible(tile, scene)));
|
||||
}
|
||||
|
||||
// 去掉瓦片事件,不再跟踪新变化
|
||||
detach() {
|
||||
if (!this.attached) return;
|
||||
this.attached = false;
|
||||
this.tileset.removeEventListener("load-model", this.onLoadModel);
|
||||
this.tileset.removeEventListener("dispose-model", this.onDisposeModel);
|
||||
this.tileset.removeEventListener("tile-visibility-change", this.onVisibilityChange);
|
||||
}
|
||||
|
||||
// 拆掉全部碰撞并停止跟踪
|
||||
dispose() {
|
||||
this.detach();
|
||||
for (const entry of this.tracked.values()) this.removeCollider(entry);
|
||||
this.tracked.clear();
|
||||
}
|
||||
|
||||
// 只给当前可见且靠近的瓦片建碰撞;隐藏的(含 REPLACE 父级)立刻拆
|
||||
update(playerPos) {
|
||||
let adds = 0;
|
||||
let removes = 0;
|
||||
for (const entry of this.tracked.values()) {
|
||||
if (!entry.visible) {
|
||||
this.removeCollider(entry);
|
||||
continue;
|
||||
}
|
||||
const distance = this.distanceToTile(entry, playerPos);
|
||||
if (!entry.handle && distance < TILE_ENTER_RADIUS) {
|
||||
if (adds >= MAX_TILE_ADDS_PER_UPDATE) continue;
|
||||
const handle = this.player.addCollider({
|
||||
motion: "kinematic",
|
||||
useWorker: true,
|
||||
shape: { kind: "mesh", source: entry.scene },
|
||||
follow: entry.scene,
|
||||
});
|
||||
if (handle.id < 0) continue;
|
||||
entry.handle = handle;
|
||||
adds += 1;
|
||||
} else if (entry.handle && distance > TILE_EXIT_RADIUS) {
|
||||
if (removes >= MAX_TILE_REMOVES_PER_UPDATE) continue;
|
||||
this.removeCollider(entry);
|
||||
removes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 人物到瓦片包围球表面的距离;负值表示人在球内
|
||||
distanceToTile(entry, playerPos) {
|
||||
entry.scene.updateWorldMatrix(true, false);
|
||||
this.scratchCenter.copy(entry.localCenter).applyMatrix4(entry.scene.matrixWorld);
|
||||
entry.scene.getWorldScale(this.scratchScale);
|
||||
const worldRadius = entry.localRadius * (this.scratchScale.x || 1);
|
||||
return playerPos.distanceTo(this.scratchCenter) - worldRadius;
|
||||
}
|
||||
|
||||
// 以 traversal.visible 为准,没有则看 scene 是否还挂在树上
|
||||
isTileVisible(tile, scene) {
|
||||
if (typeof tile?.traversal?.visible === "boolean") return tile.traversal.visible;
|
||||
return Boolean(scene?.parent);
|
||||
}
|
||||
|
||||
// 同步可见性;藏起来就拆碰撞,避免粗父级残留
|
||||
setVisible(scene, visible) {
|
||||
const entry = this.tracked.get(scene) ?? this.track(scene, visible);
|
||||
if (!entry) return;
|
||||
entry.visible = visible;
|
||||
if (!visible) this.removeCollider(entry);
|
||||
}
|
||||
|
||||
// 登记瓦片局部中心和半径,已跟踪则只改可见性
|
||||
track(scene, visible = Boolean(scene.parent)) {
|
||||
const existing = this.tracked.get(scene);
|
||||
if (existing) {
|
||||
existing.visible = visible;
|
||||
return existing;
|
||||
}
|
||||
scene.updateWorldMatrix(true, true);
|
||||
this.scratchBox.setFromObject(scene);
|
||||
if (this.scratchBox.isEmpty()) return null;
|
||||
this.scratchBox.getBoundingSphere(this.scratchSphere);
|
||||
const localCenter = scene.worldToLocal(this.scratchSphere.center.clone());
|
||||
scene.getWorldScale(this.scratchScale);
|
||||
const scale = this.scratchScale.x || 1;
|
||||
const entry = {
|
||||
scene,
|
||||
localCenter,
|
||||
localRadius: this.scratchSphere.radius / scale,
|
||||
visible,
|
||||
handle: null,
|
||||
};
|
||||
this.tracked.set(scene, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// 瓦片卸载时拆碰撞并移出跟踪表
|
||||
untrack(scene) {
|
||||
const entry = this.tracked.get(scene);
|
||||
if (!entry) return;
|
||||
this.removeCollider(entry);
|
||||
this.tracked.delete(scene);
|
||||
}
|
||||
|
||||
// 从人物控制器移除该瓦片的运动学碰撞
|
||||
removeCollider(entry) {
|
||||
if (!entry.handle) return;
|
||||
this.player.removeCollider(entry.handle);
|
||||
entry.handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 当前选中的出生城市
|
||||
function currentCity() {
|
||||
return guiParams.city in CITIES ? guiParams.city : "newyork";
|
||||
}
|
||||
|
||||
// 局部坐标系中的出生点
|
||||
function spawnLocalPosition() {
|
||||
return new Vector3(0, 100, 0);
|
||||
}
|
||||
|
||||
// 渲染 Google / Cesium 版权信息
|
||||
function renderAttributions() {
|
||||
if (!attributionsElement) return;
|
||||
const attributions = activeLayer?.tileset.getAttributions() ?? [];
|
||||
attributionsElement.replaceChildren();
|
||||
for (const attribution of attributions) {
|
||||
if (attribution.type === "image") {
|
||||
const image = document.createElement("img");
|
||||
image.src = String(attribution.value);
|
||||
image.alt = "";
|
||||
attributionsElement.append(image);
|
||||
continue;
|
||||
}
|
||||
if (attribution.value) {
|
||||
const item = document.createElement("span");
|
||||
item.innerHTML = String(attribution.value);
|
||||
attributionsElement.append(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建或更新局部坐标系
|
||||
function ensureLocalFrame(origin) {
|
||||
if (!localFrame) {
|
||||
localFrame = new LocalFrameManager(viewer.tileset.ellipsoid, origin);
|
||||
viewer.scene.threeScene.add(localFrame.group);
|
||||
return;
|
||||
}
|
||||
localFrame.setOrigin(origin);
|
||||
}
|
||||
|
||||
// 飞行时按住 Shift 切换极速
|
||||
function syncTurboMode() {
|
||||
if (!player) return;
|
||||
const next = player.isFlying && player.input.shift;
|
||||
if (next === turboMode) return;
|
||||
turboMode = next;
|
||||
player.setPlayerFlySpeed(BASE_FLY_SPEED * (turboMode ? TURBO_SPEED_SCALE : 1));
|
||||
}
|
||||
|
||||
// 极速时拉宽视野
|
||||
function updateTurboFov() {
|
||||
const camera = viewer.camera.threeCamera;
|
||||
const targetFov = turboMode ? 120 : 60;
|
||||
if (Math.abs(camera.fov - targetFov) > 0.01) {
|
||||
camera.fov += (targetFov - camera.fov) * 0.01;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
// 切换城市或重新出生时悬停,等待周围地形加载
|
||||
function respawn() {
|
||||
ensureLocalFrame(CITIES[currentCity()]);
|
||||
viewer.clock.hourUTC = CITIES[currentCity()].hourUTC;
|
||||
player?.reset(spawnLocalPosition());
|
||||
if (player && !player.isFlying) player.setInput({ toggleFly: true });
|
||||
player?.setSkipCapsuleCollision(true);
|
||||
skipCollisionAfterRebase = true;
|
||||
}
|
||||
|
||||
// 加载 Google Photorealistic 3D Tiles
|
||||
function loadPhotorealisticTiles() {
|
||||
ensureLocalFrame(CITIES[currentCity()]);
|
||||
activeLayer = viewer.load3DTileset({
|
||||
type: "cesium-ion",
|
||||
id: "photorealistic-3d-tiles",
|
||||
assetId: GOOGLE_PHOTOREALISTIC_ASSET_ID,
|
||||
apiToken: CESIUM_ION_TOKEN,
|
||||
creasedNormals: true,
|
||||
materialMode: "unlit",
|
||||
});
|
||||
localFrame.group.add(activeLayer.tileset.group);
|
||||
localFrame.group.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
// 玩家控制器
|
||||
async function initPlayer() {
|
||||
const loader = new GLTFLoader();
|
||||
const gltf = await loader.loadAsync("./glb/josh.glb");
|
||||
player = new playerController();
|
||||
await player.init({
|
||||
scene: viewer.scene.threeScene,
|
||||
camera: viewer.camera.threeCamera,
|
||||
controls: orbitControls,
|
||||
initPos: spawnLocalPosition(),
|
||||
minCamDistance: 5,
|
||||
maxCamDistance: 200,
|
||||
playerModelConfig: {
|
||||
model: gltf.scene,
|
||||
animations: gltf.animations ?? [],
|
||||
scale: 0.01,
|
||||
idleAnim: "idle1",
|
||||
walkAnim: "walk",
|
||||
runAnim: "run",
|
||||
jumpAnim: ["jump", "Jump_Loop", "Jump_Land"],
|
||||
flyAnim: "fly",
|
||||
flyIdleAnim: "flyIdle",
|
||||
flyHoverForwardAnim: "flyHoverForward",
|
||||
flyHoverBackAnim: "flyHoverBack",
|
||||
flyHoverLeftAnim: "flyHoverLeft",
|
||||
flyHoverRightAnim: "flyHoverRight",
|
||||
flyHoverUpAnim: "flyHoverUp",
|
||||
flyHoverDownAnim: "flyHoverDown",
|
||||
headBoneName: "mixamorigHead",
|
||||
rotateY: Math.PI,
|
||||
speed: BASE_WALK_SPEED,
|
||||
runSpeed: BASE_RUN_SPEED,
|
||||
flySpeed: BASE_FLY_SPEED,
|
||||
acceleration: 500,
|
||||
deceleration: 10000000,
|
||||
},
|
||||
});
|
||||
player.setPlayerCapsuleDebug(guiParams.playerCapsuleDebug);
|
||||
player.setColliderDebug(guiParams.colliderDebug);
|
||||
// 启用实际太阳光、天空光和环境反射。
|
||||
viewer.atmosphere.setPostProcessMaterialLights(true);
|
||||
}
|
||||
|
||||
// 创建调试面板
|
||||
function initGUI() {
|
||||
const gui = new GUI({ title: "3D Tiles Scene", width: 260 });
|
||||
if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close();
|
||||
Object.assign(gui.domElement.style, {
|
||||
position: "fixed",
|
||||
top: "12px",
|
||||
right: "12px",
|
||||
zIndex: "9999",
|
||||
});
|
||||
gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
gui.add(guiParams, "city", CITY_OPTIONS).name("City").onChange(respawn);
|
||||
gui.add(guiParams, "playerCapsuleDebug").name("Capsule Debug").onChange((value) => {
|
||||
player?.setPlayerCapsuleDebug(value);
|
||||
});
|
||||
gui.add(guiParams, "colliderDebug").name("Collider Debug").onChange((value) => {
|
||||
player?.setColliderDebug(value);
|
||||
});
|
||||
gui.add(guiParams, "localAxes").name("Local Axes").onChange((value) => {
|
||||
localAxes.visible = value;
|
||||
});
|
||||
}
|
||||
|
||||
// 启动示例
|
||||
async function start() {
|
||||
await viewer.ready;
|
||||
loadPhotorealisticTiles();
|
||||
await initPlayer();
|
||||
tileCollision = new TileCollisionManager(activeLayer.tileset, player);
|
||||
tileCollision.attach();
|
||||
respawn();
|
||||
initGUI();
|
||||
requestAnimationFrame(loop);
|
||||
// 关闭加载页面
|
||||
window.hideLoader?.();
|
||||
}
|
||||
|
||||
// 每帧调用
|
||||
function loop(time) {
|
||||
if (!running) return;
|
||||
if (player) {
|
||||
if (skipCollisionAfterRebase) {
|
||||
player.setSkipCapsuleCollision(false);
|
||||
skipCollisionAfterRebase = false;
|
||||
}
|
||||
syncTurboMode();
|
||||
void player.update();
|
||||
if (localFrame?.maybeRebase(player)) {
|
||||
player.setSkipCapsuleCollision(true);
|
||||
skipCollisionAfterRebase = true;
|
||||
}
|
||||
const pos = player.getPosition();
|
||||
if (pos) tileCollision?.update(pos);
|
||||
}
|
||||
updateTurboFov();
|
||||
// 局部坐标系下同步大气世界到 ECEF 的变换
|
||||
syncAtmosphereFrame();
|
||||
viewer.render(time);
|
||||
renderAttributions();
|
||||
stats.update();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
window.viewer = viewer;
|
||||
void start();
|
||||
|
||||
// 页面卸载时释放资源
|
||||
window.addEventListener("beforeunload", () => {
|
||||
running = false;
|
||||
tileCollision?.dispose();
|
||||
player?.destroy();
|
||||
stats.dom.remove();
|
||||
viewer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
// 按键文字 -> KeyboardEvent.code(可被 data-code 覆盖)
|
||||
const LABEL_CODES = {
|
||||
W: ["KeyW", "ArrowUp"],
|
||||
A: ["KeyA", "ArrowLeft"],
|
||||
S: ["KeyS", "ArrowDown"],
|
||||
D: ["KeyD", "ArrowRight"],
|
||||
Shift: ["ShiftLeft", "ShiftRight"],
|
||||
Space: ["Space"],
|
||||
RMB: ["Mouse2"],
|
||||
LMB: ["Mouse0"],
|
||||
1: ["Digit1"],
|
||||
4: ["Digit4"],
|
||||
};
|
||||
|
||||
// 在 GUI / 输入框里操作时不点亮 HUD
|
||||
function shouldIgnore(el) {
|
||||
if (!(el instanceof Element)) return false;
|
||||
return Boolean(el.closest("input, textarea, select, [contenteditable='true'], .lil-gui"));
|
||||
}
|
||||
|
||||
// 解析 kbd 对应的键码:优先 data-code,否则按显示文字查表
|
||||
function codesFor(kbd) {
|
||||
const explicit = kbd.getAttribute("data-code");
|
||||
if (explicit) return explicit.trim().split(/\s+/).filter(Boolean);
|
||||
const label = kbd.textContent.replace(/\s+/g, " ").trim();
|
||||
if (LABEL_CODES[label]) return LABEL_CODES[label];
|
||||
if (/^[A-Za-z]$/.test(label)) return [`Key${label.toUpperCase()}`];
|
||||
return [];
|
||||
}
|
||||
|
||||
const LOOK_SCALE = 0.28; // 鼠标位移换算到指示点的系数
|
||||
const LOOK_MAX = 5; // 指示点最大偏移(px)
|
||||
const LOOK_DECAY = 0.84; // 每帧回弹衰减
|
||||
const LOOK_REST = 0.04; // 小于此值视为静止
|
||||
|
||||
// 确保每个移动提示块都有鼠标图标(HTML 未写时补一个)
|
||||
function ensureLookMice(hud) {
|
||||
const mice = [];
|
||||
for (const move of hud.querySelectorAll(".hint-move")) {
|
||||
let mouse = move.querySelector(".hint-mouse");
|
||||
if (!mouse) {
|
||||
const cluster = document.createElement("div");
|
||||
cluster.className = "hint-cluster";
|
||||
cluster.innerHTML = '<div class="hint-mouse" aria-hidden="true"><div class="hint-mouse-body"><span class="hint-mouse-wheel"></span><span class="hint-mouse-nub"></span></div></div><span class="hint-text">Look</span>';
|
||||
move.append(cluster);
|
||||
mouse = cluster.querySelector(".hint-mouse");
|
||||
}
|
||||
mice.push(mouse);
|
||||
}
|
||||
return mice;
|
||||
}
|
||||
|
||||
// 绑定 HUD 按键高亮与鼠标 Look 指示
|
||||
export function bindControlHints(hud = document.querySelector(".hud")) {
|
||||
if (!hud) return;
|
||||
|
||||
const keys = [...hud.querySelectorAll("kbd")];
|
||||
const mice = ensureLookMice(hud);
|
||||
if (!keys.length && !mice.length) return;
|
||||
|
||||
const pressed = new Set(); // 当前按下的 KeyboardEvent.code / MouseN
|
||||
let lookX = 0;
|
||||
let lookY = 0;
|
||||
let lookRaf = 0;
|
||||
|
||||
// 同步 kbd.is-down
|
||||
const syncKeys = () => {
|
||||
for (const kbd of keys) {
|
||||
kbd.classList.toggle("is-down", codesFor(kbd).some((code) => pressed.has(code)));
|
||||
}
|
||||
};
|
||||
|
||||
// 把 Look 偏移写到鼠标图标
|
||||
const applyLook = () => {
|
||||
const x = `${lookX}px`;
|
||||
const y = `${lookY}px`;
|
||||
const active = Math.hypot(lookX, lookY) > LOOK_REST;
|
||||
for (const mouse of mice) {
|
||||
mouse.style.setProperty("--look-x", x);
|
||||
mouse.style.setProperty("--look-y", y);
|
||||
mouse.classList.toggle("is-active", active);
|
||||
}
|
||||
};
|
||||
|
||||
// 指示点回弹;静止后停掉 rAF
|
||||
const tickLook = () => {
|
||||
lookX *= LOOK_DECAY;
|
||||
lookY *= LOOK_DECAY;
|
||||
if (Math.abs(lookX) < LOOK_REST && Math.abs(lookY) < LOOK_REST) {
|
||||
lookX = 0;
|
||||
lookY = 0;
|
||||
lookRaf = 0;
|
||||
applyLook();
|
||||
return;
|
||||
}
|
||||
applyLook();
|
||||
lookRaf = requestAnimationFrame(tickLook);
|
||||
};
|
||||
|
||||
// 键盘按下 / 抬起
|
||||
const onKey = (event, down) => {
|
||||
if (down && shouldIgnore(event.target)) return;
|
||||
if (down) pressed.add(event.code);
|
||||
else pressed.delete(event.code);
|
||||
syncKeys();
|
||||
};
|
||||
|
||||
// 鼠标按键(LMB / RMB 等)
|
||||
const onMouse = (event, down) => {
|
||||
if (down && shouldIgnore(event.target)) return;
|
||||
const code = `Mouse${event.button}`;
|
||||
if (down) pressed.add(code);
|
||||
else pressed.delete(code);
|
||||
syncKeys();
|
||||
};
|
||||
|
||||
// 指针锁定时始终跟踪;否则仅在按住拖拽转视角时跟踪
|
||||
const onMouseMove = (event) => {
|
||||
if (!document.pointerLockElement && event.buttons === 0) return;
|
||||
if (!document.pointerLockElement && shouldIgnore(event.target)) return;
|
||||
lookX = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookX + event.movementX * LOOK_SCALE));
|
||||
lookY = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookY + event.movementY * LOOK_SCALE));
|
||||
applyLook();
|
||||
if (!lookRaf) lookRaf = requestAnimationFrame(tickLook);
|
||||
};
|
||||
|
||||
// 失焦时清掉按下态和 Look 偏移
|
||||
const clear = () => {
|
||||
pressed.clear();
|
||||
syncKeys();
|
||||
lookX = 0;
|
||||
lookY = 0;
|
||||
if (lookRaf) {
|
||||
cancelAnimationFrame(lookRaf);
|
||||
lookRaf = 0;
|
||||
}
|
||||
applyLook();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", (event) => onKey(event, true));
|
||||
window.addEventListener("keyup", (event) => onKey(event, false));
|
||||
window.addEventListener("mousedown", (event) => onMouse(event, true));
|
||||
window.addEventListener("mouseup", (event) => onMouse(event, false));
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("blur", clear);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) clear();
|
||||
});
|
||||
}
|
||||
|
||||
// 按上下车切换走路 / 载具两套提示,并转调原有回调
|
||||
export function bindVehicleHintMode(player, hud = document.querySelector(".hud")) {
|
||||
if (!player || !hud) return;
|
||||
|
||||
const setMode = (mode) => {
|
||||
hud.dataset.hintMode = mode;
|
||||
};
|
||||
|
||||
setMode(player.getControllerMode?.() === 1 ? "vehicle" : "on-foot");
|
||||
|
||||
const prevEnter = player.onVehicleEnter;
|
||||
const prevExit = player.onVehicleExit;
|
||||
player.onVehicleEnter = (vehicle) => {
|
||||
setMode("vehicle");
|
||||
prevEnter?.(vehicle);
|
||||
};
|
||||
player.onVehicleExit = (vehicle) => {
|
||||
setMode("on-foot");
|
||||
prevExit?.(vehicle);
|
||||
};
|
||||
}
|
||||
|
||||
bindControlHints();
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Foot IK</title>
|
||||
<link rel="stylesheet" href="./styles/common.css" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #dfe8ef;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#top-notice {
|
||||
white-space: nowrap;
|
||||
line-height: normal;
|
||||
text-align: start;
|
||||
}
|
||||
</style>
|
||||
<script src="./loader.js" data-demo="Foot IK"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
<div id="top-notice">
|
||||
Character Model:
|
||||
<a href="https://quaternius.itch.io/universal-animation-library" target="_blank" rel="noopener noreferrer">
|
||||
Universal Animation Library
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="hud" role="note" aria-label="Controls">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Move</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Sprint</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Jump</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>F</kbd></span>
|
||||
<span class="hint-text">Toggle Fly</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./control-hints.js"></script>
|
||||
<script type="module" src="./footIK.js"></script>
|
||||
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/footIK.js" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./img/source.svg" alt="" />
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,610 @@
|
||||
import {
|
||||
ACESFilmicToneMapping,
|
||||
AmbientLight,
|
||||
BoxGeometry,
|
||||
DirectionalLight,
|
||||
Group,
|
||||
LineBasicMaterial,
|
||||
LineSegments,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
VSMShadowMap,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { Sky } from "three/addons/objects/Sky.js";
|
||||
import { MapControls } from "three/examples/jsm/Addons.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
|
||||
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||
import { playerController } from "../src/PlayerController";
|
||||
import { FootIK } from "../src/foot-ik";
|
||||
|
||||
const scene = new Scene();
|
||||
|
||||
let camera;
|
||||
let renderer;
|
||||
let controls;
|
||||
let player;
|
||||
let footIK;
|
||||
let sky;
|
||||
let gui;
|
||||
let footIKDebugParams;
|
||||
let writePelvisOffsetController;
|
||||
let stats;
|
||||
let leftMouseSlowMotion = false;
|
||||
let rightMouseSlowMotion = false;
|
||||
let savedPlayerTimeScale = 1;
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
const container = document.querySelector("#container");
|
||||
|
||||
// 渲染器
|
||||
renderer = new WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.toneMapping = ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 0.8;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = VSMShadowMap;
|
||||
renderer.setAnimationLoop(animate);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// 帧率
|
||||
stats = new Stats();
|
||||
Object.assign(stats.dom.style, {
|
||||
position: "fixed",
|
||||
bottom: "0px",
|
||||
left: "0px",
|
||||
top: "auto",
|
||||
zIndex: "9998",
|
||||
display: "block",
|
||||
});
|
||||
document.body.appendChild(stats.dom);
|
||||
|
||||
// 相机
|
||||
camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.01, 500);
|
||||
camera.position.set(-6, 6, 9);
|
||||
|
||||
// 控制器
|
||||
controls = new MapControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.maxDistance = 80;
|
||||
controls.maxPolarAngle = Math.PI / 2.05;
|
||||
controls.target.set(0, 0.8, 0);
|
||||
|
||||
// 环境光
|
||||
scene.add(new AmbientLight(0xffffff, 1.8));
|
||||
|
||||
// 平行光
|
||||
const sun = new DirectionalLight(0xffffff, 2);
|
||||
sun.position.set(6, 10, 7);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
sun.shadow.camera.near = 0.5;
|
||||
sun.shadow.camera.far = 35;
|
||||
sun.shadow.camera.left = -12;
|
||||
sun.shadow.camera.right = 12;
|
||||
sun.shadow.camera.top = 10;
|
||||
sun.shadow.camera.bottom = -10;
|
||||
sun.shadow.bias = -0.0005;
|
||||
scene.add(sun);
|
||||
sky = new Sky();
|
||||
sky.scale.setScalar(450000);
|
||||
scene.add(sky);
|
||||
const skyUniforms = sky.material.uniforms;
|
||||
skyUniforms["turbidity"].value = 10;
|
||||
skyUniforms["rayleigh"].value = 2;
|
||||
skyUniforms["mieCoefficient"].value = 0.005;
|
||||
skyUniforms["mieDirectionalG"].value = 0.99;
|
||||
updateSkySun(sun);
|
||||
|
||||
// 测试场景
|
||||
const staticCollider = buildTestCourse();
|
||||
scene.add(staticCollider);
|
||||
addGroundGrid(scene);
|
||||
|
||||
// 人物控制器
|
||||
const gltfLoader = new GLTFLoader();
|
||||
const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb");
|
||||
|
||||
player = new playerController();
|
||||
await player.init({
|
||||
scene,
|
||||
camera,
|
||||
controls,
|
||||
colliders: [
|
||||
{ motion: "static", shape: { kind: "mesh", source: staticCollider } },
|
||||
],
|
||||
initPos: new Vector3(-7.5, 0.25, -3.5),
|
||||
minCamDistance: 10,
|
||||
maxCamDistance: 300,
|
||||
camLookAtHeightRatio: 0.5,
|
||||
enableSpringCamera: true,
|
||||
playerModelConfig: {
|
||||
model: playerGltf.scene,
|
||||
animations: playerGltf.animations,
|
||||
scale: 0.005,
|
||||
idleAnim: "Idle_Loop",
|
||||
walkAnim: "Walk_Loop",
|
||||
runAnim: "Sprint_Loop",
|
||||
jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
|
||||
flyAnim: "fly",
|
||||
flyIdleAnim: "flyIdle",
|
||||
flyHoverForwardAnim: "flyHoverForward",
|
||||
flyHoverBackAnim: "flyHoverBack",
|
||||
flyHoverLeftAnim: "flyHoverLeft",
|
||||
flyHoverRightAnim: "flyHoverRight",
|
||||
flyHoverUpAnim: "flyHoverUp",
|
||||
flyHoverDownAnim: "flyHoverDown",
|
||||
speed: 100,
|
||||
runSpeed: 600,
|
||||
headBoneName: "Head",
|
||||
firstPersonCameraOffset: [0, 0.15, 0.12],
|
||||
rotateY: Math.PI / 2,
|
||||
},
|
||||
// keyMap: { toggleFly: null }, // 关闭飞行
|
||||
});
|
||||
enableModelShadows(player.playerModel);
|
||||
|
||||
// 骨骼配置
|
||||
const skeleton = {
|
||||
hips: "pelvis",
|
||||
legs: {
|
||||
left: {
|
||||
upper: "thigh_l",
|
||||
lower: "calf_l",
|
||||
foot: "foot_l",
|
||||
toe: "ball_l",
|
||||
},
|
||||
right: {
|
||||
upper: "thigh_r",
|
||||
lower: "calf_r",
|
||||
foot: "foot_r",
|
||||
toe: "ball_r",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
footIK = new FootIK({
|
||||
skeleton,
|
||||
soleHalfWidth: 7,
|
||||
soleToeExtend: 7,
|
||||
soleSkinThickness: 1.6,
|
||||
plantedHeightSpeed: 200,
|
||||
penetrationLiftSpeed: 200,
|
||||
predictivePlacement: true,
|
||||
straightPoleEnabled: true,
|
||||
});
|
||||
player.use(footIK);
|
||||
|
||||
createDebugPanel();
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
window.addEventListener("mousedown", onMouseDown);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
window.addEventListener("blur", releaseRightMouseSlowMotion);
|
||||
renderer.domElement.addEventListener("contextmenu", event => event.preventDefault());
|
||||
|
||||
window.hideLoader?.();
|
||||
}
|
||||
|
||||
// 创建调试面板
|
||||
function createDebugPanel() {
|
||||
const options = footIK.getOptions();
|
||||
const params = {
|
||||
colliderDebug: false,
|
||||
playerCapsuleDebug: false,
|
||||
footIKEnabled: options.enabled,
|
||||
footIKDebug: options.debug,
|
||||
predictivePlacement: options.predictivePlacement,
|
||||
straightPoleEnabled: options.straightPoleEnabled,
|
||||
pelvisOffset: 0,
|
||||
writePelvisOffset: 0,
|
||||
clearPelvisOffset: () => {
|
||||
params.writePelvisOffset = 0;
|
||||
writePelvisOffsetController.updateDisplay();
|
||||
footIK.clearPelvisOffset();
|
||||
},
|
||||
leftFootPhase: "",
|
||||
leftFootLandIK: "-- / 0.00",
|
||||
leftPrediction: "disabled",
|
||||
leftPredictionScore: "--",
|
||||
rightFootPhase: "",
|
||||
rightFootLandIK: "-- / 0.00",
|
||||
rightPrediction: "disabled",
|
||||
rightPredictionScore: "--",
|
||||
maxPelvisRaise: options.maxPelvisRaise,
|
||||
maxPelvisDrop: options.maxPelvisDrop,
|
||||
maxFootRaise: options.maxFootRaise,
|
||||
maxFootDrop: options.maxFootDrop,
|
||||
plantedHeightSpeed: options.plantedHeightSpeed,
|
||||
penetrationLiftSpeed: options.penetrationLiftSpeed,
|
||||
soleHalfWidth: options.soleHalfWidth,
|
||||
soleToeExtend: options.soleToeExtend,
|
||||
soleHeelExtend: options.soleHeelExtend,
|
||||
soleSkinThickness: options.soleSkinThickness,
|
||||
maxPredictionClearance: options.maxPredictionClearance,
|
||||
};
|
||||
footIKDebugParams = params;
|
||||
|
||||
const applyFootIKOptions = (patch) => {
|
||||
footIK.configure(patch);
|
||||
};
|
||||
|
||||
footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled);
|
||||
|
||||
gui = new GUI({ title: "Foot IK Controls", width: 320 });
|
||||
if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close();
|
||||
Object.assign(gui.domElement.style, {
|
||||
position: "fixed",
|
||||
top: "12px",
|
||||
right: "12px",
|
||||
maxHeight: "calc(100vh - 24px)",
|
||||
overflowY: "auto",
|
||||
});
|
||||
gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
const collisionFolder = gui.addFolder("Collision Debug");
|
||||
collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => {
|
||||
player.setColliderDebug(value);
|
||||
});
|
||||
collisionFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => {
|
||||
player.setPlayerCapsuleDebug(value);
|
||||
});
|
||||
collisionFolder.open();
|
||||
|
||||
const footIKFolder = gui.addFolder("Foot IK");
|
||||
footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => {
|
||||
footIK.setEnabled(value);
|
||||
footIK.setDebugEnabled(value && params.footIKDebug);
|
||||
});
|
||||
footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => {
|
||||
footIK.setDebugEnabled(value && params.footIKEnabled);
|
||||
});
|
||||
footIKFolder.add(params, "predictivePlacement").name("Predictive Placement").onChange((value) => {
|
||||
footIK.configure({ predictivePlacement: value });
|
||||
});
|
||||
footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => {
|
||||
footIK.configure({ straightPoleEnabled: value });
|
||||
});
|
||||
|
||||
const footIKRuntimeFolder = footIKFolder.addFolder("Runtime");
|
||||
footIKRuntimeFolder.add(params, "pelvisOffset").name("Pelvis Offset").decimals(2).listen().disable();
|
||||
footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "leftPrediction").name("Left Prediction").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "leftPredictionScore").name("Left Score").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "rightPrediction").name("Right Prediction").listen().disable();
|
||||
footIKRuntimeFolder.add(params, "rightPredictionScore").name("Right Score").listen().disable();
|
||||
footIKRuntimeFolder.close();
|
||||
|
||||
const pelvisFolder = footIKFolder.addFolder("Pelvis");
|
||||
pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ maxPelvisRaise: value });
|
||||
});
|
||||
pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ maxPelvisDrop: value });
|
||||
});
|
||||
writePelvisOffsetController = pelvisFolder.add(params, "writePelvisOffset", -60, 60, 0.1)
|
||||
.name("Write Offset")
|
||||
.decimals(1)
|
||||
.onChange((value) => {
|
||||
footIK.setPelvisOffset(value);
|
||||
});
|
||||
pelvisFolder.add(params, "clearPelvisOffset").name("Clear Offset");
|
||||
pelvisFolder.close();
|
||||
|
||||
const footReachFolder = footIKFolder.addFolder("Foot Reach");
|
||||
footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ maxFootRaise: value });
|
||||
});
|
||||
footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ maxFootDrop: value });
|
||||
});
|
||||
footReachFolder.add(params, "plantedHeightSpeed", 50, 500, 1).name("Planted Speed").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ plantedHeightSpeed: value });
|
||||
});
|
||||
footReachFolder.add(params, "penetrationLiftSpeed", 50, 500, 1).name("Penetration Speed").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ penetrationLiftSpeed: value });
|
||||
});
|
||||
footReachFolder.close();
|
||||
|
||||
const soleFolder = footIKFolder.addFolder("Sole Layout");
|
||||
soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => {
|
||||
applyFootIKOptions({ soleHalfWidth: value });
|
||||
});
|
||||
soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => {
|
||||
applyFootIKOptions({ soleToeExtend: value });
|
||||
});
|
||||
soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => {
|
||||
applyFootIKOptions({ soleHeelExtend: value });
|
||||
});
|
||||
soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => {
|
||||
applyFootIKOptions({ soleSkinThickness: value });
|
||||
});
|
||||
soleFolder.close();
|
||||
|
||||
const predictionFolder = footIKFolder.addFolder("Prediction");
|
||||
predictionFolder.add(params, "maxPredictionClearance", 0, 100, 1).name("Max Clearance").decimals(0).onChange((value) => {
|
||||
applyFootIKOptions({ maxPredictionClearance: value });
|
||||
});
|
||||
predictionFolder.close();
|
||||
footIKFolder.open();
|
||||
}
|
||||
|
||||
// 更新天空太阳方向。
|
||||
function updateSkySun(light) {
|
||||
sky.material.uniforms["sunPosition"].value.copy(light.position).normalize();
|
||||
}
|
||||
|
||||
// 创建测试场景
|
||||
function buildTestCourse() {
|
||||
const root = new Group();
|
||||
const groundMat = new MeshStandardMaterial({ color: 0xd8d5cf, roughness: 0.9 });
|
||||
const stairMats = [
|
||||
new MeshStandardMaterial({ color: 0xb8b0a1, roughness: 0.75 }),
|
||||
new MeshStandardMaterial({ color: 0xc3a28f, roughness: 0.75 }),
|
||||
new MeshStandardMaterial({ color: 0xa8b4c4, roughness: 0.75 }),
|
||||
];
|
||||
const rampMats = [
|
||||
new MeshStandardMaterial({ color: 0xadbca8, roughness: 0.8 }),
|
||||
new MeshStandardMaterial({ color: 0x9fb7b4, roughness: 0.8 }),
|
||||
new MeshStandardMaterial({ color: 0xc0b48c, roughness: 0.8 }),
|
||||
];
|
||||
const speedBumpMats = [
|
||||
new MeshStandardMaterial({ color: 0xe5b73b, roughness: 0.72 }),
|
||||
new MeshStandardMaterial({ color: 0x4b4a46, roughness: 0.82 }),
|
||||
];
|
||||
const wallMat = new MeshStandardMaterial({ color: 0x9a9691, roughness: 0.88 });
|
||||
|
||||
addBox(root, new Vector3(0, -0.06, 0), new Vector3(20, 0.12, 12), groundMat);
|
||||
createBoundaryWalls(root, wallMat);
|
||||
|
||||
createStairs(root, new Vector3(-6.5, 0, -3.5), 0.08, stairMats[0]);
|
||||
createStairs(root, new Vector3(-1.2, 0, -3.5), 0.12, stairMats[1]);
|
||||
createStairs(root, new Vector3(4.1, 0, -3.5), 0.16, stairMats[2]);
|
||||
|
||||
createSpeedBumpCourse(root, -0.25, speedBumpMats);
|
||||
|
||||
createRamp(root, new Vector3(-6.1, 0, 3), 10, rampMats[0]);
|
||||
createRamp(root, new Vector3(-0.3, 0, 3), 18, rampMats[1]);
|
||||
createRamp(root, new Vector3(5.8, 0, 3), 28, rampMats[2]);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// 创建地面网格
|
||||
function addGroundGrid(root) {
|
||||
const width = 20;
|
||||
const depth = 12;
|
||||
const step = 1;
|
||||
const points = [];
|
||||
|
||||
for (let x = -width / 2; x <= width / 2 + 0.0001; x += step) {
|
||||
points.push(new Vector3(x, 0, -depth / 2), new Vector3(x, 0, depth / 2));
|
||||
}
|
||||
for (let z = -depth / 2; z <= depth / 2 + 0.0001; z += step) {
|
||||
points.push(new Vector3(-width / 2, 0, z), new Vector3(width / 2, 0, z));
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry().setFromPoints(points);
|
||||
const material = new LineBasicMaterial({ color: 0xbcb7af });
|
||||
const grid = new LineSegments(geometry, material);
|
||||
grid.position.y = 0.004;
|
||||
root.add(grid);
|
||||
return grid;
|
||||
}
|
||||
|
||||
// 创建一组边界墙
|
||||
function createBoundaryWalls(root, material) {
|
||||
const wallHeight = 1.2;
|
||||
const wallThickness = 0.16;
|
||||
const halfWidth = 10;
|
||||
const halfDepth = 6;
|
||||
const centerY = wallHeight / 2;
|
||||
|
||||
addBox(root, new Vector3(0, centerY, -halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material);
|
||||
addBox(root, new Vector3(0, centerY, halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material);
|
||||
addBox(root, new Vector3(-halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material);
|
||||
addBox(root, new Vector3(halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material);
|
||||
}
|
||||
|
||||
// 创建一组楼梯
|
||||
function createStairs(root, start, stepHeight, material) {
|
||||
const stepCount = 7;
|
||||
const stepDepth = 0.42;
|
||||
const width = 1.9;
|
||||
|
||||
for (let i = 0; i < stepCount; i++) {
|
||||
const height = stepHeight * (i + 1);
|
||||
addBox(
|
||||
root,
|
||||
new Vector3(start.x + i * stepDepth, height / 2, start.z),
|
||||
new Vector3(stepDepth, height, width),
|
||||
material,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 在楼梯和斜坡之间创建 10 个高度统一、宽度不超过 0.32 的长方体。
|
||||
function createSpeedBumpCourse(root, centerZ, materials) {
|
||||
const height = 0.140;
|
||||
const depth = 1.7;
|
||||
const widths = [0.14, 0.16, 0.18, 0.20, 0.22, 0.24, 0.26, 0.28, 0.30, 0.32];
|
||||
const gaps = [0.32, 0.58, 0.41, 0.75, 0.36, 0.63, 0.48, 0.71, 0.54];
|
||||
const totalWidth = widths.reduce((sum, width) => sum + width, 0)
|
||||
+ gaps.reduce((sum, gap) => sum + gap, 0);
|
||||
let cursorX = -totalWidth / 2;
|
||||
|
||||
widths.forEach((width, index) => {
|
||||
addBox(
|
||||
root,
|
||||
new Vector3(cursorX + width / 2, height / 2, centerZ),
|
||||
new Vector3(width, height, depth),
|
||||
materials[index % materials.length],
|
||||
);
|
||||
cursorX += width + (gaps[index] ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 创建一个斜坡
|
||||
function createRamp(root, position, angleDeg, material) {
|
||||
const length = 3.4;
|
||||
const width = 1.8;
|
||||
const height = Math.tan(angleDeg * Math.PI / 180) * length;
|
||||
const ramp = new Mesh(createRampGeometry(length, height, width), material);
|
||||
ramp.position.copy(position);
|
||||
ramp.castShadow = true;
|
||||
ramp.receiveShadow = true;
|
||||
root.add(ramp);
|
||||
return ramp;
|
||||
}
|
||||
|
||||
// 创建斜坡碰撞几何体。
|
||||
function createRampGeometry(length, height, width) {
|
||||
const halfLength = length / 2;
|
||||
const halfWidth = width / 2;
|
||||
const vertices = [
|
||||
-halfLength, 0, -halfWidth,
|
||||
halfLength, 0, -halfWidth,
|
||||
halfLength, height, -halfWidth,
|
||||
-halfLength, 0, halfWidth,
|
||||
halfLength, 0, halfWidth,
|
||||
halfLength, height, halfWidth,
|
||||
];
|
||||
const indices = [
|
||||
0, 1, 4, 0, 4, 3,
|
||||
1, 2, 5, 1, 5, 4,
|
||||
0, 3, 5, 0, 5, 2,
|
||||
0, 2, 1,
|
||||
3, 4, 5,
|
||||
];
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
|
||||
geometry.setIndex(indices);
|
||||
const flatGeometry = geometry.toNonIndexed();
|
||||
flatGeometry.computeVertexNormals();
|
||||
return flatGeometry;
|
||||
}
|
||||
|
||||
function addBox(root, position, scale, material) {
|
||||
const mesh = new Mesh(new BoxGeometry(1, 1, 1), material);
|
||||
mesh.position.copy(position);
|
||||
mesh.scale.copy(scale);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
root.add(mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// 启用模型阴影
|
||||
function enableModelShadows(root) {
|
||||
root?.traverse(obj => {
|
||||
if (!obj.isMesh) return;
|
||||
obj.castShadow = true;
|
||||
obj.receiveShadow = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 每帧调用
|
||||
function animate() {
|
||||
if (player) {
|
||||
player.update();
|
||||
} else {
|
||||
controls.update();
|
||||
}
|
||||
updateFootIKDebugPanel();
|
||||
renderer.render(scene, camera);
|
||||
stats?.update();
|
||||
}
|
||||
|
||||
// 更新 Foot IK 运行状态只读字段。
|
||||
function updateFootIKDebugPanel() {
|
||||
if (!footIK || !footIKDebugParams) return;
|
||||
footIKDebugParams.pelvisOffset = footIK.getPelvisOffset();
|
||||
footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left");
|
||||
footIKDebugParams.leftFootLandIK = formatFootLandIK(
|
||||
footIK.getFootTimeToLand("left"),
|
||||
footIK.getFootIKWeight("left"),
|
||||
);
|
||||
footIKDebugParams.leftPrediction = footIK.getPredictiveFootDebugText("left");
|
||||
footIKDebugParams.leftPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("left"));
|
||||
footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right");
|
||||
footIKDebugParams.rightFootLandIK = formatFootLandIK(
|
||||
footIK.getFootTimeToLand("right"),
|
||||
footIK.getFootIKWeight("right"),
|
||||
);
|
||||
footIKDebugParams.rightPrediction = footIK.getPredictiveFootDebugText("right");
|
||||
footIKDebugParams.rightPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("right"));
|
||||
}
|
||||
|
||||
function formatFootLandIK(landTime, ikWeight) {
|
||||
const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--";
|
||||
const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--";
|
||||
return `${land} / ${weight}`;
|
||||
}
|
||||
|
||||
function formatPredictionScore(value) {
|
||||
return Number.isFinite(value) ? value.toFixed(2) : "--";
|
||||
}
|
||||
|
||||
// 鼠标右键慢动作
|
||||
function onMouseDown(event) {
|
||||
if (!player) return;
|
||||
if (event.button == 2) {
|
||||
rightMouseSlowMotion = true;
|
||||
}
|
||||
if (event.button == 0) {
|
||||
leftMouseSlowMotion = true;
|
||||
}
|
||||
updatePlayerTimeScale();
|
||||
}
|
||||
|
||||
// 鼠标右键慢动作释放
|
||||
function onMouseUp(event) {
|
||||
if (event.button == 2) {
|
||||
rightMouseSlowMotion = false;
|
||||
}
|
||||
if (event.button == 0) {
|
||||
leftMouseSlowMotion = false;
|
||||
}
|
||||
updatePlayerTimeScale();
|
||||
|
||||
}
|
||||
|
||||
// 集中更新 player.timeScale
|
||||
function releaseRightMouseSlowMotion() {
|
||||
rightMouseSlowMotion = false;
|
||||
leftMouseSlowMotion = false;
|
||||
updatePlayerTimeScale();
|
||||
}
|
||||
|
||||
// 更新 player.timeScale
|
||||
function updatePlayerTimeScale() {
|
||||
if (!player) return;
|
||||
|
||||
if (leftMouseSlowMotion) {
|
||||
player.timeScale = 0;
|
||||
} else if (rightMouseSlowMotion) {
|
||||
player.timeScale = 0.1;
|
||||
} else {
|
||||
player.timeScale = savedPlayerTimeScale;
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口尺寸变化
|
||||
function onResize() {
|
||||
const container = document.querySelector("#container");
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>three-player-controller</title>
|
||||
<link rel="stylesheet" href="./styles/common.css" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#container {
|
||||
overflow: hidden !important;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
<script src="./loader.js" data-demo="glTF Scene"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
<div class="hud" data-hint-mode="on-foot" role="note" aria-label="Controls">
|
||||
<div class="hint-set" data-for="on-foot">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Move</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Sprint</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Jump</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>E</kbd></span>
|
||||
<span class="hint-text">Enter</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>F</kbd></span>
|
||||
<span class="hint-text">Toggle Fly</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-set" data-for="vehicle">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Drive</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Drift</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Brake</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>E</kbd></span>
|
||||
<span class="hint-text">Exit</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>R</kbd></span>
|
||||
<span class="hint-text">Reset Vehicle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-credit">
|
||||
Scene model:
|
||||
<a href="https://sketchfab.com/3d-models/burnout-revenge-central-route-crash-junction-cfb4bec737644befb4a39d28e30a3926" target="_blank" rel="noopener noreferrer">Sketchfab</a>
|
||||
<br />
|
||||
Vehicle model:
|
||||
<a href="https://sketchfab.com/3d-models/generic-sedan-car-58c33766470d46e7b2aed542650494e5" target="_blank" rel="noopener noreferrer">Generic Sedan Car</a>
|
||||
</div>
|
||||
|
||||
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/glTF.js" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./img/source.svg" alt="" />
|
||||
</a>
|
||||
|
||||
<script type="module" src="./control-hints.js"></script>
|
||||
<script type="module" src="./glTF.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,383 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>three-player-controller</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@900&family=Nunito:wght@700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--accent: #e8a020;
|
||||
--card-bg: #f0f0f0;
|
||||
--card-hover: #e6e6e6;
|
||||
--text: #1a1a2e;
|
||||
--muted: #999;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: #ececec;
|
||||
color: var(--text);
|
||||
font-family: "Nunito", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2.5rem 2rem 4rem;
|
||||
}
|
||||
|
||||
/* ── GitHub corner ── */
|
||||
.github-link {
|
||||
position: fixed;
|
||||
top: 1.2rem;
|
||||
right: 1.4rem;
|
||||
color: #333;
|
||||
transition: color 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
.github-link:hover {
|
||||
color: #000;
|
||||
}
|
||||
.github-link svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.version-switch {
|
||||
position: fixed;
|
||||
top: 1.2rem;
|
||||
left: 1.4rem;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.version-switch-active {
|
||||
padding: 6px 14px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.version-switch-link {
|
||||
padding: 6px 14px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.version-switch-link:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Cinzel", serif;
|
||||
font-size: clamp(1.4rem, 3.5vw, 2.2rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.desc {
|
||||
font-size: 0.88rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.sections {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.7rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── grid ── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.2rem;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* ── card ── */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 4/3;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
background 0.18s ease;
|
||||
opacity: 0;
|
||||
animation: rise 0.4s ease forwards;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
background: var(--card-hover);
|
||||
box-shadow:
|
||||
0 12px 36px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 2px var(--accent);
|
||||
}
|
||||
.card:active {
|
||||
transform: translateY(-1px) scale(1.005);
|
||||
}
|
||||
|
||||
.card-thumb {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.card-thumb-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(245, 181, 53, 0.7), transparent 35%),
|
||||
linear-gradient(135deg, #263238, #455a64);
|
||||
font-size: clamp(1.8rem, 5vw, 3.5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.card-info {
|
||||
padding: 0.9rem 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.card-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.play-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35);
|
||||
transition:
|
||||
transform 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.card:hover .play-btn {
|
||||
transform: scale(1.12);
|
||||
background: #f5b535;
|
||||
}
|
||||
.play-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
fill: #fff;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* stagger */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.card:nth-child(1) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
.card:nth-child(2) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.card:nth-child(3) {
|
||||
animation-delay: 0.19s;
|
||||
}
|
||||
.card:nth-child(4) {
|
||||
animation-delay: 0.26s;
|
||||
}
|
||||
.card:nth-child(5) {
|
||||
animation-delay: 0.33s;
|
||||
}
|
||||
.card:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="github-link" href="https://github.com/hh-hang/three-player-controller" target="_blank" title="GitHub">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577
|
||||
0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.335-1.755-1.335-1.755
|
||||
-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998
|
||||
.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22
|
||||
-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405
|
||||
1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176
|
||||
.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22
|
||||
0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297
|
||||
c0-6.627-5.373-12-12-12"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="version-switch">
|
||||
<span class="version-switch-active">Three.js</span>
|
||||
<a href="https://hh-hang.github.io/cesium-player-controller/" class="version-switch-link">CesiumJS</a>
|
||||
</div>
|
||||
|
||||
<h1>three-player-controller</h1>
|
||||
<p class="desc">A lightweight character controller for Three.js, accelerated by three-mesh-bvh for smooth performance in large-scale 3D scenes.</p>
|
||||
|
||||
<div class="sections">
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">Demo</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<a class="card" href="./showcase.html">
|
||||
<div class="card-thumb">
|
||||
<img src="./img/covers/showcase.png" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">Showcase</span><em class="badge">Full Controls</em>
|
||||
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./footIK.html">
|
||||
<div class="card-thumb">
|
||||
<img src="./img/covers/footik.png" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">Foot IK</span>
|
||||
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./shooting/shooting.html">
|
||||
<div class="card-thumb">
|
||||
<img src="./img/covers/shooting.jpg" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">FPS Game</span>
|
||||
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-title">Model Formats</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<a class="card" href="./glTF.html">
|
||||
<div class="card-thumb">
|
||||
<img src="./img/covers/glTF.png" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">glTF Scene</span><em class="badge">Full Controls</em>
|
||||
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./3dtilesScene.html">
|
||||
<div class="card-thumb">
|
||||
<img src="./img/covers/3dtiles.png" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">3DTiles Scene</span>
|
||||
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./3dgs.html">
|
||||
<div class="card-thumb">
|
||||
<img src="./img/covers/3dgs.png" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">3DGS Scene</span>
|
||||
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.20467473566532135,-4.759437705400649e-21,1.2439442348830942e-21,0.9788300395011902],"translation":[-1.5903435849255598e-27,0.0711912140250206,2.0489096641540527e-08]},{"children":[0],"name":"neck_01","rotation":[0.3088374435901642,8.337463586651673e-21,-5.849849968646638e-21,0.95111483335495],"translation":[-1.0141478263557337e-20,0.19815585017204285,2.60770320892334e-08]},{"name":"index_04_leaf_l","rotation":[-8.381903171539307e-09,1,-1.5432005174599583e-14,3.6517765238386346e-06],"translation":[9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[2],"name":"index_03_l","rotation":[1.0023382124302316e-09,7.603918037758783e-13,-0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[3],"name":"index_02_l","rotation":[1.8987577732332284e-08,1.4024364247688936e-07,-0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[4],"name":"index_01_l","rotation":[-0.0033091865479946136,0.7071018815040588,-0.003309202380478382,0.7070961594581604],"translation":[0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_l","rotation":[2.0489096641540527e-08,1,6.335320587363621e-14,3.6517765238386346e-06],"translation":[2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[6],"name":"middle_03_l","rotation":[2.420814304571195e-08,1.4173854798182894e-11,-0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[-4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[7],"name":"middle_02_l","rotation":[-2.949975197452659e-08,1.402399334438087e-07,0.0038676888216286898,0.9999924898147583],"translation":[1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[8],"name":"middle_01_l","rotation":[-0.007979890331625938,0.7070646286010742,-0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_l","rotation":[8.381903171539307e-09,1,5.647658583201112e-14,3.65177675121231e-06],"translation":[-1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[10],"name":"pinky_03_l","rotation":[-2.1800540039862426e-08,5.25581791629981e-12,0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[-1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[11],"name":"pinky_02_l","rotation":[1.03155857189563e-08,1.4017835781032773e-07,-0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[-2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[12],"name":"pinky_01_l","rotation":[-0.01044775452464819,0.7070325016975403,-0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_l","rotation":[2.7474015951156616e-08,1,1.0721373739979931e-13,3.65177675121231e-06],"translation":[9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[14],"name":"ring_03_l","rotation":[2.6234243222234e-08,4.3393351412124304e-11,-0.0034884933847934008,0.9999939203262329],"translation":[7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[15],"name":"ring_02_l","rotation":[-1.8197205520209536e-08,1.4009778226409253e-07,0.002474082401022315,0.9999969601631165],"translation":[-9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[16],"name":"ring_01_l","rotation":[0.0025809321086853743,0.7071049213409424,0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_l","rotation":[-1.2782692238033633e-08,0.48539119958877563,2.0375746601075662e-07,0.8742970824241638],"scale":[0.9999999403953552,1,1],"translation":[-1.2670352589339018e-07,0.03127264976501465,3.073364496231079e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-0.0012816648231819272,5.811178561998531e-05,0.0002541383437346667,0.9999991655349731],"scale":[1,1,0.9999999403953552],"translation":[2.2436142899096012e-07,0.035620346665382385,5.122274160385132e-09]},{"children":[19],"name":"thumb_02_l","rotation":[0.0019128327257931232,0.00019216042710468173,-0.0004484721284825355,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[1.453081495128572e-07,0.03582108020782471,-1.3038516044616Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.13207007944583893,0,0,0.991240382194519],"translation":[0,0.08285234868526459,9.313225746154785e-09]},{"children":[0],"name":"neck_01","rotation":[0.25725916028022766,0,0,0.9663424491882324],"translation":[0,0.21509313583374023,1.862645149230957e-08]},{"name":"index_04_leaf_l","rotation":[-1.789221437675792e-09,0.9998229742050171,3.712300440383842e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[-1.1732481652870774e-09,0.024110054597258568,-1.3058706826996058e-07]},{"children":[2],"name":"index_03_l","rotation":[1.9416402210481465e-06,-7.145895324356388e-06,-0.00018566079961601645,1],"scale":[1,1,1.0000001192092896],"translation":[-5.757101462222636e-10,0.027911342680454254,5.889705789741129e-08]},{"children":[3],"name":"index_02_l","rotation":[3.8516853351211466e-07,4.9490859964862466e-05,-0.0005330402054823935,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[-8.440110832452774e-10,0.032612428069114685,1.2031887308694422e-07]},{"children":[4],"name":"index_01_l","rotation":[0.008505093865096569,0.7068116664886475,-0.01852414943277836,0.7071080803871155],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.007159475702792406,0.1174025684595108,0.040643345564603806]},{"name":"middle_04_leaf_l","rotation":[-1.5724443969133972e-08,0.9998255372047424,5.420716675530457e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[2.2610038286074996e-09,0.030519673600792885,1.955777406692505e-07]},{"children":[6],"name":"middle_03_l","rotation":[-4.686012857746391e-07,-5.432930265669711e-05,-0.0014198303688317537,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-5.793481250293553e-10,0.030116640031337738,-6.39629433862865e-08]},{"children":[7],"name":"middle_02_l","rotation":[5.612571953861334e-07,7.84045914770104e-05,0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[-4.098183126188815e-09,0.03752607852220535,-1.7083948478102684e-08]},{"children":[8],"name":"middle_01_l","rotation":[0.005067416001111269,0.7067627906799316,-0.02195817232131958,0.7070916295051575],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.004682549275457859,0.1154387816786766,0.015113381668925285]},{"name":"pinky_04_leaf_l","rotation":[-6.453817924523264e-09,0.9998291730880737,2.147510969052746e-07,0.018483733758330345],"translation":[2.8803697205148637e-09,0.021523315459489822,-3.95521055907011e-08]},{"children":[10],"name":"pinky_03_l","rotation":[3.3508222259115428e-06,-0.00013744289753958583,0.0004660093109123409,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[-4.249159246683121e-09,0.027430830523371696,1.7808633856475353e-07]},{"children":[11],"name":"pinky_02_l","rotation":[3.9539517615594377e-07,9.536039578961208e-05,-0.0015674401074647903,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[1.1932570487260818e-08,0.03163102641701698,-1.7530692275613546e-07]},{"children":[12],"name":"pinky_01_l","rotation":[0.002601056592538953,0.7066821455955505,-0.024414697661995888,0.7071049809455872],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.0060377283953130245,0.09854141622781754,-0.03461737558245659]},{"name":"ring_04_leaf_l","rotation":[8.940817863845041e-09,0.9998185634613037,5.402291662903735e-07,0.019049758091568947],"translation":[-9.167706593871117e-09,0.02910165674984455,5.82986103836447e-09]},{"children":[14],"name":"ring_03_l","rotation":[3.849756012641592e-06,-0.00025297756656073034,-0.003387551987543702,0.9999942183494568],"translation":[1.204170985147357e-09,0.028300179168581963,5.35583239980042e-08]},{"children":[15],"name":"ring_02_l","rotation":[-4.73642239740002e-06,0.00021853116049896926,0.00239976542070508,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[7.916241884231567e-09,0.03600123152136803,-8.07840478955768e-08]},{"children":[16],"name":"ring_01_l","rotation":[0.015323746018111706,0.7069170475006104,-0.011713176034390926,0.7070334553718567],"scale":[1,0.9999997615814209,1],"translation":[0.004751814063638449,0.10940060019493103,-0.012370164506137371]},{"name":"thumb_04_leaf_l","rotation":[1.285916511051255e-07,0.38270875811576843,-4.782022244853579e-08,0.9238690137863159],"scale":[1,0.9999999403953552,1],"translation":[-1.0035000741481781e-07,0.034558095037937164,2.1187588572502136e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-5.349284037947655e-08,-2.9730843380093575e-05,1.7080456018447876e-06,1],"translation":[-1.2048985809087753e-08,0.03549090400338173,-2.9336661100387573e-08]},{"children":[19],"name":"thumb_02_l","rotation":[6.149784894660115e-07,4.712928785011172e-06,-3.21120Line truncated
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#000000">
|
||||
<path fill="none" d="M0 0h24v24H0V0z"/>
|
||||
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,221 @@
|
||||
(function () {
|
||||
const script = document.currentScript || document.querySelector("script[src*='loader.js']");
|
||||
const demo = script?.dataset?.demo || "";
|
||||
const fade = 600;
|
||||
|
||||
if (!document.querySelector('link[href*="Cinzel"]')) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "https://fonts.googleapis.com/css2?family=Cinzel:wght@900&family=Nunito:wght@700&display=swap";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "__loader-style__";
|
||||
style.textContent = `
|
||||
html { height: 100%; }
|
||||
|
||||
#__loading-overlay__ {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
background: #ececec;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity ${fade}ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
#__loading-overlay__.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.__ldr-content__ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
.__ldr-play__ {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
border-radius: 50%;
|
||||
background: #e8a020;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.4rem;
|
||||
box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35);
|
||||
animation: __ldr-pulse__ 1.6s ease-in-out infinite;
|
||||
}
|
||||
.__ldr-play__ svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: #fff;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.__ldr-title__ {
|
||||
font-family: "Cinzel", serif;
|
||||
font-size: clamp(1.4rem, 3.5vw, 2.2rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
color: #1a1a2e;
|
||||
text-align: center;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.__ldr-meta__ {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45em;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.__ldr-kicker__ {
|
||||
font-family: "Nunito", sans-serif;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.__ldr-status__ {
|
||||
font-family: "Nunito", sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.__ldr-status__:empty {
|
||||
display: none;
|
||||
}
|
||||
.__ldr-progress__ {
|
||||
width: min(220px, 70vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.__ldr-track__ {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.__ldr-fill__ {
|
||||
height: 100%;
|
||||
width: 40%;
|
||||
background: #e8a020;
|
||||
border-radius: 2px;
|
||||
transform: translateX(-120%);
|
||||
animation: __ldr-slide__ 1.35s ease-in-out infinite;
|
||||
}
|
||||
.__ldr-fill__.is-determinate {
|
||||
width: 0%;
|
||||
transform: none;
|
||||
animation: none;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
.__ldr-pct__ {
|
||||
font-family: "Nunito", system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
letter-spacing: 0.05em;
|
||||
min-height: 1em;
|
||||
}
|
||||
@keyframes __ldr-pulse__ {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
background: #f5b535;
|
||||
box-shadow: 0 8px 24px rgba(232, 160, 32, 0.45);
|
||||
}
|
||||
}
|
||||
@keyframes __ldr-slide__ {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.__ldr-play__,
|
||||
.__ldr-fill__ {
|
||||
animation: none;
|
||||
}
|
||||
.__ldr-fill__:not(.is-determinate) {
|
||||
width: 100%;
|
||||
transform: none;
|
||||
}
|
||||
#__loading-overlay__ {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "__loading-overlay__";
|
||||
overlay.setAttribute("role", "status");
|
||||
overlay.setAttribute("aria-busy", "true");
|
||||
overlay.setAttribute("aria-live", "polite");
|
||||
overlay.innerHTML = `
|
||||
<div class="__ldr-content__">
|
||||
<div class="__ldr-play__" aria-hidden="true">
|
||||
<svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg>
|
||||
</div>
|
||||
<div class="__ldr-title__">three-player-controller</div>
|
||||
<div class="__ldr-meta__">
|
||||
<div class="__ldr-kicker__">Loading</div>
|
||||
<div class="__ldr-status__" id="__ldr-status__"></div>
|
||||
</div>
|
||||
<div class="__ldr-progress__" id="__ldr-progress__">
|
||||
<div class="__ldr-track__"><div class="__ldr-fill__" id="__ldr-fill__"></div></div>
|
||||
<div class="__ldr-pct__" id="__ldr-pct__"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.documentElement.appendChild(overlay);
|
||||
|
||||
const fill = overlay.querySelector("#__ldr-fill__");
|
||||
const pct = overlay.querySelector("#__ldr-pct__");
|
||||
const status = overlay.querySelector("#__ldr-status__");
|
||||
if (demo) status.textContent = demo;
|
||||
|
||||
window.setLoaderStatus = function (text) {
|
||||
if (status) status.textContent = text || "";
|
||||
};
|
||||
|
||||
window.setLoaderProgress = function (loaded, total) {
|
||||
if (!fill) return;
|
||||
if (total > 0) {
|
||||
fill.classList.add("is-determinate");
|
||||
const p = Math.min(100, Math.round((loaded / total) * 100));
|
||||
fill.style.width = p + "%";
|
||||
if (pct) pct.textContent = p + "%";
|
||||
} else {
|
||||
fill.classList.remove("is-determinate");
|
||||
fill.style.width = "";
|
||||
if (pct) pct.textContent = (loaded / 1048576).toFixed(1) + " MB";
|
||||
}
|
||||
};
|
||||
|
||||
window.hideLoader = function () {
|
||||
const el = document.getElementById("__loading-overlay__");
|
||||
if (!el || el.dataset.hiding) return;
|
||||
el.dataset.hiding = "1";
|
||||
el.classList.add("fade-out");
|
||||
el.setAttribute("aria-busy", "false");
|
||||
|
||||
let cleaned = false;
|
||||
const done = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
el.remove();
|
||||
document.getElementById("__loader-style__")?.remove();
|
||||
};
|
||||
el.addEventListener("transitionend", done, { once: true });
|
||||
setTimeout(done, fade + 80);
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
ACESFilmicToneMapping,
|
||||
DirectionalLight,
|
||||
MathUtils,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
VSMShadowMap,
|
||||
WebGLRenderer,
|
||||
} from "three";
|
||||
import { MapControls } from "three/examples/jsm/Addons.js";
|
||||
import { LightProbeGrid } from "three/addons/lighting/LightProbeGrid.js";
|
||||
import { LightProbeGridHelper } from "three/addons/helpers/LightProbeGridHelper.js";
|
||||
|
||||
|
||||
export function createSceneSetup({ container }) {
|
||||
// ==================== 渲染器 ====================
|
||||
const renderer = new WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.toneMapping = ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = VSMShadowMap;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// ==================== 场景 & 相机 ====================
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera(
|
||||
60,
|
||||
container.clientWidth / container.clientHeight,
|
||||
0.1,
|
||||
2000
|
||||
);
|
||||
|
||||
// ==================== 控制器 ====================
|
||||
const controls = new MapControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.maxDistance = 2000;
|
||||
|
||||
// ==================== 参数 ====================
|
||||
const params = {
|
||||
showLightHelper: true,
|
||||
lightAzimuth: 45,
|
||||
lightElevation: 45,
|
||||
lightIntensity: 10,
|
||||
shadows: true,
|
||||
probesEnabled: true,
|
||||
showProbes: false,
|
||||
probeSize: 0.5,
|
||||
boundsX: 8, boundsY: 3.2, boundsZ: -2,
|
||||
sizeX: 40, sizeY: 3, sizeZ: 25,
|
||||
countX: 20, countY: 4, countZ: 10,
|
||||
};
|
||||
|
||||
// ==================== 平行光 ====================
|
||||
const dirLight = new DirectionalLight(0xfff2dc, params.lightIntensity);
|
||||
dirLight.castShadow = true;
|
||||
dirLight.shadow.mapSize.setScalar(2048);
|
||||
dirLight.shadow.camera.left = -50;
|
||||
dirLight.shadow.camera.right = 50;
|
||||
dirLight.shadow.camera.top = 50;
|
||||
dirLight.shadow.camera.bottom = -50;
|
||||
dirLight.shadow.camera.near = 0.1;
|
||||
dirLight.shadow.camera.far = 200;
|
||||
dirLight.shadow.bias = -0.001;
|
||||
scene.add(dirLight);
|
||||
scene.add(dirLight.target);
|
||||
|
||||
function updateLightPosition() {
|
||||
const azimuth = MathUtils.degToRad(params.lightAzimuth);
|
||||
const elevation = MathUtils.degToRad(params.lightElevation);
|
||||
const radius = 100;
|
||||
const h = Math.cos(elevation) * radius;
|
||||
const v = Math.sin(elevation) * radius;
|
||||
dirLight.position.set(Math.cos(azimuth) * h, v, Math.sin(azimuth) * h);
|
||||
dirLight.target.position.set(0, 0, 0);
|
||||
dirLight.target.updateMatrixWorld();
|
||||
}
|
||||
|
||||
updateLightPosition();
|
||||
|
||||
// ==================== 光探针 ====================
|
||||
let probes = null;
|
||||
let probesHelper = null;
|
||||
let isBaking = false;
|
||||
let bakeQueued = false;
|
||||
|
||||
async function bakeProbes() {
|
||||
if (isBaking) { bakeQueued = true; return; }
|
||||
isBaking = true;
|
||||
do {
|
||||
bakeQueued = false;
|
||||
if (probes) { scene.remove(probes); probes.dispose(); }
|
||||
|
||||
probes = new LightProbeGrid(
|
||||
params.sizeX, params.sizeY, params.sizeZ,
|
||||
params.countX, params.countY, params.countZ
|
||||
);
|
||||
probes.position.set(params.boundsX, params.boundsY, params.boundsZ);
|
||||
probes.updateMatrixWorld(true); // 强制更新世界矩阵,否则 bake() 读到旧位置
|
||||
if (probesHelper) probesHelper.visible = false;
|
||||
|
||||
const far = Math.max(params.sizeX, params.sizeY, params.sizeZ) * 2;
|
||||
probes.bake(renderer, scene, { cubemapSize: 32, near: 0.05, far });
|
||||
probes.visible = params.probesEnabled;
|
||||
scene.add(probes);
|
||||
|
||||
if (!probesHelper) {
|
||||
probesHelper = new LightProbeGridHelper(probes, params.probeSize);
|
||||
scene.add(probesHelper);
|
||||
} else {
|
||||
probesHelper.probes = probes;
|
||||
probesHelper.update();
|
||||
}
|
||||
probesHelper.position.set(params.boundsX, params.boundsY, params.boundsZ);
|
||||
probesHelper.visible = params.showProbes;
|
||||
} while (bakeQueued);
|
||||
isBaking = false;
|
||||
}
|
||||
|
||||
|
||||
return { scene, renderer, camera, controls, dirLight, bakeProbes };
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { MathUtils, Quaternion, Vector3, AnimationMixer, AnimationClip, LoopOnce, LoopRepeat } from "three";
|
||||
import { playerController } from "../../../src/PlayerController";
|
||||
import { SpineIK } from "./spineIK.js";
|
||||
|
||||
const spineBoneNames = ["mixamorigSpine", "mixamorigSpine1", "mixamorigSpine2"];
|
||||
|
||||
// 运动状态集合(用于判断 isMoving)
|
||||
const locomotionStates = new Set([
|
||||
"idle",
|
||||
"walking",
|
||||
"walking_backward",
|
||||
"left_walking",
|
||||
"right_walking",
|
||||
"running",
|
||||
"jumping",
|
||||
"flyidle",
|
||||
"flying",
|
||||
]);
|
||||
|
||||
// 允许执行战斗逻辑的运动状态(上半身分离后,奔跑时也可开火/换弹)
|
||||
const combatAllowedLocomotion = new Set([
|
||||
"idle", "walking", "walking_backward", "left_walking", "right_walking", "running",
|
||||
]);
|
||||
|
||||
const minPitchAngle = -Math.PI * (60 / 180); // 俯仰最小角(向下)
|
||||
const maxPitchAngle = Math.PI * (40 / 180); // 俯仰最大角(向上)
|
||||
|
||||
export class LocalPlayer {
|
||||
constructor({ scene, camera, controls }) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this._camera = camera;
|
||||
this._controls = controls;
|
||||
|
||||
// ==================== 核心对象 ====================
|
||||
this._player = null; // playerController 实例
|
||||
this.spineIK = null; // 脊椎 IK 实例
|
||||
|
||||
// ==================== 运动状态 ====================
|
||||
this.pitchTarget1P = 0; // 第一人称俯仰累积值
|
||||
this.isMoving = false; // 当前帧是否处于移动状态(由 onAnimationChange 更新)
|
||||
this._locomotionState = "idle"; // 当前运动动画状态名
|
||||
|
||||
// ==================== 配置 ====================
|
||||
this._mouseSensitivity = 5;
|
||||
this._firstPersonPitchOffset = 0; // 第一人称相机俯仰初始偏移
|
||||
|
||||
// ==================== 外部注入 ====================
|
||||
this._isGunEngagedFn = null; // 由 WeaponController 注入,判断是否持枪
|
||||
|
||||
// ==================== 上半身动画层 ====================
|
||||
this._upperMixer = null; // 上半身专用 AnimationMixer(root = 模型根节点)
|
||||
this._upperBodyBoneNames = null; // 脊椎以上所有骨骼名称集合,用于过滤 partial clip
|
||||
this._upperBodyBones = null; // 脊椎以上骨骼引用数组(缓存,避免每帧 getObjectByName)
|
||||
this._upperBoneSnapshots = null; // 主 mixer 骨骼值快照
|
||||
this._upperActions = new Map(); // key → AnimationAction
|
||||
this._upperState = null; // 当前上半身动作
|
||||
|
||||
// ==================== 走路瞄准修正 ====================
|
||||
this._idleHipsQ = null; // 静止时保存的 hips 本地四元数,用于走路时抵消 hips 走路偏移
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 初始化 playerController、骨骼 IK、事件回调
|
||||
async init(config) {
|
||||
const { mouseSensitivity = 5, ...rest } = config;
|
||||
this._mouseSensitivity = mouseSensitivity;
|
||||
this._firstPersonPitchOffset = config.playerModelConfig?.firstPersonPitchOffset
|
||||
?? this._firstPersonPitchOffset;
|
||||
|
||||
this._player = new playerController();
|
||||
await this._player.init({
|
||||
scene: this._scene,
|
||||
camera: this._camera,
|
||||
controls: this._controls,
|
||||
mouseSensitivity,
|
||||
...rest,
|
||||
});
|
||||
|
||||
// 绑定脊椎 & 头部骨骼
|
||||
const model = this._player.getPlayerModel();
|
||||
const spineBones = spineBoneNames
|
||||
.map((n) => model?.getObjectByName(n))
|
||||
.filter(Boolean);
|
||||
const headBoneName = config.playerModelConfig?.headBoneName;
|
||||
const headBone = model?.getObjectByName(headBoneName) ?? null;
|
||||
this.spineIK = new SpineIK(spineBones, headBone);
|
||||
|
||||
// 上半身 mixer:root = 模型根节点(与主 mixer 一致,路径解析最可靠)
|
||||
// 通过 partial clip(只含脊椎以上骨骼的 track)来限制写入范围,
|
||||
// 主 mixer 更新后再更新 upper mixer,后者覆写脊椎以上骨骼,下半身保持 locomotion 值。
|
||||
if (spineBones.length > 0) {
|
||||
this._upperBodyBoneNames = new Set();
|
||||
this._upperBodyBones = [];
|
||||
spineBones[0].traverse(b => {
|
||||
this._upperBodyBoneNames.add(b.name);
|
||||
this._upperBodyBones.push(b);
|
||||
});
|
||||
// 预分配快照数组,避免每帧 GC
|
||||
this._upperBoneSnapshots = this._upperBodyBones.map(() => new Quaternion());
|
||||
this._upperMixer = new AnimationMixer(model);
|
||||
}
|
||||
|
||||
// 监听动画切换,更新 isMoving
|
||||
this._player.onAnimationChange = (name) => {
|
||||
if (locomotionStates.has(name)) this._locomotionState = name;
|
||||
this.isMoving =
|
||||
name === "walking" ||
|
||||
name === "left_walking" ||
|
||||
name === "right_walking" ||
|
||||
name === "walking_backward" ||
|
||||
name === "running";
|
||||
};
|
||||
|
||||
// 接管第一人称鼠标移动
|
||||
this._player.onTowardChange = (dx, dy, speed) => {
|
||||
if (!this._player.getIsFirstPerson()) return;
|
||||
|
||||
// 水平朝向
|
||||
this._player.getPlayerCapsule().rotateY(
|
||||
-dx * speed * this._mouseSensitivity
|
||||
);
|
||||
|
||||
// 俯仰角累积
|
||||
this.pitchTarget1P = MathUtils.clamp(
|
||||
this.pitchTarget1P + (-dy * speed * this._mouseSensitivity),
|
||||
minPitchAngle,
|
||||
maxPitchAngle
|
||||
);
|
||||
|
||||
// 未持枪时直接驱动相机
|
||||
if (!this._isGunEngagedFn?.()) {
|
||||
this._camera.rotation.x = MathUtils.clamp(
|
||||
this._camera.rotation.x + (-dy * speed * this._mouseSensitivity),
|
||||
minPitchAngle,
|
||||
maxPitchAngle
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 视角切换
|
||||
this._player.onViewChange = (isFirstPerson) => {
|
||||
if (isFirstPerson) {
|
||||
if (headBoneName) {
|
||||
console.log(headBoneName);
|
||||
this._camera.position.z = 8;
|
||||
this._camera.position.x = 15;
|
||||
} else {
|
||||
this._camera.position.z = 0;
|
||||
this._camera.position.x = 0;
|
||||
}
|
||||
this._camera.rotation.x = this._firstPersonPitchOffset;
|
||||
this._player.setEnableToward(false);
|
||||
// 同步控制器俯仰角
|
||||
const targetPolar = this._controls.getPolarAngle() - Math.PI / 2 + Math.PI * (7.5 / 180);
|
||||
this.pitchTarget1P = targetPolar;
|
||||
// 航向角偏移
|
||||
this._player.getPlayerCapsule().rotateY(-Math.PI * (17 / 180));
|
||||
} else {
|
||||
this._player.setEnableToward(true);
|
||||
// 持枪瞄准状态下刷新一次动画,保证骨骼复原
|
||||
if (this._player.getCurrentPlayerAnimationName().includes("rifle_idle_aim")) {
|
||||
this._player.playAnimation("idle");
|
||||
}
|
||||
// 同步第一人称俯仰角
|
||||
const targetPolar = Math.PI / 2 + this.pitchTarget1P - Math.PI * (7.5 / 180);
|
||||
this._controls.minPolarAngle = targetPolar;
|
||||
this._controls.maxPolarAngle = targetPolar;
|
||||
this._controls.update();
|
||||
this._controls.minPolarAngle = minPitchAngle + Math.PI / 2;
|
||||
this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2;
|
||||
// 航向角偏移
|
||||
const delta = Math.PI * (17 / 180);
|
||||
const offset = this._camera.position.clone().sub(this._controls.target);
|
||||
offset.applyAxisAngle(new Vector3(0, 1, 0), delta);
|
||||
this._camera.position.copy(this._controls.target).add(offset);
|
||||
this._controls.update();
|
||||
}
|
||||
};
|
||||
|
||||
// 限制第三人称俯仰角
|
||||
this._controls.minPolarAngle = minPitchAngle + Math.PI / 2;
|
||||
this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2;
|
||||
}
|
||||
|
||||
// ==================== 外部注入 ====================
|
||||
|
||||
// 由 WeaponController 注入,让 1P 俯仰驱动能感知持枪状态
|
||||
setGunEngagedGetter(fn) {
|
||||
this._isGunEngagedFn = fn;
|
||||
}
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
|
||||
// 每帧驱动动画与物理
|
||||
// dt 须由主循环传入,上半身 mixer 在主 mixer 之后更新才能正确覆写骨骼
|
||||
update(dt) {
|
||||
this._player?.update(dt);
|
||||
|
||||
if (this._upperMixer && dt != null) {
|
||||
const ua = this._upperState;
|
||||
if (!ua) return;
|
||||
|
||||
// Three.js PropertyMixer.apply() 有变更检测优化:仅当 accu0 ≠ accu1 时
|
||||
// 才调用 setValue() 写入骨骼。对于完全相同帧的动画(两帧值一致),
|
||||
// 两个 accu buffer 永远相等,导致 setValue() 被跳过,主 mixer 的
|
||||
// locomotion 动画透过来。修复:每帧更新前把两个 accu buffer 填为 NaN,
|
||||
// 使比较永远不相等,强制 setValue() 每帧执行,确保上半身覆盖生效。
|
||||
if (ua._propertyBindings) {
|
||||
for (const pm of ua._propertyBindings) {
|
||||
if (pm?.buffer) {
|
||||
const s = pm.valueSize;
|
||||
pm.buffer.fill(NaN, s, s * 3); // dirty accu0 + accu1
|
||||
}
|
||||
}
|
||||
}
|
||||
this._upperMixer.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 上半身动画层 ====================
|
||||
|
||||
// 从指定 clip 的 t=0 帧直接读取 hips 四元数
|
||||
initIdleHipsQ(clipName) {
|
||||
const clip = this._player?.animation?.clips?.find(c => c.name === clipName);
|
||||
if (!clip) { console.warn(`initIdleHipsQ: 找不到 "${clipName}"`); return; }
|
||||
const track = clip.tracks.find(t => t.name === 'mixamorigHips.quaternion');
|
||||
if (!track || track.values.length < 4) return;
|
||||
this._idleHipsQ = new Quaternion(track.values[0], track.values[1], track.values[2], track.values[3]);
|
||||
}
|
||||
|
||||
// 在上半身 mixer 上注册一个动画
|
||||
registerUpperAnimation(key, clipName, opts = {}) {
|
||||
if (!this._upperMixer || !this._upperBodyBoneNames) return;
|
||||
const clips = this._player?.animation?.clips;
|
||||
if (!clips) return;
|
||||
const clip = clips.find(c => c.name === clipName);
|
||||
if (!clip) { console.warn(`registerUpperAnimation: 找不到 "${clipName}"`); return; }
|
||||
|
||||
// 只保留脊椎以上骨骼的 track,其余 track(hips、腿部等)丢弃
|
||||
const upperTracks = clip.tracks.filter(t => {
|
||||
const boneName = t.name.split('.')[0];
|
||||
return this._upperBodyBoneNames.has(boneName);
|
||||
});
|
||||
const partialClip = new AnimationClip(clip.name + '_upper_' + key, clip.duration, upperTracks);
|
||||
|
||||
const action = this._upperMixer.clipAction(partialClip);
|
||||
action.setLoop(opts.loop === false ? LoopOnce : LoopRepeat, Infinity);
|
||||
action.clampWhenFinished = opts.clampWhenFinished ?? false;
|
||||
const ts = opts.duration ? clip.duration / opts.duration : (opts.timeScale ?? 1);
|
||||
action.setEffectiveTimeScale(ts);
|
||||
action.enabled = true;
|
||||
action.setEffectiveWeight(0);
|
||||
this._upperActions.set(key, action);
|
||||
|
||||
if (opts.onFinished) {
|
||||
this._upperMixer.addEventListener("finished", (ev) => {
|
||||
if (ev.action === action) opts.onFinished();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 播放上半身动画(仅覆写脊椎以上骨骼)
|
||||
playUpperBody(key, opts = {}) {
|
||||
if (!this._upperMixer) return;
|
||||
const next = this._upperActions.get(key);
|
||||
if (!next) { console.warn(`playUpperBody: "${key}" 未注册`); return; }
|
||||
|
||||
const fade = opts.fade ?? 0.18;
|
||||
const prev = this._upperState;
|
||||
|
||||
if (!opts.force && prev === next) return;
|
||||
|
||||
// 直接设为 1,从第一帧起就以满权重覆盖主 mixer。
|
||||
if (prev && prev !== next) prev.fadeOut(fade);
|
||||
|
||||
next.reset();
|
||||
next.setEffectiveWeight(1);
|
||||
next.play();
|
||||
|
||||
this._upperState = next;
|
||||
}
|
||||
|
||||
// 走路/奔跑时修正 spine[0] 四元数,抵消 hips 走路旋转偏移
|
||||
// 使 spine0 的世界朝向等效于静止时(idle hips × spine_local),
|
||||
applyHipsCorrection() {
|
||||
if (!this._idleHipsQ || !this.spineIK?.spineBones?.length) return;
|
||||
const hipsBone = this._player?.getPlayerModel()?.getObjectByName("mixamorigHips");
|
||||
if (!hipsBone) return;
|
||||
|
||||
// correction = hips_walk_local⁻¹ × hips_idle_local
|
||||
const correction = new Quaternion().copy(hipsBone.quaternion).invert().multiply(this._idleHipsQ);
|
||||
const spine0 = this.spineIK.spineBones[0];
|
||||
spine0.quaternion.premultiply(correction);
|
||||
spine0.updateWorldMatrix(false, false);
|
||||
}
|
||||
|
||||
// 停止上半身动画,让下半身(全身)动画完全接管
|
||||
stopUpperBody(fade = 0.18) {
|
||||
if (!this._upperState) return;
|
||||
this._upperState.fadeOut(fade);
|
||||
this._upperState = null;
|
||||
}
|
||||
|
||||
// ==================== 工具方法 ====================
|
||||
|
||||
isCombatLocomotionAllowed() { return combatAllowedLocomotion.has(this._locomotionState); }
|
||||
|
||||
// ==================== playerController 代理 ====================
|
||||
|
||||
getIsFirstPerson() { return this._player?.getIsFirstPerson() ?? false; }
|
||||
getIsFlying() { return this._player?.getIsFlying() ?? false; }
|
||||
getPosition() { return this._player?.getPosition?.() ?? null; }
|
||||
getFirstPersonPitchOffset() { return this._firstPersonPitchOffset; }
|
||||
getPlayerModel() { return this._player?.getPlayerModel(); }
|
||||
getColliderMeshes() { return this._player?.getColliderMeshes?.() ?? []; }
|
||||
getCenterScreenRaycastHit() { return this._player?.getCenterScreenRaycastHit() ?? null; }
|
||||
playAnimation(name, opts) { return this._player?.playAnimation(name, opts); }
|
||||
registerAnimation(key, clipName, opts) { return this._player?.registerAnimation(key, clipName, opts); }
|
||||
registerLocomotionSet(...a) { return this._player?.registerLocomotionSet(...a); }
|
||||
switchLocomotionSet(name) { return this._player?.switchLocomotionSet(name); }
|
||||
setMaxCamDistance(d) { return this._player?.setMaxCamDistance(d); }
|
||||
setPlayerSpeed(s) { return this._player?.setPlayerSpeed(s); }
|
||||
setEnableToward(v) { return this._player?.setEnableToward(v); }
|
||||
setThirdMouseMode(mode) { return this._player?.setThirdMouseMode(mode); }
|
||||
onAllEvent() { return this._player?.onAllEvent(); }
|
||||
offAllEvent() { return this._player?.offAllEvent(); }
|
||||
onViewChange(isFirstPerson) { return this._player?.onViewChange(isFirstPerson); }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Euler, Quaternion, Vector3 } from "three";
|
||||
|
||||
// 复用的临时量(避免每帧 GC)
|
||||
const _aimQ = new Quaternion();
|
||||
const _yawQ = new Quaternion();
|
||||
const _localPitchQ = new Quaternion();
|
||||
const _parentWorldQ = new Quaternion();
|
||||
const _cameraWorldQ = new Quaternion();
|
||||
const _aimAxis = new Vector3(1, 0, 0);
|
||||
const _yawAxis = new Vector3(0, 1, 0);
|
||||
|
||||
const _headWorldQ = new Quaternion();
|
||||
const _headParentWorldQ = new Quaternion();
|
||||
const _headEuler = new Euler();
|
||||
|
||||
// 脊椎骨骼 IK
|
||||
export class SpineIK {
|
||||
constructor(spineBones, headBone) {
|
||||
// ==================== 骨骼引用 ====================
|
||||
this.spineBones = spineBones; // 脊椎骨骼数组(从下到上)
|
||||
this.headBone = headBone; // 头部骨骼
|
||||
|
||||
// 每帧 IK 应用前保存的"干净动画姿态",下一帧先恢复再驱动
|
||||
this.baseQuats = spineBones.map(() => new Quaternion());
|
||||
}
|
||||
|
||||
// ==================== 骨骼保存 / 恢复 ====================
|
||||
|
||||
// 将脊椎骨骼恢复到上一帧保存的干净动画姿态
|
||||
// 在 player.update()(动画驱动)之前调用,防止 IK 残留影响动画混合
|
||||
restoreBones() {
|
||||
for (let i = 0; i < this.spineBones.length; i++) {
|
||||
this.spineBones[i].quaternion.copy(this.baseQuats[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除头骨骼的横滚(roll),防止第一人称下相机随动画倾斜
|
||||
clearHeadRoll() {
|
||||
const head = this.headBone;
|
||||
if (!head) return;
|
||||
head.updateWorldMatrix(true, false);
|
||||
head.getWorldQuaternion(_headWorldQ);
|
||||
_headEuler.setFromQuaternion(_headWorldQ, "YXZ");
|
||||
_headEuler.z = 0;
|
||||
_headWorldQ.setFromEuler(_headEuler);
|
||||
head.parent.getWorldQuaternion(_headParentWorldQ);
|
||||
head.quaternion.copy(_headParentWorldQ).invert().multiply(_headWorldQ);
|
||||
head.updateWorldMatrix(false, false);
|
||||
}
|
||||
|
||||
// ==================== IK 应用 ====================
|
||||
|
||||
// 第一人称脊椎俯仰 IK
|
||||
// 将 pitchTarget 均分到每根脊椎骨骼,使持枪手臂跟随鼠标上下瞄准
|
||||
applyAim1P(camera, pitchTarget) {
|
||||
if (!this.spineBones.length) return;
|
||||
this.clearHeadRoll();
|
||||
|
||||
camera.getWorldQuaternion(_cameraWorldQ);
|
||||
_aimAxis.set(1, 0, 0).applyQuaternion(_cameraWorldQ);
|
||||
_aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length);
|
||||
|
||||
this.spineBones[0].parent.updateWorldMatrix(true, false);
|
||||
for (let i = 0; i < this.spineBones.length; i++) {
|
||||
this.baseQuats[i].copy(this.spineBones[i].quaternion); // 保存干净姿态
|
||||
// 转到父骨骼局部空间后叠加
|
||||
this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ);
|
||||
_localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ);
|
||||
this.spineBones[i].quaternion.premultiply(_localPitchQ);
|
||||
this.spineBones[i].updateWorldMatrix(false, false);
|
||||
}
|
||||
// 传播到头骨骼,让相机挂点跟随
|
||||
this.spineBones[this.spineBones.length - 1].updateWorldMatrix(false, true);
|
||||
}
|
||||
|
||||
// 第三人称脊椎俯仰 + 偏航 IK,同时对 camera.rotation.x 做视觉补偿偏移
|
||||
applyAim3P(camera, isGunEngaged) {
|
||||
if (!this.spineBones.length) return;
|
||||
|
||||
// 归一化俯仰(-1 ~ +1),用于二次曲线补偿
|
||||
const normalizedPitch = camera.rotation.x / (Math.PI * (50 / 180));
|
||||
const pitchSq = Math.pow(Math.abs(normalizedPitch), 2.0);
|
||||
|
||||
const pitchTarget = isGunEngaged ? camera.rotation.x : 0;
|
||||
// 相机视角补偿:向下看时额外抬高,向上看时额外压低,使瞄准更自然
|
||||
camera.rotation.x += normalizedPitch > 0 ? pitchSq * 0.35 : -pitchSq * 0.1;
|
||||
|
||||
// 人物朝向补偿:需要往屏幕中心方向旋转
|
||||
const yawTarget = isGunEngaged ? -Math.PI * ((10 * (1 + pitchSq * 0.35)) / 180) : 0;
|
||||
|
||||
_aimAxis.set(1, 0, 0).applyQuaternion(camera.quaternion);
|
||||
_aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length);
|
||||
_yawQ.setFromAxisAngle(_yawAxis, yawTarget);
|
||||
_aimQ.premultiply(_yawQ);
|
||||
|
||||
this.spineBones[0].parent.updateWorldMatrix(true, false);
|
||||
for (let i = 0; i < this.spineBones.length; i++) {
|
||||
this.baseQuats[i].copy(this.spineBones[i].quaternion);
|
||||
this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ);
|
||||
_localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ);
|
||||
this.spineBones[i].quaternion.premultiply(_localPitchQ);
|
||||
this.spineBones[i].updateWorldMatrix(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>three-player-controller · FPS Game</title>
|
||||
<link rel="stylesheet" href="../styles/common.css" />
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#container {
|
||||
overflow: hidden !important;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 准心 */
|
||||
#crosshair {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* 弹药面板位于右下角源码按钮上方,间距 8px。 */
|
||||
#weapon-hud { display: none; }
|
||||
#br-panel {
|
||||
position: fixed; bottom: 72px; right: 16px; z-index: 9999;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
}
|
||||
#ammo-hud {
|
||||
display: none; flex-direction: column; align-items: center; gap: 4px;
|
||||
}
|
||||
.weapon-img-box {
|
||||
width: 96px; height: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ammo-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 16px 3px 8px; border-radius: 4px;
|
||||
background: linear-gradient(to right, rgba(0,0,0,0.7), transparent);
|
||||
}
|
||||
.ammo-label-tag {
|
||||
font-family: system-ui; font-size: 9px; font-weight: bold;
|
||||
letter-spacing: 0.18em; color: #fff;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
|
||||
}
|
||||
#ammo-current-val {
|
||||
color: #e8a020; font-family: monospace; font-size: 22px;
|
||||
font-weight: bold; line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
|
||||
}
|
||||
.ammo-divider { color: #fff; font-family: monospace; font-size: 15px; margin: 0 1px; }
|
||||
#ammo-total-val { color: #fff; font-family: monospace; font-size: 13px;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,0.9); }
|
||||
|
||||
/* ── Start Screen ── */
|
||||
#start-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99998;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
#start-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 48px;
|
||||
background: #c0392b;
|
||||
color: #fff;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 24px rgba(192,57,43,0.55);
|
||||
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
#start-btn:hover {
|
||||
background: #e74c3c;
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 6px 32px rgba(192,57,43,0.7), 0 0 0 4px rgba(231,76,60,0.25);
|
||||
}
|
||||
#start-btn:active { transform: scale(0.98); }
|
||||
#start-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
<script src="../loader.js" data-demo="FPS Game"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
<div id="crosshair"></div>
|
||||
|
||||
<div id="start-screen">
|
||||
<button id="start-btn">
|
||||
<svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9"/></svg>
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hud" role="note" aria-label="Controls">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Move</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Sprint</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Jump</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>1</kbd><kbd>4</kbd></span>
|
||||
<span class="hint-text">Switch Weapon / Scroll</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>RMB</kbd></span>
|
||||
<span class="hint-text">Aim (Hold)</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>LMB</kbd></span>
|
||||
<span class="hint-text">Shoot</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>R</kbd></span>
|
||||
<span class="hint-text">Reload</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="weapon-hud"></div>
|
||||
|
||||
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/shooting/shooting.js" target="_blank" rel="noopener noreferrer">
|
||||
<img src="../img/source.svg" alt="" />
|
||||
</a>
|
||||
|
||||
<div id="br-panel">
|
||||
<div id="ammo-hud">
|
||||
<div class="weapon-img-box">
|
||||
<svg width="96" height="38" viewBox="0 0 96 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 18 L2 24 L20 24 L22 20 L20 15 L2 15 Z" fill="rgba(210,195,160,0.8)"/>
|
||||
<rect x="18" y="13" width="50" height="12" rx="2" fill="rgba(210,195,160,0.8)"/>
|
||||
<path d="M36 25 L34 37 L44 37 L46 25 Z" fill="rgba(190,175,140,0.85)"/>
|
||||
<rect x="66" y="15" width="28" height="6" rx="1" fill="rgba(210,195,160,0.8)"/>
|
||||
<rect x="88" y="12" width="2" height="3" fill="rgba(210,195,160,0.7)"/>
|
||||
<rect x="30" y="9" width="22" height="5" rx="1" fill="rgba(190,175,140,0.7)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ammo-row">
|
||||
<span class="ammo-label-tag">AMMO</span>
|
||||
<span id="ammo-current-val">30</span>
|
||||
<span class="ammo-divider">/</span>
|
||||
<span id="ammo-total-val">300</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-credit">
|
||||
Scene model:
|
||||
<a href="https://sketchfab.com/3d-models/horror-corridor-3-e03a192de67d442ebdf42f43a8a2d37a" target="_blank" rel="noopener noreferrer">Sketchfab</a>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<script type="module" src="../control-hints.js"></script>
|
||||
<script type="module" src="./shooting.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,294 @@
|
||||
import * as THREE from "three";
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
|
||||
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||
|
||||
import { createSceneSetup } from "./core/SceneSetup.js";
|
||||
import { LocalPlayer } from "./player/LocalPlayer.js";
|
||||
import { DecalSystem } from "./weapon/DecalSystem.js";
|
||||
import { WeaponController, MODE } from "./weapon/WeaponController.js";
|
||||
import { ShootingEffects } from "./weapon/effects.js";
|
||||
import { HUD } from "./ui/HUD.js";
|
||||
import { ZombieManager } from "./zombie/ZombieManager.js";
|
||||
|
||||
const base = import.meta.env.BASE_URL;
|
||||
|
||||
const initPos = new THREE.Vector3(33.21, 4.6, 2.6);
|
||||
|
||||
const NORMAL_MAX_CAM = 220;
|
||||
const MOUSE_SENSITIVITY = 5;
|
||||
const PLAYER_MODEL_URL = base + "./glb/swat.glb";
|
||||
const FIRST_PERSON_PITCH_OFFSET_BY_MODEL = {
|
||||
[base + "./glb/swat.glb"]: Math.PI * (16 / 180),
|
||||
};
|
||||
|
||||
async function init() {
|
||||
const container = document.querySelector("#container");
|
||||
|
||||
let tiles, localPlayer, weapon, zombieManager;
|
||||
|
||||
// ==================== 1. 场景基础 ====================
|
||||
const { scene, renderer, camera, controls, bakeProbes } = createSceneSetup({ container });
|
||||
|
||||
// 添加音频监听器
|
||||
const listener = new THREE.AudioListener();
|
||||
camera.add(listener);
|
||||
|
||||
camera.position.copy(initPos);
|
||||
camera.lookAt(initPos.x, initPos.y, initPos.z + 1);
|
||||
controls.target.set(initPos.x, initPos.y, initPos.z + 1);
|
||||
|
||||
const stats = new Stats();
|
||||
Object.assign(stats.dom.style, {
|
||||
position: "fixed",
|
||||
bottom: "0px",
|
||||
left: "0px",
|
||||
top: "auto",
|
||||
zIndex: "9998",
|
||||
display: "block",
|
||||
});
|
||||
document.body.appendChild(stats.dom);
|
||||
|
||||
const timer = new THREE.Timer();
|
||||
let _prevGunEngaged = false;
|
||||
renderer.setAnimationLoop(() => {
|
||||
timer.update();
|
||||
const elapsed = timer.getElapsed();
|
||||
const dt = timer.getDelta();
|
||||
|
||||
if (localPlayer && weapon && zombieManager) {
|
||||
const spineIK = localPlayer.spineIK;
|
||||
const gunEngaged = weapon.isGunEngaged();
|
||||
|
||||
// 开火/瞄准时切 mouseMode 5(锁定朝向跟相机),其余情况用 1
|
||||
if (gunEngaged !== _prevGunEngaged) {
|
||||
localPlayer.setThirdMouseMode(gunEngaged ? 5 : 1);
|
||||
_prevGunEngaged = gunEngaged;
|
||||
}
|
||||
|
||||
// 恢复脊椎骨骼到上一帧的干净动画姿态
|
||||
if (gunEngaged) spineIK.restoreBones();
|
||||
|
||||
// 驱动动画与物理(传入 dt,使上半身 mixer 与主 mixer 同步)
|
||||
localPlayer ? localPlayer.update(dt) : controls.update();
|
||||
|
||||
// 应用脊椎 IK 与朝向(在 player.update() 后、weapon.update() 前)
|
||||
if (gunEngaged) {
|
||||
if (localPlayer.getIsFirstPerson()) {
|
||||
localPlayer.applyHipsCorrection();
|
||||
spineIK.applyAim1P(camera, localPlayer.pitchTarget1P);
|
||||
} else {
|
||||
// console.log('应用 3P IK');
|
||||
localPlayer.applyHipsCorrection();
|
||||
spineIK.applyAim3P(camera, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新武器状态(特效、射线命中、连射节拍)——骨骼此时已含 IK
|
||||
weapon.update(elapsed, dt);
|
||||
|
||||
// 丧尸 AI
|
||||
zombieManager.update(dt, localPlayer.getPosition());
|
||||
|
||||
} else {
|
||||
controls?.update();
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
stats.update();
|
||||
});
|
||||
|
||||
|
||||
// ==================== 2. 资源加载器 ====================
|
||||
const gltfLoader = new GLTFLoader();
|
||||
|
||||
const draco = new DRACOLoader();
|
||||
draco.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/");
|
||||
gltfLoader.setDRACOLoader(draco);
|
||||
|
||||
const ktx2 = new KTX2Loader();
|
||||
ktx2.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/");
|
||||
ktx2.detectSupport(renderer);
|
||||
gltfLoader.setKTX2Loader(ktx2);
|
||||
|
||||
// ==================== 3. 场景模型 ====================
|
||||
const { scene: mapScene } = await gltfLoader.loadAsync(
|
||||
base + "./glb/horror_corridor.glb"
|
||||
);
|
||||
mapScene.name = "sceneGLB";
|
||||
mapScene.scale.set(0.09, 0.09, 0.09);
|
||||
scene.add(mapScene);
|
||||
scene.updateMatrixWorld(true);
|
||||
bakeProbes();
|
||||
|
||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
const playerGltf = await gltfLoader.loadAsync(PLAYER_MODEL_URL);
|
||||
|
||||
// ==================== 4. 本地玩家 ====================
|
||||
localPlayer = new LocalPlayer({ scene, camera, controls });
|
||||
await localPlayer.init({
|
||||
playerModelConfig: {
|
||||
model: playerGltf.scene,
|
||||
animations: playerGltf.animations,
|
||||
scale: 0.01,
|
||||
idleAnim: "idle1",
|
||||
walkAnim: "walk",
|
||||
runAnim: "run",
|
||||
jumpAnim: "jump",
|
||||
flyAnim: "flying",
|
||||
flyIdleAnim: "flyidle",
|
||||
headBoneName: "mixamorigHead",
|
||||
firstPersonPitchOffset: FIRST_PERSON_PITCH_OFFSET_BY_MODEL[PLAYER_MODEL_URL] ?? 0,
|
||||
rotateY: - Math.PI / 2,
|
||||
},
|
||||
initPos: initPos,
|
||||
minCamDistance: 10,
|
||||
maxCamDistance: NORMAL_MAX_CAM,
|
||||
enableOverShoulderView: true,
|
||||
mouseSensitivity: MOUSE_SENSITIVITY,
|
||||
camLookAtHeightRatio: 0.9,
|
||||
keyMap: { toggleFly: null },
|
||||
colliders: [
|
||||
{ motion: "static", shape: { kind: "mesh", source: mapScene } },
|
||||
],
|
||||
});
|
||||
|
||||
localPlayer.getPlayerModel()?.traverse((c) => {
|
||||
if (c.isMesh) {
|
||||
c.frustumCulled = false;
|
||||
c.castShadow = true;
|
||||
c.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 5. HUD ====================
|
||||
const weaponSlots = [
|
||||
{ key: "1", mode: "primary", label: "Rifle" },
|
||||
{ key: "4", mode: "normal", label: "Fists" },
|
||||
];
|
||||
const hud = new HUD(weaponSlots);
|
||||
hud.build();
|
||||
|
||||
// ==================== 6. 特效 ====================
|
||||
let effects = new ShootingEffects(scene, {
|
||||
listener,
|
||||
flashScale: 0.15,
|
||||
smokeSize: 0.8
|
||||
});
|
||||
await effects.load(
|
||||
base + "./img/muzzle_flash.png",
|
||||
base + "./img/smoke.png",
|
||||
base + "./audio/gun_shot.mp3",
|
||||
base + "./audio/reload.mp3",
|
||||
);
|
||||
|
||||
// ==================== 7. 弹痕系统 ====================
|
||||
const decalSystem = new DecalSystem(scene, 60, 0.25);
|
||||
await decalSystem.loadMaterials(["img/bullet_hole.png"], base);
|
||||
|
||||
// ==================== 8. 丧尸管理器 ====================
|
||||
zombieManager = new ZombieManager(scene, {
|
||||
loader: gltfLoader,
|
||||
colliders: localPlayer.getColliderMeshes(),
|
||||
modelUrl: base + "./glb/zombie.glb",
|
||||
scale: 0.01,
|
||||
walkAnim: "walking",
|
||||
idleAnim: "Idle",
|
||||
punchAnim: "punching",
|
||||
runAnim: "running",
|
||||
rotateY: Math.PI,
|
||||
speed: 120,
|
||||
});
|
||||
|
||||
// ==================== 9. 武器控制器 ====================
|
||||
weapon = new WeaponController({
|
||||
scene,
|
||||
camera,
|
||||
localPlayer,
|
||||
decalSystem,
|
||||
effects,
|
||||
hud,
|
||||
zombieManager,
|
||||
});
|
||||
|
||||
await weapon.load(gltfLoader, base);
|
||||
weapon.setupAnimations();
|
||||
weapon.bindInput();
|
||||
weapon.switchMode(MODE.PRIMARY);
|
||||
|
||||
// 让 LocalPlayer 的 1P 俯仰驱动能感知武器状态
|
||||
localPlayer.setGunEngagedGetter(() => weapon.isGunEngaged());
|
||||
|
||||
// 初始刷新武器槽高亮
|
||||
hud.update(weapon.getMode());
|
||||
|
||||
// ==================== 10. 鼠标拾取 ====================
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
|
||||
const getMousePickingPoint = (event) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
// 将鼠标位置归一化为设备坐标 (NDC)
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
// 检测场景中所有的物体(包含 3D Tiles 和玩家模型)
|
||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
return intersects[0].point;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
container.addEventListener("mousedown", (e) => {
|
||||
// 只有当鼠标没有被锁定(非第一人称控制状态)时才执行点击拾取
|
||||
if (document.pointerLockElement !== renderer.domElement) {
|
||||
const point = getMousePickingPoint(e);
|
||||
if (point) {
|
||||
console.log("鼠标点击的 3D 坐标:", point);
|
||||
// 你可以在这里添加逻辑,比如在点击位置生成丧尸:
|
||||
// zombieManager.spawnZombie(point);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 丧尸波次 ====================
|
||||
await Promise.all([
|
||||
zombieManager.startWave({ origin: new THREE.Vector3(2.012, 4.6, -0.78), count: 5, radius: 1 }),
|
||||
zombieManager.startWave({ origin: new THREE.Vector3(-3.731, 4.6, -1.824), count: 2, radius: 1 }),
|
||||
zombieManager.startWave({ origin: new THREE.Vector3(11.911, 4.6, -4.010), count: 2, radius: 1 }),
|
||||
zombieManager.startWave({ origin: new THREE.Vector3(13.567, 4.6, 7.001), count: 2, radius: 1 }),
|
||||
]);
|
||||
|
||||
window.hideLoader?.();
|
||||
|
||||
// ==================== Start Screen ====================
|
||||
await new Promise((resolve) => {
|
||||
const screen = document.getElementById("start-screen");
|
||||
screen.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
screen.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||
screen.addEventListener("click", (e) => e.stopPropagation());
|
||||
screen.style.display = "flex";
|
||||
document.getElementById("start-btn").addEventListener("click", () => {
|
||||
screen.remove();
|
||||
document.body.style.cursor = "none";
|
||||
document.body.requestPointerLock?.();
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
// ==================== 窗口缩放 ====================
|
||||
window.addEventListener("resize", () => {
|
||||
const cont = document.querySelector("#container");
|
||||
if (!cont) return;
|
||||
camera.aspect = cont.clientWidth / cont.clientHeight;
|
||||
renderer.setSize(cont.clientWidth, cont.clientHeight);
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,65 @@
|
||||
// HUD:准星 + 武器选择栏 + 弹药显示
|
||||
// 不依赖任何 Three.js 对象,只操作 DOM
|
||||
export class HUD {
|
||||
constructor(weaponSlots) {
|
||||
this._slots = weaponSlots; // [{ key, mode, label }]
|
||||
|
||||
this._crosshair = document.getElementById("crosshair");
|
||||
this._weaponHud = document.getElementById("weapon-hud");
|
||||
this._ammoHud = document.getElementById("ammo-hud");
|
||||
this._ammoCurrent = document.getElementById("ammo-current-val");
|
||||
this._ammoTotal = document.getElementById("ammo-total-val");
|
||||
this._hitTimer = null;
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
build() {
|
||||
this._weaponHud.innerHTML = "";
|
||||
for (let i = this._slots.length - 1; i >= 0; i--) {
|
||||
const slot = this._slots[i];
|
||||
const el = document.createElement("div");
|
||||
el.className = "weapon-slot";
|
||||
el.dataset.mode = slot.mode;
|
||||
el.innerHTML = `<span class="slot-key">${slot.key}</span><span class="slot-name">${slot.label}</span>`;
|
||||
this._weaponHud.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 武器槽 ====================
|
||||
|
||||
update(currentMode) {
|
||||
this._weaponHud.querySelectorAll(".weapon-slot").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.mode === currentMode);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 弹药 ====================
|
||||
|
||||
updateAmmo(current, max) {
|
||||
if (this._ammoCurrent) this._ammoCurrent.textContent = current;
|
||||
if (this._ammoTotal) this._ammoTotal.textContent = max;
|
||||
}
|
||||
|
||||
showAmmo() {
|
||||
if (this._ammoHud) this._ammoHud.style.display = "flex";
|
||||
}
|
||||
|
||||
hideAmmo() {
|
||||
if (this._ammoHud) this._ammoHud.style.display = "none";
|
||||
}
|
||||
|
||||
// ==================== 准星 ====================
|
||||
|
||||
showCrosshair() { this._crosshair.style.display = "block"; }
|
||||
hideCrosshair() { this._crosshair.style.display = "none"; }
|
||||
|
||||
flashHit() {
|
||||
if (!this._crosshair) return;
|
||||
if (this._hitTimer) clearTimeout(this._hitTimer);
|
||||
this._crosshair.style.backgroundColor = "red";
|
||||
this._hitTimer = setTimeout(() => {
|
||||
this._crosshair.style.backgroundColor = "";
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Matrix3,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
PlaneGeometry,
|
||||
TextureLoader,
|
||||
} from "three";
|
||||
|
||||
const _normalMatrix = new Matrix3(); // 法线矩阵
|
||||
const _decalHelper = new Object3D(); // 弹孔朝向辅助体
|
||||
|
||||
// 弹孔贴花系统(对象池)
|
||||
export class DecalSystem {
|
||||
constructor(scene, maxDecals = 60, decalSize = 0.02) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
|
||||
// ==================== 对象池配置 ====================
|
||||
this._maxDecals = maxDecals; // 最大弹孔数量,超出后移除最旧的
|
||||
this._pool = []; // 场景中现存的弹孔 Mesh
|
||||
this._mats = []; // 可用的弹痕材质(多张随机选取)
|
||||
this._geo = new PlaneGeometry(decalSize, decalSize); // 弹孔平面几何体
|
||||
this.onSpawn = null; // (hitPoint, hitNormal) => void,多人模式注入广播回调
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 批量加载弹痕贴图,生成对应材质
|
||||
async loadMaterials(files, baseUrl) {
|
||||
const loader = new TextureLoader();
|
||||
await Promise.all(
|
||||
files.map(async (f) => {
|
||||
const tex = await loader.loadAsync(baseUrl + f);
|
||||
this._mats.push(
|
||||
new MeshBasicMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -4, // 防止与墙面 Z-fighting
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 生成 / 清除 ====================
|
||||
|
||||
// 在射线命中点生成弹孔,并触发命中硝烟特效
|
||||
spawn(hit, effects) {
|
||||
if (!hit?.face || !hit?.object) return;
|
||||
if (!this._mats.length) return;
|
||||
|
||||
// 计算世界法线
|
||||
_normalMatrix.getNormalMatrix(hit.object.matrixWorld);
|
||||
const hitNormal = hit.face.normal.clone().applyMatrix3(_normalMatrix).normalize();
|
||||
const hitPoint = hit.point.clone();
|
||||
|
||||
// 计算弹孔朝向(面向法线 + 随机旋转)
|
||||
_decalHelper.position.copy(hitPoint);
|
||||
_decalHelper.lookAt(hitPoint.clone().add(hitNormal));
|
||||
_decalHelper.rotation.z = Math.random() * Math.PI * 2;
|
||||
|
||||
// 创建弹孔 Mesh,沿法线微偏移以避免 Z-fighting
|
||||
const mat = this._mats[Math.floor(Math.random() * this._mats.length)];
|
||||
const decal = new Mesh(this._geo, mat);
|
||||
decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001);
|
||||
decal.rotation.copy(_decalHelper.rotation);
|
||||
decal.renderOrder = 1;
|
||||
this._scene.add(decal);
|
||||
|
||||
// 超出上限时移除最旧弹孔
|
||||
this._pool.push(decal);
|
||||
if (this._pool.length > this._maxDecals) {
|
||||
this._scene.remove(this._pool.shift());
|
||||
}
|
||||
|
||||
// 命中硝烟特效
|
||||
effects?.triggerHitSmoke(hitPoint, hitNormal);
|
||||
|
||||
// 广播给其他客户端(多人模式注入)
|
||||
this.onSpawn?.(hitPoint, hitNormal);
|
||||
}
|
||||
|
||||
// 根据世界坐标和法线直接生成弹孔(接收远程弹痕时使用,不触发硝烟)
|
||||
spawnAtPoint(hitPoint, hitNormal) {
|
||||
if (!this._mats.length) return;
|
||||
|
||||
_decalHelper.position.copy(hitPoint);
|
||||
_decalHelper.lookAt(hitPoint.clone().add(hitNormal));
|
||||
_decalHelper.rotation.z = Math.random() * Math.PI * 2;
|
||||
|
||||
const mat = this._mats[Math.floor(Math.random() * this._mats.length)];
|
||||
const decal = new Mesh(this._geo, mat);
|
||||
decal.position.copy(hitPoint).addScaledVector(hitNormal, 0.001);
|
||||
decal.rotation.copy(_decalHelper.rotation);
|
||||
decal.renderOrder = 1;
|
||||
this._scene.add(decal);
|
||||
|
||||
this._pool.push(decal);
|
||||
if (this._pool.length > this._maxDecals) {
|
||||
this._scene.remove(this._pool.shift());
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有弹孔
|
||||
clear() {
|
||||
for (const d of this._pool) this._scene.remove(d);
|
||||
this._pool.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { AnimationMixer, LoopOnce, Object3D, Quaternion, Vector3 } from "three";
|
||||
|
||||
export const MODE = Object.freeze({ NORMAL: "normal", PRIMARY: "primary" });
|
||||
|
||||
const _muzzleWorldPos = new Vector3(); // 枪口世界坐标
|
||||
|
||||
export class WeaponController {
|
||||
constructor({ scene, camera, localPlayer, decalSystem, effects, hud, zombieManager }) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this._camera = camera;
|
||||
this._player = localPlayer;
|
||||
this._decalSystem = decalSystem;
|
||||
this._effects = effects;
|
||||
this._hud = hud;
|
||||
this._zombieManager = zombieManager;
|
||||
|
||||
// ==================== 武器配置 ====================
|
||||
this._gunScale = 0.1; // 枪模型缩放
|
||||
this._gunPos = [1, 26.5, 2]; // 枪挂载到右手骨骼的位置偏移
|
||||
this._gunBarrelDir = new Vector3(0, 0, -1); // 枪管沿模型局部 -Z
|
||||
this._gunTargetDir = new Vector3(0, 1, 0); // 手骨 +Y = 手指方向
|
||||
this._gunRoll = Math.PI / 2;
|
||||
this._gunMuzzleOffset = [0, 80, -480]; // 枪口偏移 [左, 上, 前]
|
||||
|
||||
// ==================== 武器模型 ====================
|
||||
this._weaponModel = null; // 枪模型根节点
|
||||
this._muzzlePoint = null; // 枪口标记点(供特效定位)
|
||||
this._weaponMixer = null; // 武器动画混合器
|
||||
this._weaponReloadAction = null; // 换弹动画
|
||||
this._weaponShootAction = null; // 射击动画
|
||||
this._shotSound = null; // 射击音效
|
||||
this._shakeIntensity = 0; // 当前镜头抖动强度
|
||||
this._shakeDecay = 10; // 抖动衰减系数
|
||||
|
||||
// ==================== 模式 & 武器槽 ====================
|
||||
this._currentMode = MODE.NORMAL;
|
||||
this._weaponSlots = [
|
||||
{ key: "1", mode: MODE.PRIMARY, label: "Rifle" },
|
||||
{ key: "4", mode: MODE.NORMAL, label: "Fists" },
|
||||
];
|
||||
|
||||
// ==================== 相机距离限制 ====================
|
||||
this._normalMaxCam = 220; // 徒手模式最远距离
|
||||
this._armedMaxCam = 100; // 瞄准模式最远距离
|
||||
|
||||
// ==================== 移速配置 ====================
|
||||
this._baseSpeed = 300;
|
||||
this._armedSpeed = 240; // 持枪移速 = baseSpeed * 0.8
|
||||
|
||||
// ==================== 开火状态机 ====================
|
||||
this._isAiming = false; // 是否瞄准中(RMB 触发,含视角缩放)
|
||||
this._isSoftAiming = false; // 是否软瞄准中(单击开火触发,不缩放视角)
|
||||
this._isFiring = false; // 是否连射中
|
||||
this._isTriggerDown = false; // 鼠标左键是否按住
|
||||
this._isReloading = false; // 是否换弹中
|
||||
this._magSize = 30; // 弹夹容量
|
||||
this._currentAmmo = 30; // 当前子弹数
|
||||
this._totalAmmo = 300; // 总备用弹药量
|
||||
this._firstShotTimer = null; // 180ms 后必出一枪的计时器
|
||||
this._holdAimTimer = null; // 停火后 2s 才放枪的计时器
|
||||
this._lastFireTime = 0;
|
||||
this._elapsed = 0; // 由 update() 每帧更新,供 setTimeout 回调读取
|
||||
|
||||
this._reloadTimer1 = null; // 换弹半程计时器
|
||||
this._reloadTimer2 = null; // 换弹完成计时器
|
||||
this._RELOAD_DURATION_MS = 2000; // 换弹动画时长,需根据实际模型动画调整
|
||||
this._FIRE_RATE_S = 0.1; // 连射间隔(秒)
|
||||
this._FIRE_ANIM_FADE_MS = 180; // 与 playAnimation 默认 fade(0.18s) 对齐
|
||||
this._HOLD_AIM_DURATION = 2000; // 停火后保持瞄准状态时长(ms)
|
||||
|
||||
// ==================== 射线命中缓存 ====================
|
||||
this._frameHit = null; // 每帧射线命中结果
|
||||
|
||||
// ==================== 多人扩展 ====================
|
||||
this.onHitPlayer = null; // (playerId, damage) => void,由外部注入
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
// 加载武器模型并挂载到右手骨骼
|
||||
async load(gltfLoader, baseUrl) {
|
||||
const gltf = await gltfLoader.loadAsync(baseUrl + "./glb/ak47.glb");
|
||||
this._weaponModel = gltf.scene;
|
||||
|
||||
const person = this._player.getPlayerModel();
|
||||
const rightHand = person?.getObjectByName("mixamorigRightHand");
|
||||
if (!rightHand) {
|
||||
console.warn("[WeaponController] 未找到右手骨骼 mixamorigRightHand");
|
||||
return;
|
||||
}
|
||||
|
||||
this._weaponModel.scale.setScalar(this._gunScale);
|
||||
this._weaponModel.position.set(...this._gunPos);
|
||||
|
||||
this._magazineBone = null;
|
||||
this._weaponModel.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
child.frustumCulled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 找到备用弹夹骨骼,初始缩为 0 隐藏
|
||||
this._magazineBone = this._weaponModel.getObjectByName("Bone002_01");
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001);
|
||||
|
||||
// 对齐枪管方向并附加滚转
|
||||
const alignQ = new Quaternion().setFromUnitVectors(this._gunBarrelDir, this._gunTargetDir);
|
||||
const rollQ = new Quaternion().setFromAxisAngle(this._gunTargetDir, this._gunRoll);
|
||||
this._weaponModel.quaternion.copy(rollQ.multiply(alignQ));
|
||||
|
||||
this._weaponModel.visible = false;
|
||||
rightHand.add(this._weaponModel);
|
||||
|
||||
// 枪口标记点(供特效定位)
|
||||
this._muzzlePoint = new Object3D();
|
||||
this._muzzlePoint.position.set(...this._gunMuzzleOffset);
|
||||
this._weaponModel.add(this._muzzlePoint);
|
||||
|
||||
// 初始化武器动画
|
||||
this._weaponMixer = new AnimationMixer(this._weaponModel);
|
||||
const reloadClip = gltf.animations.find(a => a.name === "Armature.003|reload");
|
||||
if (reloadClip) {
|
||||
this._weaponReloadAction = this._weaponMixer.clipAction(reloadClip);
|
||||
this._weaponReloadAction.setLoop(LoopOnce);
|
||||
// 关键修改:取消锁定在最后一帧。
|
||||
// 这样动画结束后,骨骼会自动通过 Mixer 的权重回归到 Bind Pose(初始插在枪上的位置)。
|
||||
this._weaponReloadAction.clampWhenFinished = false;
|
||||
this._weaponReloadAction.timeScale = 1.3;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 注册所有射击相关动画集
|
||||
setupAnimations() {
|
||||
this._player.registerLocomotionSet("primary", {
|
||||
idle: "rifle_idle",
|
||||
walking: "rifle_walk",
|
||||
running: "rifle_run",
|
||||
jumping: "rifle_jump",
|
||||
});
|
||||
this._player.registerLocomotionSet("primary_aim", {
|
||||
idle: "rifle_idle_aim3",
|
||||
walking: "rifle_walk",
|
||||
running: "rifle_run",
|
||||
jumping: "rifle_jump",
|
||||
});
|
||||
|
||||
// 上半身专用动画:仅覆写脊椎以上骨骼,下半身继续播放移动动画
|
||||
this._player.registerUpperAnimation("upper_shoot", "rifle_shoot3", { loop: true, timeScale: 0.5 });
|
||||
this._player.registerUpperAnimation("upper_reload", "reload", { loop: false, timeScale: 1.5 });
|
||||
this._player.registerUpperAnimation("upper_aim", "rifle_idle_aim3", { loop: true, timeScale: 0.5 });
|
||||
|
||||
this._player.initIdleHipsQ("rifle_idle_aim3");
|
||||
}
|
||||
|
||||
// 绑定键鼠输入
|
||||
bindInput() {
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
if (e.button === 2 && !this._player.getIsFirstPerson()) {
|
||||
this.enterAim();
|
||||
}
|
||||
if (e.button === 0) {
|
||||
if (!this._firstShotTimer && !this._isFiring) this._triggerShootAnim();
|
||||
this._startFiring();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", (e) => {
|
||||
if (e.button === 2 && !this._player.getIsFirstPerson()) this.exitAim();
|
||||
if (e.button === 0) this._stopFiring();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "1") this.switchMode(MODE.PRIMARY);
|
||||
if (k === "4") this.switchMode(MODE.NORMAL);
|
||||
if (k === "q") this.switchMode(this._currentMode === MODE.PRIMARY ? MODE.NORMAL : MODE.PRIMARY);
|
||||
if (k === "r") this.reload();
|
||||
});
|
||||
|
||||
document.addEventListener("wheel", (e) => {
|
||||
if (this._isReloading) return;
|
||||
const idx = this._weaponSlots.findIndex((s) => s.mode === this._currentMode);
|
||||
const next = (idx + (e.deltaY > 0 ? 1 : -1) + this._weaponSlots.length) % this._weaponSlots.length;
|
||||
this.switchMode(this._weaponSlots[next].mode);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
|
||||
// 每帧由 shooting.js 主循环调用
|
||||
update(elapsed, dt) {
|
||||
this._elapsed = elapsed;
|
||||
const canRunCombatLogic = this._canRunCombatLogic();
|
||||
|
||||
// 飞行时强制取消换弹
|
||||
if (this._currentMode === MODE.PRIMARY && this._isReloading && this._player.getIsFlying()) {
|
||||
this._cancelReload();
|
||||
}
|
||||
// 飞行/跳跃等不可战斗状态时强制中断武器逻辑(换弹中不打断,由换弹自己管)
|
||||
if (this._currentMode === MODE.PRIMARY && !canRunCombatLogic && !this._isReloading) {
|
||||
this._forceStopCombatLogic();
|
||||
}
|
||||
|
||||
// 第一人称:静止时自动进入瞄准,移动时退出
|
||||
if (this._currentMode === MODE.PRIMARY && this._player.getIsFirstPerson()) {
|
||||
if (canRunCombatLogic && !this._isAiming) this.enterAim();
|
||||
else if (!canRunCombatLogic && this._isAiming) this.exitAim();
|
||||
}
|
||||
|
||||
// 更新粒子特效
|
||||
this._effects?.update(dt);
|
||||
|
||||
// 更新武器动画混合器
|
||||
if (this._weaponMixer) this._weaponMixer.update(dt);
|
||||
|
||||
// 非换弹时每帧强制隐藏备用弹夹骨骼(mixer 更新后覆写)
|
||||
if (this._magazineBone && !this._isReloading) this._magazineBone.scale.setScalar(0.0001);
|
||||
|
||||
// 镜头抖动(指数衰减)
|
||||
if (this._shakeIntensity > 0.0001) {
|
||||
this._camera.rotation.x += (Math.random() - 0.5) * this._shakeIntensity;
|
||||
this._camera.rotation.y += (Math.random() - 0.5) * this._shakeIntensity * 0.4;
|
||||
this._shakeIntensity *= Math.exp(-this._shakeDecay * dt);
|
||||
} else {
|
||||
this._shakeIntensity = 0;
|
||||
}
|
||||
|
||||
// 更新射线命中缓存
|
||||
this._frameHit = this._player.getCenterScreenRaycastHit();
|
||||
|
||||
// 连射节拍
|
||||
if (canRunCombatLogic && this._isFiring && elapsed - this._lastFireTime >= this._FIRE_RATE_S) {
|
||||
if (this._currentAmmo <= 0) {
|
||||
this._stopFiring();
|
||||
// 只有总弹药大于0时才触发自动换弹
|
||||
if (this._totalAmmo > 0) this.reload();
|
||||
} else {
|
||||
this._lastFireTime = elapsed;
|
||||
this._fireOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
// 是否处于任意武器激活状态(等待首发 / 连射 / hold-aim 冷却 / 瞄准)
|
||||
// SpineIK 和主循环通过此方法判断是否需要驱动 IK
|
||||
isGunEngaged() {
|
||||
return (
|
||||
this._isAiming ||
|
||||
this._isSoftAiming ||
|
||||
this._isFiring ||
|
||||
this._isReloading ||
|
||||
this._firstShotTimer !== null ||
|
||||
this._holdAimTimer !== null
|
||||
);
|
||||
}
|
||||
|
||||
getMode() { return this._currentMode; }
|
||||
|
||||
resetAmmo() {
|
||||
this._currentAmmo = this._magSize;
|
||||
this._totalAmmo = 300;
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
}
|
||||
|
||||
_canRunCombatLogic() {
|
||||
if (this._currentMode !== MODE.PRIMARY) return false;
|
||||
if (this._isReloading) return false;
|
||||
if (this._player.getIsFlying()) return false;
|
||||
if (typeof this._player.isCombatLocomotionAllowed === "function") {
|
||||
return this._player.isCombatLocomotionAllowed();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_forceStopCombatLogic() {
|
||||
this._isTriggerDown = false;
|
||||
if (this._firstShotTimer) {
|
||||
clearTimeout(this._firstShotTimer);
|
||||
this._firstShotTimer = null;
|
||||
}
|
||||
this._isFiring = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
this._player.stopUpperBody(0.18);
|
||||
this._cancelHoldAimTimer();
|
||||
if (this._isAiming) this.exitAim();
|
||||
else {
|
||||
this._isSoftAiming = false;
|
||||
this._hud.hideCrosshair();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模式切换 & 瞄准 ====================
|
||||
|
||||
switchMode(newMode) {
|
||||
if (this._currentMode === newMode || this._isReloading) return;
|
||||
|
||||
// 退出旧模式
|
||||
if (this._currentMode === MODE.PRIMARY) {
|
||||
this.exitAim();
|
||||
this._isSoftAiming = false;
|
||||
this._isTriggerDown = false;
|
||||
this._isFiring = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
this._player.stopUpperBody(0.18);
|
||||
if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; }
|
||||
this._cancelHoldAimTimer();
|
||||
this._hud.hideCrosshair();
|
||||
this._hud.hideAmmo?.();
|
||||
this._player.switchLocomotionSet("default");
|
||||
this._player.setMaxCamDistance(this._normalMaxCam);
|
||||
this._player.setPlayerSpeed(this._baseSpeed);
|
||||
}
|
||||
|
||||
this._currentMode = newMode;
|
||||
|
||||
// 进入新模式
|
||||
if (this._currentMode === MODE.PRIMARY) {
|
||||
this._player.switchLocomotionSet("primary");
|
||||
this._player.setPlayerSpeed(this._armedSpeed);
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
this._hud.showAmmo?.();
|
||||
}
|
||||
|
||||
if (this._weaponModel) this._weaponModel.visible = (this._currentMode === MODE.PRIMARY);
|
||||
}
|
||||
|
||||
// 进入瞄准模式;soft=true 时为软瞄准,跳过视角缩放
|
||||
enterAim(soft = false) {
|
||||
if (soft) {
|
||||
if (this._isSoftAiming) return;
|
||||
this._isSoftAiming = true;
|
||||
} else {
|
||||
if (!this._canRunCombatLogic() || this._isAiming) return;
|
||||
this._isAiming = true;
|
||||
this._isSoftAiming = false;
|
||||
this._player.setMaxCamDistance(this._armedMaxCam);
|
||||
}
|
||||
this._player.switchLocomotionSet("primary_aim");
|
||||
this._hud.showCrosshair();
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
if (this._player.getIsFirstPerson()) {
|
||||
this._camera.rotation.x = this._player.getFirstPersonPitchOffset();
|
||||
}
|
||||
}
|
||||
|
||||
// 退出瞄准模式;soft=true 时为软瞄准,跳过视角还原
|
||||
exitAim(soft = false) {
|
||||
if (soft) {
|
||||
if (!this._isSoftAiming) return;
|
||||
this._isSoftAiming = false;
|
||||
} else {
|
||||
if (!this._isAiming) return;
|
||||
this._isAiming = false;
|
||||
this._player.setMaxCamDistance(this._normalMaxCam);
|
||||
this._cancelHoldAimTimer();
|
||||
}
|
||||
this._player.switchLocomotionSet(
|
||||
this._currentMode === MODE.PRIMARY ? "primary" : "default"
|
||||
);
|
||||
this._player.stopUpperBody(0.18);
|
||||
this._hud.hideCrosshair();
|
||||
if (this._player.getIsFirstPerson()) {
|
||||
this._camera.rotation.x = this._player.pitchTarget1P;
|
||||
}
|
||||
}
|
||||
|
||||
// 换弹
|
||||
reload() {
|
||||
if (this._currentMode !== MODE.PRIMARY || this._isReloading || this._totalAmmo <= 0 || this._currentAmmo === this._magSize) return;
|
||||
if (this._player.getIsFlying()) return;
|
||||
|
||||
this._isReloading = true;
|
||||
|
||||
// 1. 中断当前所有攻击状态
|
||||
this._isTriggerDown = false;
|
||||
this._isFiring = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
if (this._firstShotTimer) { clearTimeout(this._firstShotTimer); this._firstShotTimer = null; }
|
||||
this._cancelHoldAimTimer();
|
||||
this.exitAim(); // 换弹时强制退出瞄准
|
||||
|
||||
// 2. 播放换弹动画(仅上半身,下半身继续播放移动动画)
|
||||
this._player.playUpperBody("upper_reload", { force: true, fade: 0.18 });
|
||||
|
||||
// 换弹音效
|
||||
this._effects?.triggerReloadSound();
|
||||
|
||||
// 显示备用弹夹骨骼
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(1);
|
||||
|
||||
// 播放武器自身的换弹动画
|
||||
if (this._weaponReloadAction) {
|
||||
this._weaponReloadAction.reset();
|
||||
this._weaponReloadAction.play();
|
||||
}
|
||||
|
||||
// 动画播放到一半时停止,同时隐藏备用弹夹骨骼(避免 snap-back 到悬空位置时可见)
|
||||
this._reloadTimer1 = setTimeout(() => {
|
||||
this._reloadTimer1 = null;
|
||||
if (this._isReloading && this._weaponReloadAction) {
|
||||
this._weaponReloadAction.stop();
|
||||
}
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001);
|
||||
}, this._RELOAD_DURATION_MS / 2);
|
||||
|
||||
// 3. 锁定状态直到动画结束
|
||||
this._reloadTimer2 = setTimeout(() => {
|
||||
this._reloadTimer2 = null;
|
||||
// 此时 _isReloading 变回 false,允许开火
|
||||
this._isReloading = false;
|
||||
|
||||
// 停止上半身换弹动画,让全身 locomotion 动画重新完整接管上半身
|
||||
this._player.stopUpperBody(0.18);
|
||||
|
||||
// 确保动画彻底停止(以防上面的一半时间停止没触发成功)
|
||||
if (this._weaponReloadAction) this._weaponReloadAction.stop();
|
||||
|
||||
// 计算需要补充的子弹数量
|
||||
const need = this._magSize - this._currentAmmo;
|
||||
const transfer = Math.min(need, this._totalAmmo);
|
||||
this._currentAmmo += transfer;
|
||||
this._totalAmmo -= transfer;
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
|
||||
// 换弹结束后,衔接回持枪状态
|
||||
if (this._currentMode === MODE.PRIMARY) {
|
||||
this._player.switchLocomotionSet("primary_aim");
|
||||
if (this._isAiming || this._isSoftAiming || !this._player.isMoving) {
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
this._scheduleHoldAim();
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
}
|
||||
}, this._RELOAD_DURATION_MS);
|
||||
}
|
||||
|
||||
// ==================== 开火状态机 ====================
|
||||
|
||||
_startFiring() {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
if (this._firstShotTimer || this._isFiring) return;
|
||||
|
||||
// 没子弹时按左键,直接触发换弹
|
||||
if (this._currentAmmo <= 0) {
|
||||
if (this._totalAmmo > 0) this.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换到软瞄准状态
|
||||
if (!this._isAiming && !this._isSoftAiming) {
|
||||
this.enterAim(true);
|
||||
}
|
||||
|
||||
this._cancelHoldAimTimer();
|
||||
this._isTriggerDown = true;
|
||||
|
||||
// 无论鼠标是否松开,180ms 后必定打出第一枪
|
||||
this._firstShotTimer = setTimeout(() => {
|
||||
this._firstShotTimer = null;
|
||||
if (!this._canRunCombatLogic()) {
|
||||
this._forceStopCombatLogic();
|
||||
return;
|
||||
}
|
||||
this._fireOnce();
|
||||
this._lastFireTime = this._elapsed;
|
||||
|
||||
if (this._isTriggerDown) {
|
||||
this._isFiring = true; // 持续按住 → 连射
|
||||
} else {
|
||||
// 单击松开:打出第一枪后处理后续状态
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
if (this._isAiming || this._isSoftAiming || !this._player.isMoving) {
|
||||
this._scheduleHoldAim();
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
}
|
||||
}, this._FIRE_ANIM_FADE_MS);
|
||||
}
|
||||
|
||||
_stopFiring() {
|
||||
this._isTriggerDown = false;
|
||||
if (this._weaponShootAction) this._weaponShootAction.stop();
|
||||
|
||||
if (!this._isFiring) {
|
||||
// 换弹中或 hold-aim 缓冲期内松开鼠标,不打断上半身动画
|
||||
if (this._isReloading || this._holdAimTimer) return;
|
||||
|
||||
// 单发(鼠标在 180ms 定时器触发前就松开):恢复或停止上半身
|
||||
if (this._isAiming || this._isSoftAiming) {
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._isFiring = false;
|
||||
if (this._isAiming || this._isSoftAiming || !this._player.isMoving) {
|
||||
this._player.playUpperBody("upper_aim", { fade: 0.18 });
|
||||
this._scheduleHoldAim();
|
||||
} else {
|
||||
this._player.stopUpperBody(0.18);
|
||||
}
|
||||
}
|
||||
|
||||
_fireOnce() {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
|
||||
// 播放音效
|
||||
if (this._shotSound && this._shotSound.buffer) {
|
||||
if (this._shotSound.isPlaying) this._shotSound.stop();
|
||||
this._shotSound.play();
|
||||
}
|
||||
|
||||
// 第三人称触发镜头抖动
|
||||
if (!this._player.getIsFirstPerson()) {
|
||||
this._shakeIntensity = 0.01; // 弧度单位,0.02 约为 1.1 度
|
||||
}
|
||||
|
||||
this._currentAmmo--;
|
||||
this._hud.updateAmmo?.(this._currentAmmo, this._totalAmmo);
|
||||
|
||||
// 枪口火焰 + 音效:无论命中什么都必须先触发
|
||||
if (this._effects && this._muzzlePoint) {
|
||||
this._muzzlePoint.updateWorldMatrix(true, false);
|
||||
this._muzzlePoint.getWorldPosition(_muzzleWorldPos);
|
||||
this._effects.triggerMuzzleFlash(_muzzleWorldPos, this._camera);
|
||||
}
|
||||
|
||||
// 多人模式:检测是否命中远程玩家(dmgMult 由命中部位决定:头×2,躯干×1,四肢×0.75)
|
||||
const hitPlayerId = this._frameHit?.object?.userData?.playerId;
|
||||
if (hitPlayerId && this.onHitPlayer) {
|
||||
const dmgMult = this._frameHit.object.userData.dmgMult ?? 1.0;
|
||||
this.onHitPlayer(hitPlayerId, Math.round(30 * dmgMult));
|
||||
this._hud.flashHit();
|
||||
return;
|
||||
}
|
||||
|
||||
const zombieId = this._frameHit?.object?.userData?.zombieId;
|
||||
if (zombieId && this._zombieManager) {
|
||||
this._zombieManager.onHit(zombieId, 30);
|
||||
this._hud.flashHit();
|
||||
} else {
|
||||
this._decalSystem.spawn(this._frameHit, this._effects);
|
||||
}
|
||||
}
|
||||
|
||||
_triggerShootAnim() {
|
||||
if (!this._canRunCombatLogic()) return;
|
||||
// 上半身播放开火动画,下半身继续播放当前移动动画
|
||||
this._player.playUpperBody("upper_shoot", { force: true, fade: 0.18 });
|
||||
if (this._weaponShootAction) this._weaponShootAction.play();
|
||||
}
|
||||
|
||||
// 停火后延迟 2s 自动退出瞄准姿态
|
||||
_scheduleHoldAim() {
|
||||
this._cancelHoldAimTimer();
|
||||
this._holdAimTimer = setTimeout(() => {
|
||||
this._holdAimTimer = null;
|
||||
if (!this._isAiming) {
|
||||
this.exitAim(true);
|
||||
}
|
||||
}, this._HOLD_AIM_DURATION);
|
||||
}
|
||||
|
||||
_cancelReload() {
|
||||
if (!this._isReloading) return;
|
||||
this._isReloading = false;
|
||||
if (this._reloadTimer1) { clearTimeout(this._reloadTimer1); this._reloadTimer1 = null; }
|
||||
if (this._reloadTimer2) { clearTimeout(this._reloadTimer2); this._reloadTimer2 = null; }
|
||||
if (this._weaponReloadAction) this._weaponReloadAction.stop();
|
||||
if (this._magazineBone) this._magazineBone.scale.setScalar(0.0001);
|
||||
this._effects?.stopReloadSound();
|
||||
this._player.stopUpperBody(0.18);
|
||||
this._hud.hideCrosshair();
|
||||
}
|
||||
|
||||
_cancelHoldAimTimer() {
|
||||
if (this._holdAimTimer) { clearTimeout(this._holdAimTimer); this._holdAimTimer = null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Audio,
|
||||
AudioLoader,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
NormalBlending,
|
||||
PlaneGeometry,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
Quaternion,
|
||||
TextureLoader,
|
||||
Vector3,
|
||||
} from "three";
|
||||
|
||||
// 复用的临时量(避免每帧 GC)
|
||||
const _rollQ = new Quaternion();
|
||||
const _rollAxis = new Vector3(0, 0, 1);
|
||||
const _camWorldQ = new Quaternion();
|
||||
|
||||
const flashDuration = 0.06; // 枪口火焰持续时间(秒)
|
||||
const smokeCount = 14; // 每次命中的粒子数
|
||||
const smokeLifetime = 0.7; // 硝烟存活时间(秒)
|
||||
const smokePoolSize = 8; // 硝烟对象池大小
|
||||
|
||||
// ==================== 枪口火焰 ====================
|
||||
|
||||
class MuzzleFlash {
|
||||
constructor(scene, texture, scale = 0.01) {
|
||||
this._mesh = new Mesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
new MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
blending: AdditiveBlending,
|
||||
depthWrite: false,
|
||||
opacity: 0,
|
||||
})
|
||||
);
|
||||
this._mesh.visible = false;
|
||||
this._timer = 0;
|
||||
this._scale = scale;
|
||||
this._cam = null;
|
||||
scene.add(this._mesh);
|
||||
}
|
||||
|
||||
// 在枪口位置触发火焰
|
||||
trigger(worldPos, camera) {
|
||||
this._mesh.position.copy(worldPos);
|
||||
this._mesh.scale.setScalar(this._scale);
|
||||
camera.getWorldQuaternion(_camWorldQ);
|
||||
this._mesh.quaternion.copy(_camWorldQ);
|
||||
_rollQ.setFromAxisAngle(_rollAxis, Math.random() * Math.PI * 2); // 随机滚转,避免重复感
|
||||
this._mesh.quaternion.multiply(_rollQ);
|
||||
this._mesh.material.opacity = 1;
|
||||
this._mesh.visible = true;
|
||||
this._timer = flashDuration;
|
||||
this._cam = camera;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this._mesh.visible) return;
|
||||
this._timer -= dt;
|
||||
if (this._timer <= 0) {
|
||||
this._mesh.visible = false;
|
||||
return;
|
||||
}
|
||||
// billboard:每帧对齐相机朝向
|
||||
if (this._cam) { this._cam.getWorldQuaternion(_camWorldQ); this._mesh.quaternion.copy(_camWorldQ); }
|
||||
this._mesh.material.opacity = this._timer / flashDuration; // 线性淡出
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 命中硝烟 ====================
|
||||
|
||||
class SmokeEffect {
|
||||
constructor(scene, texture, size = 0.06) {
|
||||
this._active = false;
|
||||
this._timer = 0;
|
||||
this._pos = new Float32Array(smokeCount * 3); // 粒子位置缓冲
|
||||
this._vels = Array.from({ length: smokeCount }, () => new Vector3()); // 粒子速度
|
||||
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(this._pos, 3));
|
||||
|
||||
this._points = new Points(geo, new PointsMaterial({
|
||||
map: texture,
|
||||
size: size,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
blending: NormalBlending,
|
||||
}));
|
||||
this._points.visible = false;
|
||||
scene.add(this._points);
|
||||
}
|
||||
|
||||
// 在命中点触发硝烟,沿法线方向散射
|
||||
trigger(worldPos, worldNormal) {
|
||||
this._active = true;
|
||||
this._timer = smokeLifetime;
|
||||
this._points.visible = true;
|
||||
|
||||
for (let i = 0; i < smokeCount; i++) {
|
||||
const i3 = i * 3;
|
||||
this._pos[i3] = worldPos.x + (Math.random() - 0.5) * 0.02;
|
||||
this._pos[i3 + 1] = worldPos.y + (Math.random() - 0.5) * 0.02;
|
||||
this._pos[i3 + 2] = worldPos.z + (Math.random() - 0.5) * 0.02;
|
||||
this._vels[i]
|
||||
.set(
|
||||
(Math.random() - 0.5) * 0.5,
|
||||
0.15 + Math.random() * 0.25,
|
||||
(Math.random() - 0.5) * 0.5
|
||||
)
|
||||
.addScaledVector(worldNormal, 0.3 + Math.random() * 0.4);
|
||||
}
|
||||
this._points.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (!this._active) return;
|
||||
this._timer -= dt;
|
||||
if (this._timer <= 0) {
|
||||
this._active = false;
|
||||
this._points.visible = false;
|
||||
return;
|
||||
}
|
||||
this._points.material.opacity = (this._timer / smokeLifetime) * 0.7;
|
||||
for (let i = 0; i < smokeCount; i++) {
|
||||
const i3 = i * 3;
|
||||
this._pos[i3] += this._vels[i].x * dt;
|
||||
this._pos[i3 + 1] += this._vels[i].y * dt;
|
||||
this._pos[i3 + 2] += this._vels[i].z * dt;
|
||||
this._vels[i].multiplyScalar(0.92); // 阻力衰减
|
||||
}
|
||||
this._points.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 对外接口 ====================
|
||||
|
||||
export class ShootingEffects {
|
||||
constructor(scene, { listener = null, flashScale = 0.01, smokeSize = 0.06 } = {}) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this._listener = listener; // THREE.AudioListener
|
||||
|
||||
// ==================== 特效配置 ====================
|
||||
this._flashScale = flashScale; // 枪口火焰缩放
|
||||
this._smokeSize = smokeSize; // 硝烟粒子大小
|
||||
|
||||
// ==================== 特效对象 ====================
|
||||
this._flash = null; // MuzzleFlash 实例
|
||||
this._smokePool = []; // SmokeEffect 对象池
|
||||
this._smokeIdx = 0; // 轮转索引
|
||||
this._fireSound = null; // 开火音效
|
||||
this._reloadSound = null; // 换弹音效
|
||||
}
|
||||
|
||||
// 加载贴图与音效,创建特效实例
|
||||
async load(flashTexPath, smokeTexPath, fireSoundPath = null, reloadSoundPath = null) {
|
||||
const loader = new TextureLoader();
|
||||
const audioLoader = new AudioLoader();
|
||||
|
||||
const [flashTex, smokeTex, fireBuffer, reloadBuffer] = await Promise.all([
|
||||
loader.loadAsync(flashTexPath),
|
||||
loader.loadAsync(smokeTexPath),
|
||||
fireSoundPath ? audioLoader.loadAsync(fireSoundPath).catch(() => null) : Promise.resolve(null),
|
||||
reloadSoundPath ? audioLoader.loadAsync(reloadSoundPath).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
this._flash = new MuzzleFlash(this._scene, flashTex, this._flashScale);
|
||||
|
||||
if (fireBuffer && this._listener) {
|
||||
this._fireSound = new Audio(this._listener);
|
||||
this._fireSound.setBuffer(fireBuffer);
|
||||
this._fireSound.setVolume(0.4);
|
||||
}
|
||||
|
||||
if (reloadBuffer && this._listener) {
|
||||
this._reloadSound = new Audio(this._listener);
|
||||
this._reloadSound.setBuffer(reloadBuffer);
|
||||
this._reloadSound.setVolume(0.6);
|
||||
}
|
||||
|
||||
// 预创建硝烟对象池
|
||||
for (let i = 0; i < smokePoolSize; i++) {
|
||||
this._smokePool.push(new SmokeEffect(this._scene, smokeTex, this._smokeSize));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 触发接口 ====================
|
||||
|
||||
// 枪口火焰 + 开火音效
|
||||
triggerMuzzleFlash(worldPos, camera) {
|
||||
this._flash?.trigger(worldPos, camera);
|
||||
if (this._fireSound) {
|
||||
if (this._fireSound.isPlaying) this._fireSound.stop();
|
||||
this._fireSound.play();
|
||||
}
|
||||
}
|
||||
|
||||
// 换弹音效
|
||||
triggerReloadSound() {
|
||||
if (!this._reloadSound) return;
|
||||
if (this._reloadSound.isPlaying) this._reloadSound.stop();
|
||||
this._reloadSound.play();
|
||||
}
|
||||
|
||||
stopReloadSound() {
|
||||
if (this._reloadSound?.isPlaying) this._reloadSound.stop();
|
||||
}
|
||||
|
||||
// 命中硝烟(轮转对象池)
|
||||
triggerHitSmoke(worldPos, worldNormal) {
|
||||
const e = this._smokePool[this._smokeIdx % this._smokePool.length];
|
||||
this._smokeIdx++;
|
||||
e.trigger(worldPos, worldNormal);
|
||||
}
|
||||
|
||||
// 每帧更新所有特效
|
||||
update(dt) {
|
||||
this._flash?.update(dt);
|
||||
for (const e of this._smokePool) e.update(dt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import * as THREE from "three";
|
||||
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
|
||||
|
||||
export class ZombieEntity {
|
||||
constructor(scene, id) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
this.id = id; // 唯一标识
|
||||
|
||||
// ==================== 场景对象 ====================
|
||||
this._colliders = []; // 场景碰撞网格(CollisionWorld 查询结果)
|
||||
this._model = null; // 模型根节点
|
||||
this._capsule = null; // 碰撞胶囊体
|
||||
this._capsuleInfo = null; // 胶囊描述(radius + segment)
|
||||
this._mixer = null; // 动画混合器
|
||||
|
||||
// ==================== 动画动作 ====================
|
||||
this._walkAction = null; // 行走
|
||||
this._runAction = null; // 奔跑
|
||||
this._idleAction = null; // 待机
|
||||
this._punchAction = null; // 攻击
|
||||
this._deathAction = null; // 死亡
|
||||
this._currentAction = null; // 当前播放动作
|
||||
this._punchLoopCb = null; // 攻击循环回调(用于 removeEventListener)
|
||||
|
||||
// ==================== 生命状态 ====================
|
||||
this.hp = 100; // 血量
|
||||
this.isDead = false; // 是否已死亡
|
||||
this._deathTime = 0; // 死亡后累计时间(用于定时清除)
|
||||
this._isRunning = Math.random() > 0.5; // 随机决定是否为跑者
|
||||
this._state = "idle"; // 当前行为状态
|
||||
this._isPunching = false; // 是否正在攻击
|
||||
this._inAttackRange = false; // 是否在攻击范围内
|
||||
|
||||
// ==================== 物理参数 ====================
|
||||
this._gravity = -2.4; // 重力加速度(load 时按 scale 重算)
|
||||
this._speed = 0.12; // 移速(load 时按 scale 重算)
|
||||
this._modelScale = 0.001; // 模型缩放
|
||||
this._capsuleHeight = 180; // 胶囊高度(模型空间,load 时重算)
|
||||
this._capsuleRadius = 45; // 胶囊半径(模型空间,load 时重算)
|
||||
this._velocity = new THREE.Vector3(); // 当前速度(主要用于重力累积)
|
||||
this._onGround = false; // 是否落地
|
||||
|
||||
// ==================== 复用向量 ====================
|
||||
this._tempBox = new THREE.Box3();
|
||||
this._tempMat = new THREE.Matrix4();
|
||||
this._tempSeg = new THREE.Line3();
|
||||
this._tempV1 = new THREE.Vector3();
|
||||
this._tempV2 = new THREE.Vector3();
|
||||
this._moveDir = new THREE.Vector3(); // 水平移动方向(单位向量)
|
||||
this._lookTarget = new THREE.Vector3(); // 朝向目标(lookAt 复用)
|
||||
this._raycaster = new THREE.Raycaster(
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(0, -1, 0),
|
||||
);
|
||||
this._raycaster.firstHitOnly = true; // 只取最近地面交点
|
||||
}
|
||||
|
||||
// 加载模型、绑定动画、构建胶囊体,挂入场景
|
||||
async load(gltfLoader, {
|
||||
modelUrl,
|
||||
colliders,
|
||||
position,
|
||||
scale = 0.001,
|
||||
walkAnim,
|
||||
runAnim,
|
||||
idleAnim,
|
||||
punchAnim,
|
||||
deathAnim,
|
||||
rotateY = Math.PI,
|
||||
speed = 120,
|
||||
}) {
|
||||
this._colliders = colliders?.length ? colliders.slice() : [];
|
||||
this._modelScale = scale;
|
||||
this._gravity = -2400 * scale;
|
||||
// 基础移速增加随机扰动 (±15%),使丧尸步频与步幅产生差异
|
||||
this._speed = speed * scale * (0.85 + Math.random() * 0.3);
|
||||
|
||||
const gltf = await gltfLoader.loadAsync(modelUrl);
|
||||
this._model = gltf.scene;
|
||||
|
||||
this._mixer = new THREE.AnimationMixer(this._model);
|
||||
const clips = gltf.animations ?? [];
|
||||
|
||||
// 按名称或模糊匹配查找动画片段
|
||||
const findClip = (hint) => {
|
||||
if (!hint) return null;
|
||||
return (
|
||||
clips.find((clip) => clip.name === hint) ??
|
||||
clips.find((clip) => clip.name.toLowerCase().includes(hint.toLowerCase())) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const walkClip = findClip(walkAnim) ?? findClip("walk") ?? clips[0] ?? null;
|
||||
const runClip = findClip(runAnim) ?? findClip("run") ?? clips[0] ?? null;
|
||||
const idleClip = findClip(idleAnim) ?? findClip("idle") ?? clips[0] ?? null;
|
||||
const punchClip = findClip(punchAnim) ?? findClip("punch") ?? null;
|
||||
const deathClip = findClip(deathAnim) ?? findClip("dying") ?? findClip("death") ?? findClip("die") ?? null;
|
||||
|
||||
// 创建循环动作并初始权重为 0(由 _playAnim 按需激活)
|
||||
const makeAction = (clip) => {
|
||||
if (!clip) return null;
|
||||
const action = this._mixer.clipAction(clip);
|
||||
action.setLoop(THREE.LoopRepeat, Infinity);
|
||||
action.setEffectiveWeight(0);
|
||||
action.enabled = true;
|
||||
return action;
|
||||
};
|
||||
|
||||
this._walkAction = makeAction(walkClip);
|
||||
if (this._walkAction) {
|
||||
// 随机播放速度 (±10%) 和 随机起始进度 (0-100%)
|
||||
this._walkAction.setEffectiveTimeScale(1.5 * (0.9 + Math.random() * 0.2));
|
||||
this._walkAction.time = Math.random() * walkClip.duration;
|
||||
}
|
||||
|
||||
this._runAction = makeAction(runClip);
|
||||
if (this._runAction) {
|
||||
this._runAction.setEffectiveTimeScale(1.2 * (0.9 + Math.random() * 0.2));
|
||||
this._runAction.time = Math.random() * runClip.duration;
|
||||
}
|
||||
|
||||
this._idleAction = idleClip !== walkClip ? makeAction(idleClip) : this._walkAction;
|
||||
if (this._idleAction) {
|
||||
// 即使是待机动作也打乱起始时间
|
||||
this._idleAction.time = Math.random() * (idleClip?.duration ?? 1);
|
||||
}
|
||||
|
||||
if (punchClip) {
|
||||
this._punchAction = this._mixer.clipAction(punchClip);
|
||||
this._punchAction.setLoop(THREE.LoopRepeat, Infinity);
|
||||
this._punchAction.setEffectiveTimeScale(1.5);
|
||||
this._punchAction.setEffectiveWeight(0);
|
||||
this._punchAction.enabled = true;
|
||||
|
||||
// 每次攻击循环结束时检查是否已离开攻击范围,若已离开则切回移动动画
|
||||
this._punchLoopCb = (event) => {
|
||||
if (event.action !== this._punchAction || !this._isPunching) return;
|
||||
if (!this._inAttackRange) {
|
||||
this._isPunching = false;
|
||||
this._playAnim(this._walkAction ?? this._idleAction);
|
||||
}
|
||||
};
|
||||
this._mixer.addEventListener("loop", this._punchLoopCb);
|
||||
}
|
||||
|
||||
this._deathAction = makeAction(deathClip);
|
||||
if (this._deathAction) {
|
||||
this._deathAction.setLoop(THREE.LoopOnce, 1);
|
||||
this._deathAction.clampWhenFinished = true; // 停在最后一帧
|
||||
}
|
||||
|
||||
// 驱动一帧使骨骼到位,再计算包围盒以得到准确尺寸
|
||||
this._mixer.update(0);
|
||||
this._model.updateMatrixWorld(true);
|
||||
|
||||
const bbox = new THREE.Box3().setFromObject(this._model);
|
||||
const size = new THREE.Vector3();
|
||||
bbox.getSize(size);
|
||||
|
||||
// 将模型归一化到参考高度 180,再乘以 scale 得到世界尺寸
|
||||
const refHeight = 180;
|
||||
const modelScaleFactor = refHeight / size.y;
|
||||
this._capsuleHeight = size.y * modelScaleFactor;
|
||||
this._capsuleRadius = Math.min(size.x, size.z) * modelScaleFactor;
|
||||
|
||||
const radius = this._capsuleRadius * scale;
|
||||
const height = this._capsuleHeight * scale;
|
||||
|
||||
// 胶囊体:透明网格,仅用于碰撞,不渲染
|
||||
this._capsule = new THREE.Mesh(
|
||||
new RoundedBoxGeometry(radius * 2, height, radius * 2, 1, 0.75),
|
||||
new THREE.MeshStandardMaterial({
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
this._capsule.geometry.translate(0, -height * 0.25, 0);
|
||||
this._capsuleInfo = {
|
||||
radius,
|
||||
segment: new THREE.Line3(
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(0, -height * 0.5, 0),
|
||||
),
|
||||
};
|
||||
this._capsule.name = "zombie_capsule";
|
||||
this._capsule.userData.zombieId = this.id;
|
||||
this._capsule.layers.enable(2); // layer 2:可被武器射线检测到
|
||||
this._scene.add(this._capsule);
|
||||
this._capsule.position.copy(position);
|
||||
|
||||
this._model.scale.setScalar(modelScaleFactor * scale);
|
||||
this._model.position.set(0, -height * 0.75, 0);
|
||||
this._model.rotation.y = rotateY;
|
||||
this._model.traverse((child) => {
|
||||
child.userData.zombieId = this.id;
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
});
|
||||
this._capsule.add(this._model);
|
||||
|
||||
this._playAnim(this._idleAction ?? this._walkAction);
|
||||
}
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
|
||||
update(delta, playerPos) {
|
||||
if (!this._capsule || !this._colliders.length) return;
|
||||
|
||||
delta = Math.min(delta, 1 / 40); // 防止帧间隔过大导致穿透
|
||||
|
||||
if (this.isDead) {
|
||||
this._deathTime += delta;
|
||||
this._mixer?.update(delta);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重力累积
|
||||
if (!this._onGround) {
|
||||
this._velocity.y += delta * this._gravity;
|
||||
}
|
||||
this._capsule.position.addScaledVector(this._velocity, delta);
|
||||
|
||||
// 计算与玩家的水平距离,判断是否进入攻击范围
|
||||
const pos = this._capsule.position;
|
||||
const dx = playerPos.x - pos.x;
|
||||
const dz = playerPos.z - pos.z;
|
||||
const horizDist = Math.sqrt(dx * dx + dz * dz);
|
||||
const stopRadius = this._capsuleInfo.radius * 2;
|
||||
this._inAttackRange = horizDist <= stopRadius;
|
||||
|
||||
// 朝向玩家(lookAt 反向,使模型正面对着玩家)
|
||||
this._lookTarget.set(
|
||||
2 * pos.x - playerPos.x,
|
||||
pos.y,
|
||||
2 * pos.z - playerPos.z,
|
||||
);
|
||||
this._capsule.lookAt(this._lookTarget);
|
||||
|
||||
if (this._inAttackRange) {
|
||||
this._moveDir.set(0, 0, 0);
|
||||
if (!this._isPunching) {
|
||||
this._isPunching = true;
|
||||
this._state = "attack";
|
||||
this._playAnim(this._punchAction ?? this._idleAction ?? this._walkAction);
|
||||
}
|
||||
} else {
|
||||
// 只要不在攻击范围内,立即重置攻击标记以允许移动逻辑执行
|
||||
this._isPunching = false;
|
||||
|
||||
if (horizDist > 1e-5) {
|
||||
this._moveDir.set(dx / horizDist, 0, dz / horizDist);
|
||||
this._state = this._isRunning ? "run" : "walk";
|
||||
const moveAnim = this._isRunning ? (this._runAction ?? this._walkAction) : this._walkAction;
|
||||
this._playAnim(moveAnim ?? this._idleAction);
|
||||
} else {
|
||||
this._moveDir.set(0, 0, 0);
|
||||
this._state = "idle";
|
||||
this._playAnim(this._idleAction ?? this._walkAction);
|
||||
}
|
||||
}
|
||||
|
||||
const currentSpeed = this._isRunning ? this._speed * 1.5 : this._speed;
|
||||
this._applyEnvironmentCollision(delta, currentSpeed);
|
||||
this._applyGrounding(delta);
|
||||
this._mixer?.update(delta);
|
||||
}
|
||||
|
||||
// ==================== 伤害 / 死亡 ====================
|
||||
|
||||
takeDamage(damage) {
|
||||
if (this.isDead) return false;
|
||||
this.hp -= damage;
|
||||
if (this.hp <= 0) {
|
||||
this.die();
|
||||
return true; // 本次命中导致死亡
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
die() {
|
||||
if (this.isDead) return;
|
||||
this.isDead = true;
|
||||
this._state = "die";
|
||||
this._isPunching = false;
|
||||
this._deathTime = 0;
|
||||
this._velocity.set(0, 0, 0);
|
||||
|
||||
if (this._deathAction) {
|
||||
this._playAnim(this._deathAction, 0.1);
|
||||
}
|
||||
|
||||
// 禁用图层以便射线检测和自动瞄准忽略尸体
|
||||
this._capsule.layers.disable(2);
|
||||
this._model.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.layers.disable(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Getter ====================
|
||||
|
||||
getPosition() { return this._capsule?.position ?? null; }
|
||||
getCapsule() { return this._capsule; }
|
||||
getCapsuleInfo() { return this._capsuleInfo; }
|
||||
getDeathTime() { return this._deathTime; }
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
// 切换动画,带淡入淡出
|
||||
_playAnim(action, fade = 0.2) {
|
||||
if (!action || this._currentAction === action) return;
|
||||
const prev = this._currentAction;
|
||||
action.reset().setEffectiveWeight(1).play();
|
||||
if (prev) {
|
||||
prev.fadeOut(fade);
|
||||
action.fadeIn(fade);
|
||||
} else {
|
||||
action.fadeIn(fade);
|
||||
}
|
||||
this._currentAction = action;
|
||||
}
|
||||
|
||||
// 分步移动 + BVH 环境碰撞(只处理竖直面,水平面由 _applyGrounding 负责)
|
||||
_applyEnvironmentCollision(delta, speed) {
|
||||
const ci = this._capsuleInfo;
|
||||
const totalDist = speed * delta;
|
||||
const maxStep = ci.radius * 0.8; // 每步最大移动量,防止高速穿透
|
||||
const steps = Math.ceil(totalDist / maxStep) || 1;
|
||||
const stepDist = totalDist / steps;
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
this._capsule.position.addScaledVector(this._moveDir, stepDist);
|
||||
this._capsule.updateMatrixWorld();
|
||||
|
||||
for (const collider of this._colliders) {
|
||||
this._tempBox.makeEmpty();
|
||||
this._tempMat.copy(collider.matrixWorld).invert();
|
||||
this._tempSeg.copy(ci.segment);
|
||||
this._tempSeg.start
|
||||
.applyMatrix4(this._capsule.matrixWorld)
|
||||
.applyMatrix4(this._tempMat);
|
||||
this._tempSeg.end
|
||||
.applyMatrix4(this._capsule.matrixWorld)
|
||||
.applyMatrix4(this._tempMat);
|
||||
this._tempBox.expandByPoint(this._tempSeg.start).expandByPoint(this._tempSeg.end);
|
||||
this._tempBox.expandByScalar(ci.radius);
|
||||
|
||||
collider.geometry?.boundsTree?.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(this._tempBox),
|
||||
intersectsTriangle: (tri) => {
|
||||
const dist = tri.closestPointToSegment(
|
||||
this._tempSeg,
|
||||
this._tempV1,
|
||||
this._tempV2,
|
||||
);
|
||||
if (dist >= ci.radius) return;
|
||||
const normal = tri.getNormal(new THREE.Vector3());
|
||||
if (Math.abs(normal.y) > 0.5) return; // 水平面交给 _applyGrounding 处理
|
||||
const dir = this._tempV2.sub(this._tempV1).normalize();
|
||||
const depth = ci.radius - dist;
|
||||
this._tempSeg.start.addScaledVector(dir, depth);
|
||||
this._tempSeg.end.addScaledVector(dir, depth);
|
||||
},
|
||||
});
|
||||
|
||||
const newPos = this._tempV1
|
||||
.copy(this._tempSeg.start)
|
||||
.applyMatrix4(collider.matrixWorld);
|
||||
const deltaVec = this._tempV2.subVectors(newPos, this._capsule.position);
|
||||
const offset = Math.max(0, deltaVec.length() - 1e-5);
|
||||
if (offset > 0) {
|
||||
this._capsule.position.add(deltaVec.normalize().multiplyScalar(offset));
|
||||
this._capsule.updateMatrixWorld();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 射线向下检测地面,吸附胶囊体到地面高度
|
||||
_applyGrounding(delta) {
|
||||
this._raycaster.ray.origin.copy(this._capsule.position);
|
||||
const hits = this._raycaster.intersectObjects(this._colliders, false);
|
||||
|
||||
if (!hits.length) {
|
||||
this._onGround = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const groundY = hits[0].point.y;
|
||||
const scale = this._modelScale;
|
||||
const snapHeight = this._capsuleHeight * scale * 0.75; // 胶囊悬停高度
|
||||
const maxHeight = this._capsuleHeight * scale * 0.9; // 超过此距离视为腾空
|
||||
const dist = this._capsule.position.y - groundY;
|
||||
|
||||
if (dist >= maxHeight) {
|
||||
this._onGround = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._velocity.set(0, 0, 0);
|
||||
this._onGround = true;
|
||||
|
||||
if (dist >= snapHeight) {
|
||||
// 平滑吸附(走上斜坡时避免抖动)
|
||||
this._capsule.position.y = THREE.MathUtils.lerp(
|
||||
this._capsule.position.y,
|
||||
groundY + snapHeight,
|
||||
Math.min(1, 15 * delta),
|
||||
);
|
||||
} else {
|
||||
this._capsule.position.y = groundY + snapHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 销毁 ====================
|
||||
|
||||
destroy() {
|
||||
if (this._mixer) {
|
||||
if (this._punchLoopCb) {
|
||||
this._mixer.removeEventListener("loop", this._punchLoopCb);
|
||||
this._punchLoopCb = null;
|
||||
}
|
||||
this._mixer.stopAllAction();
|
||||
this._mixer.uncacheRoot(this._model);
|
||||
this._mixer = null;
|
||||
}
|
||||
if (this._capsule) {
|
||||
this._scene.remove(this._capsule);
|
||||
this._capsule.geometry?.dispose();
|
||||
this._capsule.material?.dispose?.();
|
||||
this._capsule = null;
|
||||
}
|
||||
this._model = null;
|
||||
this._colliders = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Vector3 } from "three";
|
||||
import { ZombieEntity } from "./ZombieEntity.js";
|
||||
|
||||
export class ZombieManager {
|
||||
constructor(scene, options = {}) {
|
||||
// ==================== 场景引用 ====================
|
||||
this._scene = scene;
|
||||
|
||||
// ==================== 丧尸配置 ====================
|
||||
this._loader = options.loader ?? null; // GLTF 加载器
|
||||
this._colliders = options.colliders ?? []; // 场景碰撞网格
|
||||
this._modelUrl = options.modelUrl ?? ""; // 模型路径
|
||||
this._scale = options.scale ?? 0.01; // 模型缩放
|
||||
this._rotateY = options.rotateY ?? Math.PI; // 模型初始朝向
|
||||
this._speed = options.speed ?? 120; // 移速基准值
|
||||
this._walkAnim = options.walkAnim ?? "walking"; // 行走动画名
|
||||
this._runAnim = options.runAnim ?? "running"; // 奔跑动画名
|
||||
this._idleAnim = options.idleAnim ?? "Idle"; // 待机动画名
|
||||
this._punchAnim = options.punchAnim ?? "punching"; // 攻击动画名
|
||||
this._deathAnim = options.deathAnim ?? "dying"; // 死亡动画名
|
||||
// ==================== 运行状态 ====================
|
||||
this._zombies = new Map(); // id → ZombieEntity
|
||||
this._wave = 0; // 当前波次
|
||||
this._nextId = 0; // 自增 id 计数器
|
||||
}
|
||||
|
||||
// ==================== 波次 / 生成 ====================
|
||||
|
||||
// 按波次配置批量生成丧尸
|
||||
async startWave(waveConfig = {}) {
|
||||
if (!this._loader || !this._colliders.length || !this._modelUrl) {
|
||||
console.warn("[ZombieManager] Missing loader, colliders or modelUrl");
|
||||
return;
|
||||
}
|
||||
|
||||
this._wave += 1;
|
||||
|
||||
const origin = waveConfig.origin?.clone?.() ?? waveConfig.origin ?? new Vector3();
|
||||
const count = waveConfig.count ?? 5;
|
||||
const radius = waveConfig.radius ?? 10; // 默认半径 10
|
||||
const spawnHeight = waveConfig.spawnHeight ?? 0.5; // 对应参数 y
|
||||
|
||||
let spawnPoints = waveConfig.spawnPoints;
|
||||
|
||||
// 如果没有预设点,则根据半径和高度生成随机点
|
||||
if (!spawnPoints) {
|
||||
spawnPoints = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * radius; // 使用开方确保在圆盘内分布均匀
|
||||
const x = Math.cos(angle) * r;
|
||||
const z = Math.sin(angle) * r;
|
||||
spawnPoints.push(new Vector3(origin.x + x, origin.y + spawnHeight, origin.z + z));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(spawnPoints.map((point) => this.spawnZombie(point)));
|
||||
}
|
||||
|
||||
// 在指定位置生成单个丧尸,返回其 id
|
||||
async spawnZombie(position) {
|
||||
const id = `zombie_${this._nextId++}`;
|
||||
const entity = new ZombieEntity(this._scene, id);
|
||||
await entity.load(this._loader, {
|
||||
modelUrl: this._modelUrl,
|
||||
colliders: this._colliders,
|
||||
position: position.clone?.() ?? new Vector3(position.x, position.y, position.z),
|
||||
scale: this._scale,
|
||||
walkAnim: this._walkAnim,
|
||||
runAnim: this._runAnim,
|
||||
idleAnim: this._idleAnim,
|
||||
punchAnim: this._punchAnim,
|
||||
deathAnim: this._deathAnim,
|
||||
rotateY: this._rotateY,
|
||||
speed: this._speed,
|
||||
});
|
||||
this._zombies.set(id, entity);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ==================== 命中 ====================
|
||||
|
||||
// 武器命中回调,转发伤害给对应实体
|
||||
onHit(id, damage) {
|
||||
const entity = this._zombies.get(id);
|
||||
if (!entity || entity.isDead) return;
|
||||
entity.takeDamage(damage);
|
||||
}
|
||||
|
||||
// ==================== 主循环 ====================
|
||||
|
||||
update(dt, playerPos) {
|
||||
if (!playerPos) return;
|
||||
|
||||
for (const [id, entity] of this._zombies.entries()) {
|
||||
entity.update(dt, playerPos);
|
||||
|
||||
// 如果丧尸已死亡且超过10秒,则彻底移除
|
||||
if (entity.isDead && entity.getDeathTime() >= 10) {
|
||||
entity.destroy();
|
||||
this._zombies.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
this._resolveZombieOverlaps();
|
||||
}
|
||||
|
||||
// 解决丧尸之间的胶囊重叠(两两推开,仅处理水平方向)
|
||||
_resolveZombieOverlaps() {
|
||||
const entities = Array.from(this._zombies.values()).filter(e => !e.isDead);
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const a = entities[i];
|
||||
const aCapsule = a.getCapsule();
|
||||
const aInfo = a.getCapsuleInfo();
|
||||
if (!aCapsule || !aInfo) continue;
|
||||
|
||||
for (let j = i + 1; j < entities.length; j++) {
|
||||
const b = entities[j];
|
||||
const bCapsule = b.getCapsule();
|
||||
const bInfo = b.getCapsuleInfo();
|
||||
if (!bCapsule || !bInfo) continue;
|
||||
|
||||
const dx = bCapsule.position.x - aCapsule.position.x;
|
||||
const dz = bCapsule.position.z - aCapsule.position.z;
|
||||
const distSq = dx * dx + dz * dz;
|
||||
const minDist = aInfo.radius + bInfo.radius;
|
||||
|
||||
if (distSq >= minDist * minDist || distSq <= 1e-10) continue;
|
||||
|
||||
const dist = Math.sqrt(distSq);
|
||||
const half = (minDist - dist) * 0.5; // 各推开一半
|
||||
const nx = dx / dist;
|
||||
const nz = dz / dist;
|
||||
aCapsule.position.x -= nx * half;
|
||||
aCapsule.position.z -= nz * half;
|
||||
bCapsule.position.x += nx * half;
|
||||
bCapsule.position.z += nz * half;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 销毁 ====================
|
||||
|
||||
destroy() {
|
||||
for (const entity of this._zombies.values()) {
|
||||
entity.destroy();
|
||||
}
|
||||
this._zombies.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Showcase</title>
|
||||
<link rel="stylesheet" href="./styles/common.css" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #dfe8ef;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<script src="./loader.js" data-demo="Showcase"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
|
||||
<div id="top-notice">
|
||||
<div class="credit-line">
|
||||
Character Model:
|
||||
<a href="https://quaternius.itch.io/universal-animation-library" target="_blank" rel="noopener noreferrer">Universal Animation Library</a>
|
||||
</div>
|
||||
<div class="credit-line">
|
||||
Vehicle:
|
||||
<a href="https://sketchfab.com/3d-models/low-poly-cyberpunk-suv-dc48eaca64ee4d628403ca8464964780" target="_blank" rel="noopener noreferrer">Low-poly cyberpunk SUV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hud" data-hint-mode="on-foot" role="note" aria-label="Controls">
|
||||
<div class="hint-set" data-for="on-foot">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Move</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Sprint</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Jump</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>E</kbd></span>
|
||||
<span class="hint-text">Enter</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>F</kbd></span>
|
||||
<span class="hint-text">Toggle Fly</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-set" data-for="vehicle">
|
||||
<div class="hint-move">
|
||||
<div class="hint-cluster">
|
||||
<div class="wasd">
|
||||
<kbd class="wasd-w">W</kbd>
|
||||
<kbd class="wasd-a">A</kbd>
|
||||
<kbd class="wasd-s">S</kbd>
|
||||
<kbd class="wasd-d">D</kbd>
|
||||
</div>
|
||||
<span class="hint-text">Drive</span>
|
||||
</div>
|
||||
<div class="hint-cluster">
|
||||
<div class="hint-mouse" aria-hidden="true">
|
||||
<div class="hint-mouse-body">
|
||||
<span class="hint-mouse-wheel"></span>
|
||||
<span class="hint-mouse-nub"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hint-text">Look</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-actions">
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Shift</kbd></span>
|
||||
<span class="hint-text">Drift</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>Space</kbd></span>
|
||||
<span class="hint-text">Brake</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>E</kbd></span>
|
||||
<span class="hint-text">Exit</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>V</kbd></span>
|
||||
<span class="hint-text">Toggle View</span>
|
||||
</div>
|
||||
<div class="hint-group">
|
||||
<span class="hint-keys"><kbd>R</kbd></span>
|
||||
<span class="hint-text">Reset Vehicle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./control-hints.js"></script>
|
||||
<script type="module" src="./showcase.js"></script>
|
||||
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/showcase.js" target="_blank" rel="noopener noreferrer">
|
||||
<img src="./img/source.svg" alt="" />
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,343 @@
|
||||
.lil-gui.root,
|
||||
.lil-gui.root .lil-gui {
|
||||
--background-color: rgba(8, 12, 18, 0.8);
|
||||
--text-color: #e8eef4;
|
||||
--title-background-color: rgba(255, 255, 255, 0.055);
|
||||
--title-text-color: #f4f7fb;
|
||||
--widget-color: rgba(255, 255, 255, 0.09);
|
||||
--hover-color: rgba(255, 255, 255, 0.15);
|
||||
--focus-color: rgba(232, 160, 32, 0.28);
|
||||
--number-color: #f0b64a;
|
||||
--string-color: #79c0ff;
|
||||
--font-size: 11px;
|
||||
--input-font-size: 11px;
|
||||
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--padding: 6px;
|
||||
--spacing: 5px;
|
||||
--widget-height: 24px;
|
||||
--name-width: 45%;
|
||||
--folder-indent: 10px;
|
||||
--widget-border-radius: 5px;
|
||||
}
|
||||
|
||||
.lil-gui.root {
|
||||
color-scheme: dark;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
background: var(--background-color);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.lil-gui.root .lil-gui {
|
||||
--background-color: transparent;
|
||||
}
|
||||
|
||||
.lil-gui.root > .title {
|
||||
border-radius: 9px 9px 0 0;
|
||||
color: var(--title-text-color);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.lil-gui.root.closed > .title {
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.lil-gui.root .lil-gui > .title {
|
||||
color: rgba(232, 238, 244, 0.88);
|
||||
}
|
||||
|
||||
.lil-gui.root .controller .name {
|
||||
color: rgba(230, 238, 246, 0.78);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lil-gui.root input,
|
||||
.lil-gui.root .controller.option .display,
|
||||
.lil-gui.root .controller.function button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.lil-gui.root input:focus-visible,
|
||||
.lil-gui.root button:focus-visible,
|
||||
.lil-gui.root select:focus-visible {
|
||||
outline: 1px solid rgba(240, 192, 90, 0.9);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.lil-gui.root .controller.number .slider .fill {
|
||||
background: linear-gradient(90deg, rgba(232, 160, 32, 0.18), rgba(240, 192, 90, 0.34));
|
||||
}
|
||||
|
||||
.lil-gui.root input[type="checkbox"]:checked {
|
||||
color: #1a1408;
|
||||
background: linear-gradient(180deg, #f0c05a, #e8a020);
|
||||
border-color: #f6d48a;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: #e8eef4;
|
||||
background: rgba(8, 12, 18, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.hint-move {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.hint-cluster {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wasd {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 26px);
|
||||
grid-template-rows: repeat(2, 26px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wasd-w {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.wasd-a {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.wasd-s {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.wasd-d {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.hud kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
border-radius: 5px;
|
||||
color: #f4f7fb;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 0 rgba(0, 0, 0, 0.35);
|
||||
font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
transition: background 0.08s ease, border-color 0.08s ease, color 0.08s ease, box-shadow 0.08s ease, transform 0.08s ease;
|
||||
}
|
||||
|
||||
.hud kbd.is-down {
|
||||
color: #1a1408;
|
||||
background: linear-gradient(180deg, #f0c05a, #e8a020);
|
||||
border-color: #f6d48a;
|
||||
border-bottom-color: #b87a10;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.wasd kbd {
|
||||
width: 26px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hint-actions {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 8px;
|
||||
row-gap: 5px;
|
||||
align-items: center;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.hint-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.hint-keys {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: rgba(230, 238, 246, 0.78);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint-mouse {
|
||||
width: 20px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-mouse-body {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px 10px 11px 11px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 0 rgba(0, 0, 0, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hint-mouse-wheel {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
width: 3px;
|
||||
height: 7px;
|
||||
margin-left: -1.5px;
|
||||
border-radius: 1px;
|
||||
background: rgba(244, 247, 251, 0.35);
|
||||
}
|
||||
|
||||
.hint-mouse-nub {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 64%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: -3px 0 0 -3px;
|
||||
border-radius: 50%;
|
||||
background: #e8a020;
|
||||
box-shadow: 0 0 6px rgba(232, 160, 32, 0.55);
|
||||
transform: translate(var(--look-x, 0px), var(--look-y, 0px));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hint-mouse.is-active .hint-mouse-body {
|
||||
border-color: rgba(232, 160, 32, 0.55);
|
||||
}
|
||||
|
||||
.hud[data-hint-mode] .hint-set {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hud[data-hint-mode="on-foot"] .hint-set[data-for="on-foot"],
|
||||
.hud[data-hint-mode="vehicle"] .hint-set[data-for="vehicle"] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.model-credit {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
font-size: 16px;
|
||||
color: #e6eef6;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial;
|
||||
}
|
||||
|
||||
.model-credit a {
|
||||
color: #6ecbff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.model-credit a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.source {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 0;
|
||||
background-color: #fff;
|
||||
opacity: 0.9;
|
||||
z-index: 999;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.source img {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
#top-notice {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #e6eef6;
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
#top-notice .credit-line {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#top-notice a {
|
||||
color: #79c0ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#top-notice a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse), (max-width: 768px) {
|
||||
.hud,
|
||||
.source,
|
||||
#top-notice,
|
||||
.model-credit {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// 来源:https://threejs.org/examples/?q=cloud#webgl_volume_cloud
|
||||
import {
|
||||
BackSide,
|
||||
BoxGeometry,
|
||||
Color,
|
||||
Data3DTexture,
|
||||
GLSL3,
|
||||
LinearFilter,
|
||||
Mesh,
|
||||
RawShaderMaterial,
|
||||
RedFormat,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { ImprovedNoise } from "three/examples/jsm/math/ImprovedNoise.js";
|
||||
|
||||
const vertexShader = `
|
||||
in vec3 position;
|
||||
|
||||
uniform mat4 modelMatrix;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform vec3 cameraPos;
|
||||
|
||||
out vec3 vOrigin;
|
||||
out vec3 vDirection;
|
||||
|
||||
void main() {
|
||||
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
|
||||
|
||||
vOrigin = vec3( inverse( modelMatrix ) * vec4( cameraPos, 1.0 ) ).xyz;
|
||||
vDirection = position - vOrigin;
|
||||
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
precision highp sampler3D;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
|
||||
in vec3 vOrigin;
|
||||
in vec3 vDirection;
|
||||
|
||||
out vec4 color;
|
||||
|
||||
uniform vec3 base;
|
||||
uniform sampler3D map;
|
||||
|
||||
uniform float threshold;
|
||||
uniform float range;
|
||||
uniform float opacity;
|
||||
uniform float steps;
|
||||
uniform float frame;
|
||||
|
||||
uint wang_hash(uint seed)
|
||||
{
|
||||
seed = (seed ^ 61u) ^ (seed >> 16u);
|
||||
seed *= 9u;
|
||||
seed = seed ^ (seed >> 4u);
|
||||
seed *= 0x27d4eb2du;
|
||||
seed = seed ^ (seed >> 15u);
|
||||
return seed;
|
||||
}
|
||||
|
||||
float randomFloat(inout uint seed)
|
||||
{
|
||||
return float(wang_hash(seed)) / 4294967296.;
|
||||
}
|
||||
|
||||
vec2 hitBox( vec3 orig, vec3 dir ) {
|
||||
const vec3 box_min = vec3( - 0.5 );
|
||||
const vec3 box_max = vec3( 0.5 );
|
||||
vec3 inv_dir = 1.0 / dir;
|
||||
vec3 tmin_tmp = ( box_min - orig ) * inv_dir;
|
||||
vec3 tmax_tmp = ( box_max - orig ) * inv_dir;
|
||||
vec3 tmin = min( tmin_tmp, tmax_tmp );
|
||||
vec3 tmax = max( tmin_tmp, tmax_tmp );
|
||||
float t0 = max( tmin.x, max( tmin.y, tmin.z ) );
|
||||
float t1 = min( tmax.x, min( tmax.y, tmax.z ) );
|
||||
return vec2( t0, t1 );
|
||||
}
|
||||
|
||||
float sample1( vec3 p ) {
|
||||
return texture( map, p ).r;
|
||||
}
|
||||
|
||||
float shading( vec3 coord ) {
|
||||
float step = 0.01;
|
||||
return sample1( coord + vec3( - step ) ) - sample1( coord + vec3( step ) );
|
||||
}
|
||||
|
||||
vec4 linearToSRGB( in vec4 value ) {
|
||||
return vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec3 rayDir = normalize( vDirection );
|
||||
vec2 bounds = hitBox( vOrigin, rayDir );
|
||||
|
||||
if ( bounds.x > bounds.y ) discard;
|
||||
|
||||
bounds.x = max( bounds.x, 0.0 );
|
||||
|
||||
float stepSize = ( bounds.y - bounds.x ) / steps;
|
||||
|
||||
uint seed = uint( gl_FragCoord.x ) * uint( 1973 ) + uint( gl_FragCoord.y ) * uint( 9277 ) + uint( frame ) * uint( 26699 );
|
||||
vec3 size = vec3( textureSize( map, 0 ) );
|
||||
float randNum = randomFloat( seed ) * 2.0 - 1.0;
|
||||
vec3 p = vOrigin + bounds.x * rayDir;
|
||||
p += rayDir * randNum * ( 1.0 / size );
|
||||
|
||||
vec4 ac = vec4( base, 0.0 );
|
||||
|
||||
for ( float i = 0.0; i < steps; i += 1.0 ) {
|
||||
float d = sample1( p + 0.5 );
|
||||
|
||||
d = smoothstep( threshold - range, threshold + range, d ) * opacity;
|
||||
|
||||
float col = shading( p + 0.5 ) * 3.0 + ( ( p.x + p.y ) * 0.25 ) + 0.2;
|
||||
|
||||
ac.rgb += ( 1.0 - ac.a ) * d * col;
|
||||
ac.a += ( 1.0 - ac.a ) * d;
|
||||
|
||||
if ( ac.a >= 0.95 ) break;
|
||||
|
||||
p += rayDir * stepSize;
|
||||
}
|
||||
|
||||
color = linearToSRGB( ac );
|
||||
|
||||
if ( color.a == 0.0 ) discard;
|
||||
}
|
||||
`;
|
||||
|
||||
// 生成云纹理
|
||||
function createCloudTexture(size, noiseScale) {
|
||||
// 体素密度
|
||||
const data = new Uint8Array(size * size * size);
|
||||
const perlin = new ImprovedNoise();
|
||||
const vector = new Vector3();
|
||||
|
||||
let i = 0;
|
||||
for (let z = 0; z < size; z++) {
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const d = 1.0 - vector.set(x, y, z).subScalar(size / 2).divideScalar(size).length();
|
||||
data[i] = (128 + 128 * perlin.noise(x * noiseScale / 1.5, y * noiseScale, z * noiseScale / 1.5)) * d * d;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传纹理
|
||||
const texture = new Data3DTexture(data, size, size, size);
|
||||
texture.format = RedFormat;
|
||||
texture.minFilter = LinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.unpackAlignment = 1;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// 创建体积云
|
||||
export function createVolumeCloud(options = {}) {
|
||||
// 默认参数
|
||||
const {
|
||||
size = 96,
|
||||
noiseScale = 0.05,
|
||||
color = 0x798aa0,
|
||||
threshold = 0.25,
|
||||
opacity = 0.25,
|
||||
range = 0.1,
|
||||
steps = 80,
|
||||
scale = [3, 1.5, 2.2],
|
||||
} = options;
|
||||
|
||||
// 构造材质
|
||||
const texture = createCloudTexture(size, noiseScale);
|
||||
const material = new RawShaderMaterial({
|
||||
glslVersion: GLSL3,
|
||||
uniforms: {
|
||||
base: { value: new Color(color) },
|
||||
map: { value: texture },
|
||||
cameraPos: { value: new Vector3() },
|
||||
threshold: { value: threshold },
|
||||
opacity: { value: opacity },
|
||||
range: { value: range },
|
||||
steps: { value: steps },
|
||||
frame: { value: 0 },
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
side: BackSide,
|
||||
transparent: true,
|
||||
depthWrite: true,
|
||||
});
|
||||
|
||||
// 返回网格
|
||||
const cloud = new Mesh(new BoxGeometry(1, 1, 1), material);
|
||||
cloud.name = "volumeCloud";
|
||||
cloud.scale.set(...scale);
|
||||
cloud.frustumCulled = false;
|
||||
return cloud;
|
||||
}
|
||||
|
||||
// 更新体积云
|
||||
export function updateVolumeCloud(cloud, camera, rotationSpeed = 0) {
|
||||
if (!cloud?.material?.uniforms || !camera) return;
|
||||
if (!cloud.material.visible) return;
|
||||
|
||||
// 同步相机
|
||||
const uniforms = cloud.material.uniforms;
|
||||
camera.getWorldPosition(uniforms.cameraPos.value);
|
||||
uniforms.frame.value += 1;
|
||||
// 可选旋转
|
||||
if (rotationSpeed !== 0) cloud.rotation.y += rotationSpeed / 60;
|
||||
}
|
||||
|
||||
// 释放体积云
|
||||
export function disposeVolumeCloud(cloud) {
|
||||
if (!cloud) return;
|
||||
|
||||
// 释放显存
|
||||
cloud.geometry?.dispose();
|
||||
cloud.material?.uniforms?.map?.value?.dispose();
|
||||
cloud.material?.dispose();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "three-player-controller",
|
||||
"version": "0.6.2",
|
||||
"description": "Third-person / first-person player controller for three.js (three-mesh-bvh based)",
|
||||
"author": "hh-hang",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./foot-ik": {
|
||||
"types": "./dist/foot-ik.d.ts",
|
||||
"import": "./dist/foot-ik.mjs",
|
||||
"require": "./dist/foot-ik.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsup",
|
||||
"clean": "rimraf dist",
|
||||
"prepare": "npm run build",
|
||||
"build:example": "vite build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.159.0",
|
||||
"three-mesh-bvh": ">=0.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sparkjsdev/spark": "^2.1.0",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/three": "^0.184.0",
|
||||
"3d-tiles-renderer": "^0.4.24",
|
||||
"rimraf": "^6.0.0",
|
||||
"tellux": "^0.2.0",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hh-hang/three-player-controller"
|
||||
},
|
||||
"homepage": "https://github.com/hh-hang/three-player-controller"
|
||||
}
|
||||