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>
1129 lines
42 KiB
JavaScript
1129 lines
42 KiB
JavaScript
import {
|
|
ACESFilmicToneMapping,
|
|
AmbientLight,
|
|
CircleGeometry,
|
|
DoubleSide,
|
|
Float32BufferAttribute,
|
|
Timer,
|
|
EquirectangularReflectionMapping,
|
|
Mesh,
|
|
MeshStandardMaterial,
|
|
PerspectiveCamera,
|
|
Raycaster,
|
|
Scene,
|
|
SphereGeometry,
|
|
Vector2,
|
|
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 { FootIK } from "../src/foot-ik";
|
|
import { bindVehicleHintMode } from "./control-hints.js";
|
|
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
|
|
import Stats from 'three/examples/jsm/libs/stats.module.js';
|
|
import { CSM } from "three/examples/jsm/csm/CSM.js";
|
|
import { createVolumeCloud, disposeVolumeCloud, updateVolumeCloud } from "./volumeCloud.js";
|
|
|
|
let player;
|
|
const scene = new Scene();
|
|
|
|
let camera;
|
|
let renderer;
|
|
let controls;
|
|
let gltfLoader;
|
|
let guiParams = null;
|
|
let footIK = null;
|
|
let footIKDebugParams = null;
|
|
let gui = null;
|
|
|
|
let stats = null;
|
|
let csm = null;
|
|
let currentBlobUrl = null;
|
|
let platform = null;
|
|
let platformCloud = null;
|
|
let dynamicPlatforms = [];
|
|
const dynamicPlatformXPath = [
|
|
new Vector3(20.94, 3.70, 14.89),
|
|
new Vector3(-1.32, 7.65, 14.83),
|
|
new Vector3(-19.85, 14.38, 8.77),
|
|
];
|
|
const dynamicPlatformXSegments = dynamicPlatformXPath.slice(0, -1).map((point, index) => ({
|
|
from: point,
|
|
to: dynamicPlatformXPath[index + 1],
|
|
length: point.distanceTo(dynamicPlatformXPath[index + 1]),
|
|
}));
|
|
const dynamicPlatformXLength = dynamicPlatformXSegments.reduce((sum, segment) => sum + segment.length, 0);
|
|
const animTimer = new Timer();
|
|
|
|
let globalScale = 1;
|
|
let previewMesh = null;
|
|
let previewMode = false;
|
|
let previewHintEl = null;
|
|
|
|
// CSM 基准参数
|
|
const CSM_BASE = { maxFar: 30, lightNear: 0.1, lightFar: 50, lightMargin: 30, lightIntensity: 10 };
|
|
const previewRaycaster = new Raycaster();
|
|
const previewMouse = new Vector2();
|
|
|
|
const modelUrl = "./glb/crash_junction.glb";
|
|
const pos = new Vector3(21.5, 4, 15);
|
|
|
|
// 人物模型配置
|
|
function easeEndsLinearMiddle(progress, easeRatio = 0.18) {
|
|
const ease = Math.min(Math.max(easeRatio, 0.001), 0.49);
|
|
const maxSpeed = 1 / (1 - ease);
|
|
if (progress < ease) return (maxSpeed * progress * progress) / (2 * ease);
|
|
if (progress > 1 - ease) return 1 - (maxSpeed * (1 - progress) * (1 - progress)) / (2 * ease);
|
|
return maxSpeed * (progress - ease / 2);
|
|
}
|
|
|
|
// 根据整条路径的总长度定位平台
|
|
function setPositionOnXPath(mesh, progress) {
|
|
if (!dynamicPlatformXSegments.length || dynamicPlatformXLength <= 0) return;
|
|
|
|
let targetDistance = progress * dynamicPlatformXLength;
|
|
for (const segment of dynamicPlatformXSegments) {
|
|
if (targetDistance <= segment.length) {
|
|
mesh.position.lerpVectors(segment.from, segment.to, targetDistance / segment.length);
|
|
return;
|
|
}
|
|
targetDistance -= segment.length;
|
|
}
|
|
|
|
mesh.position.copy(dynamicPlatformXPath[dynamicPlatformXPath.length - 1]);
|
|
}
|
|
|
|
const PLAYER_MODELS = {
|
|
josh: {
|
|
url: "./glb/josh.glb",
|
|
scale: 0.001,
|
|
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",
|
|
rotateY: Math.PI,
|
|
},
|
|
maw: {
|
|
url: "./glb/maw.glb",
|
|
scale: 0.005,
|
|
idleAnim: "idle",
|
|
walkAnim: "walk",
|
|
runAnim: "run",
|
|
jumpAnim: "jump",
|
|
flyAnim: "flying",
|
|
flyIdleAnim: "flyidle",
|
|
headBoneName: "mixamorigHead",
|
|
rotateY: Math.PI,
|
|
},
|
|
ual: {
|
|
url: "./glb/ual.glb",
|
|
scale: 0.001,
|
|
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",
|
|
drivingAnim: "Driving_Loop",
|
|
headBoneName: "Head",
|
|
rotateY: Math.PI,
|
|
firstPersonCameraOffset: [0, 0.15, 0.12],
|
|
},
|
|
};
|
|
|
|
// 车辆配置
|
|
const VEHICLE_TUNING = {
|
|
followVehicleDirection: true,
|
|
debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false },
|
|
chassis: {
|
|
density: 1,
|
|
linearDamping: 0.05,
|
|
angularDamping: 0.5,
|
|
clearance: 0.25,
|
|
sizeScale: { x: 0.9, y: 0.65, z: 0.9 },
|
|
},
|
|
suspension: {
|
|
restLength: 0.15,
|
|
maxTravel: 0.15,
|
|
stiffness: 25,
|
|
compression: 2.1,
|
|
relaxation: 2.5,
|
|
maxForce: 6000,
|
|
frictionSlip: 8,
|
|
sideFrictionStiffness: 1,
|
|
rollInfluence: 0.12,
|
|
},
|
|
steering: {
|
|
maxSteerAngle: Math.PI / 5,
|
|
steerTime: 0.45,
|
|
steerReturnTimeSlow: 0.55,
|
|
steerReturnTimeFast: 0.4,
|
|
highSpeedSteerScale: 0.3,
|
|
},
|
|
grip: {
|
|
maxG: 1.2,
|
|
sideFrictionIdle: 1,
|
|
sideFrictionFrontMin: 0.55,
|
|
sideFrictionRearMin: 0.45,
|
|
handbrakeRearFriction: 0.35,
|
|
handbrakeRearDriveScale: 0.65,
|
|
handbrakeReleaseTime: 0.15,
|
|
wheelbaseRatio: 0.55,
|
|
},
|
|
power: {
|
|
maxSpeed: 100,
|
|
acceleration: 5,
|
|
deceleration: 5,
|
|
},
|
|
};
|
|
|
|
const VEHICLE_CONFIG = {
|
|
url: "./glb/sedan.glb",
|
|
scale: 0.1,
|
|
wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"],
|
|
followVehicleDirection: VEHICLE_TUNING.followVehicleDirection,
|
|
debug: VEHICLE_TUNING.debug,
|
|
chassis: VEHICLE_TUNING.chassis,
|
|
suspension: VEHICLE_TUNING.suspension,
|
|
steering: VEHICLE_TUNING.steering,
|
|
grip: VEHICLE_TUNING.grip,
|
|
power: VEHICLE_TUNING.power,
|
|
driverSeatPosition: new Vector3(-0.6, 1.0, 0.4),
|
|
driverSeatRotation: -Math.PI / 2,
|
|
};
|
|
|
|
init();
|
|
|
|
function setupCSMMaterial(material) {
|
|
if (!material || !csm) return;
|
|
const mats = Array.isArray(material) ? material : [material];
|
|
mats.forEach((m) => {
|
|
csm.setupMaterial(m);
|
|
// 材质可能已在异步加载期间编译,注入 CSM 后必须刷新 shader。
|
|
m.needsUpdate = true;
|
|
});
|
|
}
|
|
|
|
function createCSM(shadowMapSize, scale) {
|
|
const c = new CSM({
|
|
maxFar: CSM_BASE.maxFar * scale,
|
|
cascades: 3,
|
|
mode: "practical",
|
|
parent: scene,
|
|
shadowMapSize,
|
|
shadowBias: -0.00001,
|
|
lightDirection: new Vector3(-1, -2, -1).normalize(),
|
|
lightIntensity: CSM_BASE.lightIntensity,
|
|
lightNear: CSM_BASE.lightNear * scale,
|
|
lightFar: CSM_BASE.lightFar * scale,
|
|
camera,
|
|
fade: true,
|
|
lightMargin: CSM_BASE.lightMargin * scale,
|
|
});
|
|
c.lights.forEach((light, index) => {
|
|
const biasMult = Math.pow(2, index);
|
|
light.shadow.bias = -0.0001 * biasMult;
|
|
light.shadow.normalBias = 0.002 * biasMult;
|
|
});
|
|
return c;
|
|
}
|
|
|
|
function recreateCSM(scale) {
|
|
csm.remove();
|
|
csm.dispose();
|
|
const maxTextureSize = renderer.capabilities.maxTextureSize;
|
|
csm = createCSM(Math.min(2048, maxTextureSize), scale);
|
|
// 重新绑定场景内所有网格的材质
|
|
scene.traverse((child) => {
|
|
if (child.isMesh) setupCSMMaterial(child.material);
|
|
});
|
|
}
|
|
|
|
function getModelScale(key) {
|
|
return PLAYER_MODELS[key].scale * globalScale;
|
|
}
|
|
|
|
async function loadPlayerModelConfig(key) {
|
|
const m = PLAYER_MODELS[key];
|
|
const gltf = await gltfLoader.loadAsync(m.url);
|
|
const { url, ...rest } = m;
|
|
return {
|
|
...rest,
|
|
model: gltf.scene,
|
|
animations: gltf.animations,
|
|
scale: rest.scale * globalScale,
|
|
};
|
|
}
|
|
|
|
async function loadVehicleConfig(extra = {}) {
|
|
const gltf = await gltfLoader.loadAsync(VEHICLE_CONFIG.url);
|
|
const { url, ...rest } = VEHICLE_CONFIG;
|
|
return {
|
|
...rest,
|
|
model: gltf.scene,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
// 加载车辆。
|
|
async function spawnSceneSedan() {
|
|
await player.loadVehicleModel(await loadVehicleConfig({
|
|
scale: VEHICLE_CONFIG.scale * globalScale,
|
|
position: pos.clone().add(new Vector3(0, -0.3, -0.5)),
|
|
}));
|
|
|
|
const sedan = player.getAllVehicles().at(-1);
|
|
sedan?.vehicleGroup?.traverse((child) => {
|
|
if (!child.isMesh) return;
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
setupCSMMaterial(child.material);
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
materials.forEach((material) => {
|
|
if (!material) return;
|
|
material.metalness = 0.8;
|
|
material.roughness = 0;
|
|
});
|
|
});
|
|
applyVehicleTuning();
|
|
}
|
|
|
|
// Foot IK 使用通用骨骼名识别,模型切换后由插件自动重新绑定。
|
|
function attachFootIK() {
|
|
const p = guiParams;
|
|
footIK = new FootIK({
|
|
enabled: p?.footIKEnabled ?? true,
|
|
debug: (p?.footIKEnabled ?? true) && (p?.footIKDebug ?? false),
|
|
maxPelvisRaise: p?.maxPelvisRaise ?? 36,
|
|
maxPelvisDrop: p?.maxPelvisDrop ?? 36,
|
|
maxFootRaise: p?.maxFootRaise ?? 60,
|
|
maxFootDrop: p?.maxFootDrop ?? 36,
|
|
soleHalfWidth: p?.soleHalfWidth ?? 7,
|
|
soleToeExtend: p?.soleToeExtend ?? 7,
|
|
soleHeelExtend: p?.soleHeelExtend ?? 3,
|
|
soleSkinThickness: p?.soleSkinThickness ?? 1.6,
|
|
straightPoleEnabled: p?.straightPoleEnabled ?? true,
|
|
});
|
|
player.use(footIK);
|
|
if (p) footIKDebugParams = p;
|
|
}
|
|
|
|
/** 动态球视觉 */
|
|
function createDemoSphereMesh(color = 0x88c0d0) {
|
|
const geo = new SphereGeometry(1, 24, 16);
|
|
const pos = geo.attributes.position;
|
|
const colors = new Float32Array(pos.count * 3);
|
|
const r = ((color >> 16) & 255) / 255;
|
|
const g = ((color >> 8) & 255) / 255;
|
|
const b = (color & 255) / 255;
|
|
for (let i = 0; i < pos.count; i++) {
|
|
const x = pos.getX(i);
|
|
const y = pos.getY(i);
|
|
const band = Math.sin(x * 8 + y * 2) > 0;
|
|
const dark = band ? 0.35 : 1;
|
|
colors[i * 3] = r * dark;
|
|
colors[i * 3 + 1] = g * dark;
|
|
colors[i * 3 + 2] = b * dark;
|
|
}
|
|
geo.setAttribute("color", new Float32BufferAttribute(colors, 3));
|
|
const mesh = new Mesh(
|
|
geo,
|
|
new MeshStandardMaterial({ vertexColors: true, roughness: 0.55, metalness: 0.05 }),
|
|
);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
return mesh;
|
|
}
|
|
|
|
// 创建动态平台
|
|
function createDynamicPlatform({
|
|
position,
|
|
radius = 0.16,
|
|
cloudScale = [0.32, 0.15, 0.32],
|
|
motion = null,
|
|
}) {
|
|
// 网格
|
|
const mesh = new Mesh(
|
|
new CircleGeometry(radius, 32),
|
|
new MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.7, metalness: 0, roughness: 0.5, side: DoubleSide }),
|
|
);
|
|
mesh.position.copy(position);
|
|
mesh.rotation.x = -Math.PI / 2;
|
|
mesh.material.visible = false;
|
|
scene.add(mesh);
|
|
player.addCollider({
|
|
motion: "kinematic",
|
|
shape: { kind: "mesh", source: mesh },
|
|
follow: mesh,
|
|
});
|
|
|
|
// 体积云
|
|
const cloud = createVolumeCloud({
|
|
scale: cloudScale,
|
|
opacity: 0.28,
|
|
steps: 80,
|
|
});
|
|
cloud.position.set(0, 0, 0);
|
|
cloud.rotation.x = Math.PI / 2;
|
|
mesh.add(cloud);
|
|
|
|
// 缓存数据
|
|
const entry = {
|
|
mesh,
|
|
cloud,
|
|
basePosition: position.clone(),
|
|
motion,
|
|
};
|
|
dynamicPlatforms.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
// 更新动态平台
|
|
function updateDynamicPlatforms() {
|
|
const t = animTimer.getElapsed();
|
|
dynamicPlatforms.forEach((entry) => {
|
|
const { mesh, basePosition, motion, cloud } = entry;
|
|
if (motion) {
|
|
// 还原基点
|
|
if (motion.axis === "y") {
|
|
mesh.position.copy(basePosition);
|
|
const amount = Math.sin(t * motion.speed) * motion.distance;
|
|
// 纵向往返
|
|
mesh.position.y = basePosition.y + amount + motion.distance;
|
|
} else if (motion.axis === "x") {
|
|
if (player.getActiveKinematicCollider()?.source === mesh && player.getIsOnGround()) {
|
|
entry.motionElapsed = (entry.motionElapsed ?? 0) + player.getCurrentDelta();
|
|
|
|
const phase = (entry.motionElapsed * motion.speed / Math.PI) % 2;
|
|
const rawProgress = phase <= 1 ? phase : 2 - phase;
|
|
const progress = easeEndsLinearMiddle(rawProgress);
|
|
|
|
setPositionOnXPath(mesh, progress);
|
|
}
|
|
}
|
|
}
|
|
// 更新体积云
|
|
updateVolumeCloud(cloud, camera);
|
|
});
|
|
}
|
|
|
|
// 移除平台
|
|
function removeDynamicPlatforms() {
|
|
dynamicPlatforms.forEach(({ mesh, cloud }) => {
|
|
// 清理碰撞
|
|
player?.removeKinematicCollider(mesh);
|
|
disposeVolumeCloud(cloud);
|
|
scene.remove(mesh);
|
|
});
|
|
dynamicPlatforms = [];
|
|
platform = null;
|
|
platformCloud = null;
|
|
}
|
|
|
|
async function init() {
|
|
const cont = document.querySelector("#container");
|
|
|
|
// 渲染器
|
|
renderer = new WebGLRenderer({ antialias: true });
|
|
renderer.setSize(cont.clientWidth, cont.clientHeight);
|
|
renderer.shadowMap.enabled = false;
|
|
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 maxTextureSize = renderer.capabilities.maxTextureSize;
|
|
const shadowMapSize = Math.min(2048, maxTextureSize);
|
|
// 级联阴影
|
|
csm = createCSM(shadowMapSize, 1);
|
|
csm.lights.forEach((light, index) => {
|
|
const biasMult = Math.pow(2, index);
|
|
light.shadow.bias = -0.0001 * biasMult;
|
|
light.shadow.normalBias = 0.002 * biasMult;
|
|
});
|
|
|
|
// 环境光
|
|
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: "0",
|
|
left: "0",
|
|
top: "auto",
|
|
zIndex: "9998",
|
|
});
|
|
document.body.appendChild(stats.dom);
|
|
|
|
// 加载场景
|
|
initGltfLoader();
|
|
const sceneModel = await initGLBScene(modelUrl);
|
|
renderer.render(scene, camera);
|
|
|
|
// 人物控制器
|
|
player = new playerController();
|
|
await player.init({
|
|
scene,
|
|
camera,
|
|
controls,
|
|
playerModelConfig: {
|
|
...(await loadPlayerModelConfig("josh")),
|
|
speed: 150,
|
|
runSpeed: 600,
|
|
},
|
|
initPos: pos,
|
|
minCamDistance: 8,
|
|
maxCamDistance: 300,
|
|
camLookAtHeightRatio: 0.5,
|
|
enableSpringCamera: true,
|
|
springCameraTime: 0.1,
|
|
enableOverShoulderView: true,
|
|
camOverShoulderOffsetRatio: 0,
|
|
enableZoom: true,
|
|
colliders: [
|
|
{ motion: "static", shape: { kind: "mesh", source: sceneModel } },
|
|
],
|
|
});
|
|
bindVehicleHintMode(player);
|
|
attachFootIK();
|
|
|
|
// 先完成角色材质的 CSM 注入,避免等待车辆时用非 CSM shader 提前编译。
|
|
player.getPlayerModel()?.traverse((child) => {
|
|
if (child.isMesh) {
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
setupCSMMaterial(child.material);
|
|
}
|
|
});
|
|
|
|
await spawnSceneSedan();
|
|
|
|
// 创建动态平台
|
|
const liftPlatform = createDynamicPlatform({
|
|
position: new Vector3(22, 2.76, 9.7),
|
|
motion: { axis: "y", distance: 4, speed: 0.5 },
|
|
});
|
|
platform = liftPlatform.mesh;
|
|
platformCloud = liftPlatform.cloud;
|
|
|
|
createDynamicPlatform({
|
|
position: dynamicPlatformXPath[0],
|
|
motion: { axis: "x", distance: 3, speed: 0.1 },
|
|
});
|
|
|
|
// 调试
|
|
initGUI();
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
if (e.code !== "KeyR" || e.repeat) return;
|
|
player.resetVehicle();
|
|
});
|
|
|
|
window.addEventListener("resize", onWindowResize, false);
|
|
|
|
// 关闭加载页面
|
|
window.hideLoader();
|
|
}
|
|
|
|
// 初始化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);
|
|
}
|
|
|
|
// 加载场景
|
|
async function initGLBScene(url, modelScale = [10, 10, 10]) {
|
|
try {
|
|
const gltf = await gltfLoader.loadAsync(url);
|
|
const model = gltf.scene;
|
|
model.name = "sceneGLB";
|
|
model.scale.set(...modelScale);
|
|
model.traverse((child) => {
|
|
if (child.isMesh) {
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
setupCSMMaterial(child.material);
|
|
}
|
|
});
|
|
scene.add(model);
|
|
return model;
|
|
} catch (e) {
|
|
console.error("GLB 加载失败:", e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 更换场景
|
|
async function replaceScene(file) {
|
|
const blobUrl = URL.createObjectURL(file);
|
|
|
|
// 退出指针锁定
|
|
if (document.pointerLockElement) {
|
|
await new Promise((resolve) => {
|
|
document.addEventListener("pointerlockchange", resolve, { once: true });
|
|
document.exitPointerLock();
|
|
});
|
|
}
|
|
|
|
// 移除旧场景并释放 GPU 资源
|
|
const old = scene.getObjectByName("sceneGLB");
|
|
if (old) {
|
|
old.traverse((child) => {
|
|
if (child.isMesh) {
|
|
child.geometry?.dispose();
|
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
mats.forEach((m) => m?.dispose());
|
|
}
|
|
});
|
|
scene.remove(old);
|
|
}
|
|
|
|
// 销毁旧玩家
|
|
removeDynamicPlatforms();
|
|
player?.destroy();
|
|
player = null;
|
|
footIK = null;
|
|
footIKDebugParams = null;
|
|
|
|
// 加载新场景
|
|
await initGLBScene(blobUrl, [1, 1, 1]);
|
|
|
|
// 释放上一个 blob URL,保存新的
|
|
if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl);
|
|
currentBlobUrl = blobUrl;
|
|
|
|
// 进入预览模式
|
|
await enterPreviewMode(guiParams?.playerModel ?? "josh");
|
|
}
|
|
|
|
// 进入预览模式
|
|
async function enterPreviewMode(playerModelKey) {
|
|
previewMode = true;
|
|
|
|
const modelConfig = PLAYER_MODELS[playerModelKey];
|
|
const gltf = await gltfLoader.loadAsync(modelConfig.url);
|
|
previewMesh = gltf.scene;
|
|
previewMesh.scale.setScalar(modelConfig.scale * globalScale);
|
|
previewMesh.visible = false;
|
|
previewMesh.traverse((child) => {
|
|
if (child.isMesh) {
|
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
mats.forEach((m) => {
|
|
m.transparent = true;
|
|
m.opacity = 0.5;
|
|
m.depthWrite = false;
|
|
});
|
|
}
|
|
});
|
|
scene.add(previewMesh);
|
|
|
|
if (!previewHintEl) {
|
|
previewHintEl = document.createElement("div");
|
|
Object.assign(previewHintEl.style, {
|
|
position: "fixed", bottom: "20px", left: "50%",
|
|
transform: "translateX(-50%)", background: "rgba(0,0,0,0.65)",
|
|
color: "#fff", padding: "12px 24px", borderRadius: "8px",
|
|
fontSize: "14px", zIndex: "9999", textAlign: "center",
|
|
display: "flex", flexDirection: "column", alignItems: "center", gap: "8px",
|
|
});
|
|
const tip = document.createElement("span");
|
|
tip.textContent = "移动鼠标预览人物位置 · 双击确认放置";
|
|
const sliderRow = document.createElement("div");
|
|
Object.assign(sliderRow.style, { display: "flex", alignItems: "center", gap: "8px" });
|
|
const label = document.createElement("span");
|
|
label.textContent = "人物比例:";
|
|
// 对数刻度:slider在log10空间线性滑动,0.01~1000 各数量级间距相同
|
|
const slider = document.createElement("input");
|
|
slider.type = "range";
|
|
slider.min = "-2"; // log10(0.01)
|
|
slider.max = "3"; // log10(1000)
|
|
slider.step = "0.01";
|
|
slider.value = String(Math.log10(globalScale));
|
|
slider.style.width = "160px";
|
|
const sliderVal = document.createElement("span");
|
|
sliderVal.textContent = globalScale.toFixed(2);
|
|
slider.addEventListener("input", () => {
|
|
globalScale = Math.pow(10, parseFloat(slider.value));
|
|
sliderVal.textContent = globalScale.toFixed(2);
|
|
if (previewMesh) {
|
|
previewMesh.scale.setScalar(modelConfig.scale * globalScale);
|
|
}
|
|
});
|
|
sliderRow.append(label, slider, sliderVal);
|
|
previewHintEl.append(tip, sliderRow);
|
|
document.body.appendChild(previewHintEl);
|
|
} else {
|
|
// 重置滑块值
|
|
const slider = previewHintEl.querySelector("input[type=range]");
|
|
const sliderVal = previewHintEl.querySelector("span:last-child");
|
|
if (slider) { slider.value = String(Math.log10(globalScale)); }
|
|
if (sliderVal) { sliderVal.textContent = globalScale.toFixed(2); }
|
|
}
|
|
previewHintEl.style.display = "flex";
|
|
|
|
renderer.domElement.addEventListener("mousemove", onPreviewMouseMove);
|
|
renderer.domElement.addEventListener("dblclick", onPreviewDblClick);
|
|
}
|
|
|
|
// 退出预览模式
|
|
function exitPreviewMode() {
|
|
previewMode = false;
|
|
controls.enableZoom = true;
|
|
|
|
if (previewMesh) {
|
|
previewMesh.traverse((child) => {
|
|
if (child.isMesh) {
|
|
child.geometry?.dispose();
|
|
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
|
mats.forEach((m) => m?.dispose());
|
|
}
|
|
});
|
|
scene.remove(previewMesh);
|
|
previewMesh = null;
|
|
}
|
|
|
|
if (previewHintEl) previewHintEl.style.display = "none";
|
|
|
|
renderer.domElement.removeEventListener("mousemove", onPreviewMouseMove);
|
|
renderer.domElement.removeEventListener("dblclick", onPreviewDblClick);
|
|
}
|
|
|
|
// 预览:鼠标移动 → 射线交点跟随
|
|
function onPreviewMouseMove(e) {
|
|
if (!previewMode || !previewMesh) return;
|
|
previewMouse.set(
|
|
(e.clientX / window.innerWidth) * 2 - 1,
|
|
-(e.clientY / window.innerHeight) * 2 + 1,
|
|
);
|
|
previewRaycaster.setFromCamera(previewMouse, camera);
|
|
const sceneModel = scene.getObjectByName("sceneGLB");
|
|
if (!sceneModel) return;
|
|
const hits = previewRaycaster.intersectObject(sceneModel, true);
|
|
if (hits.length > 0) {
|
|
previewMesh.position.copy(hits[0].point);
|
|
previewMesh.visible = true;
|
|
} else {
|
|
previewMesh.visible = false;
|
|
}
|
|
}
|
|
|
|
// 预览:双击 → 确认位置,初始化玩家
|
|
async function onPreviewDblClick() {
|
|
if (!previewMode || !previewMesh?.visible) return;
|
|
const initPos = previewMesh.position.clone();
|
|
const spawnKey = guiParams?.playerModel ?? "josh";
|
|
const spawnScale = getModelScale(spawnKey);
|
|
initPos.y += 180 * spawnScale * 0.75;
|
|
exitPreviewMode();
|
|
|
|
recreateCSM(globalScale);
|
|
|
|
player = new playerController();
|
|
const sceneModel = scene.getObjectByName("sceneGLB");
|
|
await player.init({
|
|
scene,
|
|
camera,
|
|
controls,
|
|
playerModelConfig: {
|
|
...(await loadPlayerModelConfig(spawnKey)),
|
|
speed: guiParams?.playerSpeed ?? 150,
|
|
runSpeed: guiParams?.playerRunSpeed ?? 600,
|
|
},
|
|
initPos,
|
|
minCamDistance: guiParams?.minCamDistance ?? 8,
|
|
maxCamDistance: guiParams?.maxCamDistance ?? 300,
|
|
camLookAtHeightRatio: guiParams?.camLookAtHeightRatio ?? 0.5,
|
|
enableSpringCamera: guiParams?.enableSpringCamera ?? true,
|
|
springCameraTime: guiParams?.springCameraTime ?? 0.1,
|
|
enableOverShoulderView: guiParams?.enableOverShoulderView ?? true,
|
|
camOverShoulderOffsetRatio: guiParams?.camOverShoulderOffsetRatio ?? 0,
|
|
thirdMouseMode: guiParams?.thirdMouseMode ?? 1,
|
|
enableZoom: guiParams?.enableZoom ?? true,
|
|
colliders: [
|
|
{ motion: "static", shape: { kind: "mesh", source: sceneModel } },
|
|
],
|
|
});
|
|
bindVehicleHintMode(player);
|
|
attachFootIK();
|
|
|
|
player.getPlayerModel()?.traverse((child) => {
|
|
if (child.isMesh) {
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
setupCSMMaterial(child.material);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 每帧调用
|
|
function animate(timestamp) {
|
|
animTimer.update(timestamp);
|
|
|
|
if (player) {
|
|
player.update();
|
|
} else {
|
|
controls.update();
|
|
}
|
|
|
|
updateDynamicPlatforms();
|
|
|
|
csm?.update();
|
|
updateFootIKDebugPanel();
|
|
|
|
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);
|
|
}
|
|
|
|
// 调试
|
|
function applyVehicleTuning() {
|
|
const t = VEHICLE_TUNING;
|
|
const vehicles = player?.getAllVehicles?.() ?? [];
|
|
for (const v of vehicles) {
|
|
player?.setVehicleChassisSizeScale?.(v, t.chassis.sizeScale);
|
|
player?.setVehicleClearance?.(v, t.chassis.clearance);
|
|
const he = v.halfExtents;
|
|
const volume = 8 * he.x * he.y * he.z;
|
|
v.chassisBody.setMass(volume * Math.max(1e-8, t.chassis.density));
|
|
v.chassisBody.linearDamping = t.chassis.linearDamping;
|
|
v.chassisBody.angularDamping = t.chassis.angularDamping;
|
|
|
|
Object.assign(v.steering, t.steering);
|
|
Object.assign(v.grip, t.grip);
|
|
v.maxSpeed = t.power.maxSpeed * v.scale;
|
|
v.acceleration = t.power.acceleration * v.scale;
|
|
v.deceleration = t.power.deceleration * v.scale;
|
|
v.followVehicleDirection = t.followVehicleDirection;
|
|
|
|
const sus = t.suspension;
|
|
const staticSag = 9.81 * v.scale / (4 * Math.max(1e-4, sus.stiffness));
|
|
const rest = Math.max(sus.restLength * v.scale, staticSag * 1.2);
|
|
v.sideFrictionStiffness = sus.sideFrictionStiffness;
|
|
|
|
const wheelCount = v.vehicleController.numWheels();
|
|
for (let i = 0; i < wheelCount; i++) {
|
|
v.vehicleController.setWheelSuspensionRestLength(i, rest);
|
|
v.vehicleController.setWheelMaxSuspensionTravel(i, sus.maxTravel * v.scale);
|
|
v.vehicleController.setWheelSuspensionStiffness(i, sus.stiffness);
|
|
v.vehicleController.setWheelSuspensionCompression(i, sus.compression);
|
|
v.vehicleController.setWheelSuspensionRelaxation(i, sus.relaxation);
|
|
v.vehicleController.setWheelMaxSuspensionForce(i, sus.maxForce);
|
|
v.vehicleController.setWheelFrictionSlip(i, sus.frictionSlip);
|
|
v.vehicleController.setWheelSideFrictionStiffness(i, sus.sideFrictionStiffness);
|
|
v.vehicleController.setWheelRollInfluence(i, sus.rollInfluence);
|
|
}
|
|
|
|
if (v.physicsBoxMesh) {
|
|
if (t.debug.showPhysicsBox) v.vehicleGroup.add(v.physicsBoxMesh);
|
|
else v.vehicleGroup.remove(v.physicsBoxMesh);
|
|
}
|
|
if (v.wheelRayDebug) v.wheelRayDebug.visible = t.debug.showWheelRays;
|
|
if (v.wheelTravelDebug) v.wheelTravelDebug.visible = t.debug.showWheelTravel;
|
|
if (v.wheelSphereDebug) v.wheelSphereDebug.visible = t.debug.showWheelSpheres;
|
|
}
|
|
if (player?.vehicle?.params?.debug) {
|
|
Object.assign(player.vehicle.params.debug, t.debug);
|
|
}
|
|
}
|
|
|
|
function initGUI() {
|
|
const options = footIK.getOptions();
|
|
const playerScale = player.playerModelConfig.scale;
|
|
const toBasePlayerValue = (value) => value / playerScale;
|
|
const params = {
|
|
playerModel: "josh",
|
|
showShadow: renderer.shadowMap.enabled,
|
|
colliderDebug: false,
|
|
playerCapsuleDebug: false,
|
|
mouseSensitivity: player.cam.sensitivity,
|
|
gravity: toBasePlayerValue(player.gravity),
|
|
jumpHeight: toBasePlayerValue(player.jumpHeight),
|
|
playerSpeed: toBasePlayerValue(player.playerSpeed),
|
|
playerRunSpeed: toBasePlayerValue(player.playerRunSpeed),
|
|
flySpeed: toBasePlayerValue(player.playerFlySpeed),
|
|
playerAcceleration: player.playerAcceleration,
|
|
playerDeceleration: player.playerDeceleration,
|
|
timeScale: player.timeScale,
|
|
minCamDistance: toBasePlayerValue(player.cam.minDist),
|
|
maxCamDistance: toBasePlayerValue(player.cam.maxDist),
|
|
camLookAtHeightRatio: player.cam.lookAtHeightRatio,
|
|
enableOverShoulderView: player.enableOverShoulderView,
|
|
camOverShoulderOffsetRatio: player.cam.overShoulderOffsetRatio,
|
|
enableSpringCamera: player.cam.enableSpringCamera,
|
|
springCameraTime: player.cam.springCameraTime,
|
|
thirdMouseMode: player.cam.mouseMode,
|
|
enableZoom: player.cam.zoomEnabled,
|
|
footIKEnabled: options.enabled,
|
|
footIKDebug: options.debug,
|
|
straightPoleEnabled: options.straightPoleEnabled,
|
|
leftFootPhase: "",
|
|
leftFootLandIK: "-- / 0.00",
|
|
rightFootPhase: "",
|
|
rightFootLandIK: "-- / 0.00",
|
|
maxPelvisRaise: options.maxPelvisRaise,
|
|
maxPelvisDrop: options.maxPelvisDrop,
|
|
maxFootRaise: options.maxFootRaise,
|
|
maxFootDrop: options.maxFootDrop,
|
|
soleHalfWidth: options.soleHalfWidth,
|
|
soleToeExtend: options.soleToeExtend,
|
|
soleHeelExtend: options.soleHeelExtend,
|
|
soleSkinThickness: options.soleSkinThickness,
|
|
maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI,
|
|
};
|
|
guiParams = params;
|
|
footIKDebugParams = params;
|
|
|
|
const applyFootIKOptions = (options) => footIK?.configure(options);
|
|
|
|
gui = new GUI({ title: "Showcase 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", (event) => event.stopPropagation());
|
|
|
|
const sceneFolder = gui.addFolder("Scene");
|
|
sceneFolder.add(params, "playerModel", Object.keys(PLAYER_MODELS))
|
|
.name("Character Model")
|
|
.onChange(async (modelKey) => {
|
|
await player?.switchPlayerModel(await loadPlayerModelConfig(modelKey));
|
|
player?.getPlayerModel()?.traverse((child) => {
|
|
if (!child.isMesh) return;
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
setupCSMMaterial(child.material);
|
|
if (modelKey !== "ual") return;
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
materials.forEach((material) => {
|
|
if (!material) return;
|
|
material.metalness = 0.8;
|
|
material.roughness = 0;
|
|
});
|
|
});
|
|
});
|
|
sceneFolder.add(params, "showShadow").name("Shadows").onChange((value) => {
|
|
renderer.shadowMap.enabled = value;
|
|
scene.traverse((child) => {
|
|
if (!child.isMesh) return;
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
materials.forEach((material) => {
|
|
if (material) material.needsUpdate = true;
|
|
});
|
|
});
|
|
});
|
|
sceneFolder.open();
|
|
|
|
const collisionFolder = gui.addFolder("Collision Debug");
|
|
collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => {
|
|
player?.setColliderDebug(value);
|
|
});
|
|
collisionFolder.open();
|
|
|
|
const characterFolder = gui.addFolder("Character");
|
|
characterFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => {
|
|
player?.setPlayerCapsuleDebug?.(value);
|
|
});
|
|
|
|
const movementFolder = characterFolder.addFolder("Movement");
|
|
movementFolder.add(params, "gravity", -6000, 0, 50).name("Gravity").decimals(0).onChange((value) => player?.setGravity(value));
|
|
movementFolder.add(params, "jumpHeight", 0, 2000, 10).name("Jump Height").decimals(0).onChange((value) => player?.setJumpHeight(value));
|
|
movementFolder.add(params, "playerSpeed", 0, 20000, 10).name("Walk Speed").decimals(0).onChange((value) => player?.setPlayerSpeed(value));
|
|
movementFolder.add(params, "playerRunSpeed", 0, 20000, 10).name("Run Speed").decimals(0).onChange((value) => player?.setPlayerRunSpeed(value));
|
|
movementFolder.add(params, "flySpeed", 0, 20000, 10).name("Fly Speed").decimals(0).onChange((value) => player?.setPlayerFlySpeed(value));
|
|
movementFolder.add(params, "playerAcceleration", 1, 20000, 1).name("Acceleration").decimals(0).onChange((value) => {
|
|
if (player) player.playerAcceleration = value;
|
|
});
|
|
movementFolder.add(params, "playerDeceleration", 1, 20000, 1).name("Deceleration").decimals(0).onChange((value) => {
|
|
if (player) player.playerDeceleration = value;
|
|
});
|
|
movementFolder.add(params, "timeScale", 0, 3, 0.05).name("Time Scale").decimals(2).onChange((value) => {
|
|
if (player) player.timeScale = value;
|
|
});
|
|
movementFolder.close();
|
|
|
|
const characterCameraFolder = characterFolder.addFolder("Camera");
|
|
characterCameraFolder.add(params, "mouseSensitivity", 1, 20, 0.1).name("Mouse Sensitivity").decimals(1).onChange((value) => player?.setMouseSensitivity(value));
|
|
characterCameraFolder.add(params, "minCamDistance", 0, 200, 1).name("Min Distance").decimals(0).onChange((value) => player?.setMinCamDistance(value));
|
|
characterCameraFolder.add(params, "maxCamDistance", 50, 1000, 1).name("Max Distance").decimals(0).onChange((value) => player?.setMaxCamDistance(value));
|
|
characterCameraFolder.add(params, "camLookAtHeightRatio", 0, 1, 0.01).name("Look-at Height Ratio").decimals(2).onChange((value) => player?.setCamLookAtHeightRatio(value));
|
|
characterCameraFolder.add(params, "enableOverShoulderView").name("Over-Shoulder View").onChange((value) => player?.setOverShoulderView(value));
|
|
characterCameraFolder.add(params, "camOverShoulderOffsetRatio", -0.2, 0.2, 0.01).name("Over-Shoulder Offset").decimals(2).onChange((value) => player?.setCamOverShoulderOffsetRatio(value));
|
|
characterCameraFolder.add(params, "enableSpringCamera").name("Spring Camera").onChange((value) => {
|
|
if (player) player.cam.enableSpringCamera = value;
|
|
});
|
|
characterCameraFolder.add(params, "springCameraTime", 0.01, 1, 0.01).name("Spring Time").decimals(2).onChange((value) => {
|
|
if (player) player.cam.springCameraTime = value;
|
|
});
|
|
characterCameraFolder.add(params, "thirdMouseMode", { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 }).name("Mouse Mode").onChange((value) => player?.setThirdMouseMode(Number(value)));
|
|
characterCameraFolder.add(params, "enableZoom").name("Enable Zoom").onChange((value) => player?.setEnableZoom(value));
|
|
characterCameraFolder.close();
|
|
|
|
const footIKFolder = characterFolder.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, "straightPoleEnabled").name("Straight Pole").onChange((value) => {
|
|
applyFootIKOptions({ straightPoleEnabled: value });
|
|
});
|
|
|
|
const footIKRuntimeFolder = footIKFolder.addFolder("Runtime");
|
|
footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable();
|
|
footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable();
|
|
footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable();
|
|
footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").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 }));
|
|
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.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();
|
|
footIKFolder.close();
|
|
characterFolder.open();
|
|
|
|
const t = VEHICLE_TUNING;
|
|
const vehicleFolder = gui.addFolder("Vehicle");
|
|
|
|
const chassisFolder = vehicleFolder.addFolder("Chassis");
|
|
chassisFolder.add(t.chassis, "clearance", 0, 2, 0.05).name("Clearance").decimals(2).onChange(applyVehicleTuning);
|
|
chassisFolder.add(t.chassis.sizeScale, "x", 0.1, 1.5, 0.05).name("Size Scale X").decimals(2).onChange(applyVehicleTuning);
|
|
chassisFolder.add(t.chassis.sizeScale, "y", 0.1, 1.5, 0.05).name("Size Scale Y").decimals(2).onChange(applyVehicleTuning);
|
|
chassisFolder.add(t.chassis.sizeScale, "z", 0.1, 1.5, 0.05).name("Size Scale Z").decimals(2).onChange(applyVehicleTuning);
|
|
chassisFolder.close();
|
|
|
|
const vehicleCameraFolder = vehicleFolder.addFolder("Camera");
|
|
vehicleCameraFolder.add(t, "followVehicleDirection").name("Follow Vehicle Heading").onChange(applyVehicleTuning);
|
|
vehicleCameraFolder.close();
|
|
|
|
const vehicleDebugFolder = vehicleFolder.addFolder("Debug");
|
|
vehicleDebugFolder.add(t.debug, "showPhysicsBox").name("Chassis Collider").onChange(applyVehicleTuning);
|
|
vehicleDebugFolder.add(t.debug, "showWheelRays").name("Wheel Rays").onChange(applyVehicleTuning);
|
|
vehicleDebugFolder.add(t.debug, "showWheelTravel").name("Suspension Travel").onChange(applyVehicleTuning);
|
|
vehicleDebugFolder.add(t.debug, "showWheelSpheres").name("Wheel Colliders").onChange(applyVehicleTuning);
|
|
vehicleDebugFolder.close();
|
|
|
|
const drivingFolder = vehicleFolder.addFolder("Driving");
|
|
drivingFolder.add(t.power, "maxSpeed", 0, 200, 1).name("Max Speed").onChange(applyVehicleTuning);
|
|
drivingFolder.add(t.power, "acceleration", 0, 10, 0.1).name("Acceleration").onChange(applyVehicleTuning);
|
|
drivingFolder.add(t.power, "deceleration", 0, 10, 0.1).name("Braking Deceleration").onChange(applyVehicleTuning);
|
|
drivingFolder.close();
|
|
|
|
const handlingFolder = vehicleFolder.addFolder("Handling");
|
|
handlingFolder.add(params, "maxSteerDeg", 5, 60, 0.5).name("Max Steering Angle (deg)").onChange((degrees) => {
|
|
t.steering.maxSteerAngle = degrees * Math.PI / 180;
|
|
applyVehicleTuning();
|
|
});
|
|
handlingFolder.add(t.steering, "steerTime", 0.1, 1.5, 0.05).name("Steering Response Time").decimals(2).onChange(applyVehicleTuning);
|
|
handlingFolder.add(t.steering, "highSpeedSteerScale", 0.1, 1, 0.01).name("High-Speed Steering Scale").decimals(2).onChange(applyVehicleTuning);
|
|
handlingFolder.add(t.grip, "maxG", 0.1, 3, 0.05).name("Max Lateral G").onChange(applyVehicleTuning);
|
|
handlingFolder.close();
|
|
|
|
const suspensionFolder = vehicleFolder.addFolder("Suspension");
|
|
suspensionFolder.add(t.suspension, "restLength", 0.05, 0.5, 0.01).name("Rest Length").decimals(2).onChange(applyVehicleTuning);
|
|
suspensionFolder.add(t.suspension, "maxTravel", 0.05, 0.5, 0.01).name("Max Travel").decimals(2).onChange(applyVehicleTuning);
|
|
suspensionFolder.add(t.suspension, "stiffness", 0.1, 50, 0.1).name("Stiffness").decimals(1).onChange(applyVehicleTuning);
|
|
suspensionFolder.close();
|
|
|
|
vehicleFolder.open();
|
|
}
|
|
|
|
function updateFootIKDebugPanel() {
|
|
if (!footIK || !footIKDebugParams) return;
|
|
footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left");
|
|
footIKDebugParams.leftFootLandIK = formatFootLandIK(
|
|
footIK.getFootTimeToLand("left"),
|
|
footIK.getFootIKWeight("left"),
|
|
);
|
|
footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right");
|
|
footIKDebugParams.rightFootLandIK = formatFootLandIK(
|
|
footIK.getFootTimeToLand("right"),
|
|
footIK.getFootIKWeight("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}`;
|
|
}
|