main #1

Merged
knso merged 10 commits from main into leonida/vice-slice 2026-09-15 19:35:33 +00:00
153 changed files with 42561 additions and 4 deletions
Showing only changes of commit fb38652e7f - Show all commits

No files matched your search

-3
View File
@@ -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"
}
]
}
+50
View File
@@ -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
+19
View File
@@ -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/
+28
View File
@@ -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
+21
View File
@@ -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.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+84
View File
@@ -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>
+213
View File
@@ -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>
+610
View File
@@ -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);
}
+133
View File
@@ -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>
File diff suppressed because it is too large. Load diff
+383
View File
@@ -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>
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

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);
};
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

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>
File diff suppressed because it is too large. Load diff
@@ -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();
}
File diff suppressed because it is too large. Load diff
+52
View File
@@ -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"
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more