chore(controller): three-player-controller als Ordner statt Submodul

Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur
lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json.
Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser
Stand als normaler Ordner im Repo, ohne Verweis auf GitHub.

dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher
lokal gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-15 21:27:03 +02:00
1 parent 780e246fe5
commit fb38652e7f
153 files changed
+42561 -4

No files matched your search

+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);
}