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>
2463 lines
90 KiB
JavaScript
2463 lines
90 KiB
JavaScript
import {
|
||
ACESFilmicToneMapping,
|
||
AmbientLight,
|
||
AnimationClip,
|
||
AnimationMixer,
|
||
Box3,
|
||
BoxGeometry,
|
||
BufferGeometry,
|
||
Timer,
|
||
Color,
|
||
VectorKeyframeTrack,
|
||
CylinderGeometry,
|
||
DirectionalLight,
|
||
DoubleSide,
|
||
ExtrudeGeometry,
|
||
Float32BufferAttribute,
|
||
Group,
|
||
Matrix4,
|
||
Mesh,
|
||
MeshStandardMaterial,
|
||
PerspectiveCamera,
|
||
PlaneGeometry,
|
||
Quaternion,
|
||
QuaternionKeyframeTrack,
|
||
RepeatWrapping,
|
||
ShaderMaterial,
|
||
Shape,
|
||
SphereGeometry,
|
||
SRGBColorSpace,
|
||
Scene,
|
||
TextureLoader,
|
||
VSMShadowMap,
|
||
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 { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.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";
|
||
import { bindVehicleHintMode } from "./control-hints.js";
|
||
|
||
const TILE_SIZE = 1;
|
||
const SLOPE_ANGLES = [20, 30, 40];
|
||
|
||
const DECK_Y = -0.06;
|
||
const DECK_THICK = 0.12;
|
||
|
||
// 车辆停放(仅大场景)
|
||
const VEHICLE_SPAWNS = [
|
||
new Vector3(-1, 0, -11),
|
||
new Vector3(-1, 0, 11),
|
||
];
|
||
|
||
// 车辆调参(加载与 GUI 共用)
|
||
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.8,
|
||
sizeScale: { x: 0.9, y: 0.55, 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 OBSTACLE_X0 = -9;
|
||
const OBSTACLE_COUNT = 6;
|
||
const OBSTACLE_SPACING = 1.8;
|
||
|
||
const PROP_SPHERE_R = 0.2;
|
||
const ACCENT_GRAY = 0xc8c8c8;
|
||
const PROP_GRID = 4;
|
||
const PROP_BOX_SIZE = 0.38;
|
||
const PROP_ACCENT_COLOR = 0x7eb0d4;
|
||
|
||
const KINE_SHUTTLE_SPEED = 0.9;
|
||
const KINE_SPIN_SPEED = 0.35;
|
||
const STATIC_STRIP_COUNT = 6;
|
||
const STATIC_STRIP_GAP_RADIUS_RATIO = 0.5;
|
||
const STATIC_STRIP_DEPTH_RADIUS_RATIO = 3;
|
||
|
||
const MINI_SCALE = 0.1; // 小场景相对大场景的比例
|
||
const PLAYER_SCALE_NORMAL = 0.005; // 大场景人物缩放
|
||
const VEHICLE_SCALE_NORMAL = 0.5; // 大场景车辆缩放
|
||
|
||
// 东侧巡逻甲板
|
||
const PATROL_GAP_WIDTH = 6; // 东墙豁口宽度
|
||
const PATROL_BRIDGE_LEN = 2; // 豁口外栈桥进深
|
||
const PATROL_CLIMB_ANGLE = 20; // 上甲板坡度
|
||
const PATROL_STEP_COUNT = 7;
|
||
const PATROL_DECK_LEN = 12; // 甲板 X 向长度
|
||
const PATROL_DECK_WIDTH = 18; // 甲板 Z 向宽度
|
||
const PATROL_NPC_COUNT = 6;
|
||
const PATROL_LANE_GAP = 3; // 巡逻道间距
|
||
const PATROL_END_MARGIN = 0.85; // 巡逻线距甲板两端留白
|
||
const PATROL_TURN_TIME = 0.45; // 端点转身时长
|
||
const PATROL_LANE_MARK_WIDTH = 0.12;
|
||
const PATROL_WALK_SPEED_RAW = 100; // 回退值:与玩家 playerModelConfig.speed 一致
|
||
|
||
// 可切换角色与发型(Universal Base Characters,CC0)
|
||
// 三者共用同一套 65 骨骼,动画恒取自 ual.glb,无需重定向
|
||
const CHARACTER_DIR = "./glb/characters/";
|
||
const HAIR_NONE = "None";
|
||
const CHARACTERS = [
|
||
{ name: "Mannequin", url: "./glb/ual.glb", heightFactor: 1 },
|
||
{ name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1 },
|
||
// 成年女性平均身高约为男性的 93%;模型自带的 97.5% 差异过小,几乎看不出
|
||
{ name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93 },
|
||
];
|
||
// 手臂姿态微调(角度制):hang 让手臂更垂,straighten 让肘部更直
|
||
const ARM_ADJUST_DEFAULTS = {
|
||
"Mannequin": { hang: 0, straighten: 0 },
|
||
"Superhero Male": { hang: 0, straighten: 0 },
|
||
"Superhero Female": { hang: 0, straighten: 0 },
|
||
};
|
||
const ARM_SIDES = [
|
||
{ upper: "upperarm_l", lower: "lowerarm_l", hand: "hand_l" },
|
||
{ upper: "upperarm_r", lower: "lowerarm_r", hand: "hand_r" },
|
||
];
|
||
const ARM_DOWN = new Vector3(0, -1, 0);
|
||
// 量躯干宽度时只取这些骨骼的顶点,避免 T-Pose 的手臂把宽度撑大
|
||
const HITBOX_TORSO_BONES = ["pelvis", "spine_01", "spine_02", "spine_03"];
|
||
// 控制器默认 playerCapsuleHeight / playerCapsuleRadius = 180 / 30
|
||
const CAPSULE_HEIGHT_TO_RADIUS = 6;
|
||
const HAIRSTYLES = [
|
||
"Hair_Buzzed",
|
||
"Hair_BuzzedFemale",
|
||
"Hair_SimpleParted",
|
||
"Hair_Long",
|
||
"Hair_Buns",
|
||
"Hair_Beard",
|
||
];
|
||
const DEFAULT_CHARACTER = "Superhero Male";
|
||
|
||
// 与网格无关的玩家模型配置,init 与角色切换共用
|
||
const PLAYER_MODEL_BASE = {
|
||
scale: PLAYER_SCALE_NORMAL,
|
||
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",
|
||
firstPersonCameraOffset: [0, 0.15, 0.12],
|
||
rotateY: -Math.PI / 2,
|
||
speed: 100,
|
||
runSpeed: 600,
|
||
};
|
||
// 梯形连接段长度:相对宽端开口宽度(宽矩形 / 收腰)
|
||
const TRAP_WIDE_LEN_RATIO = 0.45;
|
||
const TRAP_SLOPE_LEN_RATIO = 0.4;
|
||
// 收腰段:青通道 tint
|
||
const TRAP_TINT_COLOR = 0x88c8e0;
|
||
|
||
// 按比例生成场景布局
|
||
function createShowcaseLayout(scale = 1) {
|
||
const s = scale;
|
||
return {
|
||
scale: s,
|
||
platform: { x: -5 * s, z: 0, sizeX: 30 * s, sizeZ: 28 * s },
|
||
wallH: 2.0 * s,
|
||
wallT: 0.35 * s,
|
||
deckY: DECK_Y * s,
|
||
deckThick: DECK_THICK * s,
|
||
l1Top: 1.2 * s,
|
||
obstacleX0: OBSTACLE_X0 * s,
|
||
obstacleCount: OBSTACLE_COUNT,
|
||
obstacleSpacing: OBSTACLE_SPACING * s,
|
||
obstacleZ: 3.2 * s,
|
||
obstacleCylR: 0.15 * s,
|
||
obstacleCylLen: 2.5 * s,
|
||
obstacleBoxSize: new Vector3(0.25 * s, 0.275 * s, 2.5 * s),
|
||
stairWidth: 1 * s,
|
||
rampWidth: 1 * s,
|
||
kineThickness: 0.12 * s,
|
||
kineTopOffset: 0.4 * s,
|
||
tileSize: TILE_SIZE * s,
|
||
};
|
||
}
|
||
|
||
// 染色材质
|
||
function tintMaterial(baseMat, hex) {
|
||
const mat = baseMat.clone();
|
||
mat.color?.setHex?.(hex);
|
||
return mat;
|
||
}
|
||
|
||
const scene = new Scene();
|
||
const animTimer = new Timer();
|
||
let scaledAnimTime = 0;
|
||
|
||
let camera;
|
||
let renderer;
|
||
let controls;
|
||
let player;
|
||
let footIK;
|
||
let footIKDebugParams;
|
||
let stats;
|
||
const kinematicPlatforms = [];
|
||
const patrolNPCs = [];
|
||
let gltfLoader = null;
|
||
const gltfCache = new Map();
|
||
let playerAnimations = null; // ual.glb 的 53 个动画,独立于角色网格
|
||
let currentCharacterName = DEFAULT_CHARACTER;
|
||
let currentHairName = HAIR_NONE;
|
||
let currentHairMesh = null;
|
||
let characterBusy = false;
|
||
let currentHeightFactor = 1;
|
||
let currentHitboxScale = 1;
|
||
const characterHeights = new Map(CHARACTERS.map((c) => [c.name, c.heightFactor]));
|
||
let syncCharacterGui = null;
|
||
let sourceRestPose = null; // ual.glb 的静止姿态,用于重定基准
|
||
const clipCache = new Map();
|
||
const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quaternion() };
|
||
let armChains = null;
|
||
const armAdjust = { hang: 0, straighten: 0 };
|
||
const characterArms = new Map(Object.entries(ARM_ADJUST_DEFAULTS).map(([k, v]) => [k, { ...v }]));
|
||
const armTmp = {
|
||
a: new Vector3(), b: new Vector3(), c: new Vector3(),
|
||
dir: new Vector3(), fore: new Vector3(), axis: new Vector3(),
|
||
qWorld: new Quaternion(), qParent: new Quaternion(), qLocal: new Quaternion(),
|
||
};
|
||
let trapScaleRange = null; // 收腰段:wideWestX 处 scale=1,westX 处 scale=0.1
|
||
const glowPortals = [];
|
||
let zoneScale = 1; // 当前场景总缩放(1 → MINI_SCALE)
|
||
const scaleGateWorldPos = new Vector3(); // 缩放判定用的世界坐标缓冲
|
||
|
||
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 = 1.05;
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = VSMShadowMap;
|
||
renderer.setAnimationLoop(animate);
|
||
container.appendChild(renderer.domElement);
|
||
animTimer.connect(document);
|
||
|
||
// 帧率
|
||
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, 800);
|
||
camera.position.set(14, 10, 10);
|
||
|
||
// 控制器
|
||
controls = new MapControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.maxDistance = 260;
|
||
controls.maxPolarAngle = Math.PI / 2.05;
|
||
controls.target.set(0, 1, 0);
|
||
|
||
// 环境光
|
||
scene.add(new AmbientLight(0xffffff, 1.5));
|
||
|
||
// 平行光
|
||
const sun = new DirectionalLight(0xffffff, 2);
|
||
sun.position.set(16, 48, 24);
|
||
sun.castShadow = true;
|
||
sun.shadow.mapSize.set(2048, 2048);
|
||
sun.shadow.camera.near = 0.5;
|
||
sun.shadow.camera.far = 220;
|
||
sun.shadow.camera.left = -90;
|
||
sun.shadow.camera.right = 44; // 覆盖东侧巡逻甲板
|
||
sun.shadow.camera.top = 70;
|
||
sun.shadow.camera.bottom = -70;
|
||
sun.shadow.bias = -0.0005;
|
||
scene.add(sun);
|
||
|
||
const 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.001;
|
||
skyUniforms["mieDirectionalG"].value = 0.99;
|
||
skyUniforms["sunPosition"].value.copy(sun.position.clone().normalize());
|
||
|
||
// 测试场景
|
||
const prototypeMat = await loadTiledMaterial("./textures/showcase/prototype.png");
|
||
const mainLayout = createShowcaseLayout();
|
||
const miniLayout = createShowcaseLayout(MINI_SCALE);
|
||
// 矩形西墙全开,开口贴合南北墙外缘,与梯形宽端相接
|
||
const mainWestOpening = {
|
||
zCenter: 0,
|
||
width: mainLayout.platform.sizeZ + 2 * mainLayout.wallT,
|
||
};
|
||
// 小场景南北墙外缘宽度:梯形窄端西口与小矩形东侧共用
|
||
const miniOpening = {
|
||
zCenter: 0,
|
||
width: miniLayout.platform.sizeZ + 2 * miniLayout.wallT,
|
||
};
|
||
|
||
const world = new Group();
|
||
world.name = "ShowcaseWorld";
|
||
const mainWorld = buildShowcaseWorld(prototypeMat, mainLayout, {
|
||
westGap: mainWestOpening,
|
||
eastGap: { zCenter: 0, width: PATROL_GAP_WIDTH },
|
||
});
|
||
mainWorld.name = "MainWorld";
|
||
world.add(mainWorld);
|
||
|
||
const mainWestX = mainLayout.platform.x - mainLayout.platform.sizeX * 0.5;
|
||
const trapLayout = createTrapezoidLayout(
|
||
mainWestX,
|
||
mainLayout.platform.sizeZ,
|
||
miniLayout.platform.sizeZ,
|
||
);
|
||
const trapWorld = buildTrapezoidArena(prototypeMat, mainLayout, trapLayout, miniLayout.wallH);
|
||
trapWorld.name = "TrapezoidWorld";
|
||
world.add(trapWorld);
|
||
trapScaleRange = { eastX: trapLayout.wideWestX, westX: trapLayout.westX };
|
||
|
||
const miniWorld = buildShowcaseWorld(prototypeMat, miniLayout, {
|
||
omitEast: true,
|
||
});
|
||
miniWorld.name = "MiniWorld";
|
||
const miniLocalEast = miniLayout.platform.x + miniLayout.platform.sizeX * 0.5;
|
||
// 小场景台面东缘对齐梯形窄端西口
|
||
miniWorld.position.set(trapLayout.westX - miniLocalEast, 0, 0);
|
||
world.add(miniWorld);
|
||
|
||
// 东侧巡逻甲板:并入 world,复用静态碰撞源
|
||
const patrol = buildPatrolDeck(prototypeMat, mainLayout);
|
||
world.add(patrol.root);
|
||
scene.add(patrol.decor);
|
||
scene.add(world);
|
||
|
||
// 豁口发光门帘:宽矩形/收腰交界 + 梯形西口(进入小场景)
|
||
const slopeEastOpening = { zCenter: 0, width: trapLayout.halfWide * 2 };
|
||
glowPortals.push(
|
||
createGlowGapPortal(mainLayout, slopeEastOpening, trapLayout.wideWestX),
|
||
createGlowGapPortal(miniLayout, miniOpening, trapLayout.westX),
|
||
);
|
||
for (const portal of glowPortals) scene.add(portal);
|
||
|
||
// 加载动画库与初始角色:动画恒取自 ual.glb,网格来自所选角色
|
||
gltfLoader = new GLTFLoader();
|
||
const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb");
|
||
gltfCache.set(CHARACTERS[0].url, Promise.resolve(playerGltf));
|
||
playerAnimations = playerGltf.animations;
|
||
sourceRestPose = collectRestPose(playerGltf.scene);
|
||
const startEntry = CHARACTERS.find((c) => c.name === DEFAULT_CHARACTER) ?? CHARACTERS[0];
|
||
const startGltf = await loadGltfCached(startEntry.url);
|
||
currentCharacterName = startEntry.name;
|
||
currentHeightFactor = characterHeights.get(startEntry.name) ?? 1;
|
||
const startModel = cloneSkeleton(startGltf.scene);
|
||
const startRadiusRatio = measureTorsoRadiusRatio(startModel) * currentHitboxScale;
|
||
|
||
// 初始化玩家控制器
|
||
player = new playerController();
|
||
await player.init({
|
||
scene,
|
||
camera,
|
||
controls,
|
||
colliders: [
|
||
{ motion: "static", shape: { kind: "mesh", source: world } },
|
||
],
|
||
initPos: new Vector3(6.5, 0.4, 0.5),
|
||
minCamDistance: 8,
|
||
maxCamDistance: 300,
|
||
camLookAtHeightRatio: 0.5,
|
||
enableOverShoulderView: true,
|
||
camOverShoulderOffsetRatio: 0,
|
||
enableSpringCamera: true,
|
||
springCameraTime: 0.1,
|
||
enableZoom: true,
|
||
playerModelConfig: {
|
||
...PLAYER_MODEL_BASE,
|
||
model: startModel,
|
||
animations: clipsForModel(startEntry.name, startModel),
|
||
scale: playerScaleForZone(),
|
||
capsuleRadiusRatio: startRadiusRatio,
|
||
},
|
||
});
|
||
Object.assign(armAdjust, characterArms.get(currentCharacterName) ?? { hang: 0, straighten: 0 });
|
||
armChains = resolveArmChains(player.playerModel);
|
||
bindVehicleHintMode(player);
|
||
enableModelShadows(player.playerModel);
|
||
const staticStripBridge = createStaticStripPlatforms(prototypeMat, mainLayout, scene, {
|
||
centerX: getBalancedKinematicMeetCenterX(mainLayout, trapLayout.wideWestX),
|
||
});
|
||
const miniStripBridge = createStaticStripPlatforms(prototypeMat, miniLayout, miniWorld, {
|
||
centerX: getBalancedKinematicMeetCenterX(miniLayout),
|
||
});
|
||
|
||
// 初始化脚部 IK
|
||
footIK = new FootIK({
|
||
enabled: true,
|
||
soleHalfWidth: 4,
|
||
soleToeExtend: 4,
|
||
soleSkinThickness: 1.6,
|
||
plantedHeightSpeed: 200,
|
||
penetrationLiftSpeed: 200,
|
||
predictivePlacement: true,
|
||
straightPoleEnabled: true,
|
||
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",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
player.use(footIK);
|
||
|
||
await spawnShowcaseVehicles(gltfLoader);
|
||
spawnPatrolNPCs(playerGltf, patrol);
|
||
spawnVehiclePropBodies(prototypeMat, mainLayout);
|
||
spawnLayoutPropBodies(prototypeMat, miniLayout, miniWorld.position);
|
||
createKinematicPlatforms(prototypeMat, mainLayout, scene, {
|
||
meetCenterX: staticStripBridge.centerX,
|
||
middleStripSpan: staticStripBridge.span,
|
||
slopeMeetCenterX: trapLayout.wideWestX,
|
||
slopeWestX: trapLayout.westX,
|
||
});
|
||
createKinematicPlatforms(prototypeMat, miniLayout, miniWorld, {
|
||
meetCenterX: miniStripBridge.centerX,
|
||
middleStripSpan: miniStripBridge.span,
|
||
});
|
||
|
||
createDebugPanel();
|
||
|
||
window.addEventListener("resize", onResize);
|
||
window.addEventListener("keydown", (e) => {
|
||
if (e.code !== "KeyR" || e.repeat) return;
|
||
player?.resetVehicle();
|
||
});
|
||
window["hideLoader"]?.();
|
||
}
|
||
|
||
// 加载 tiling 纹理材质
|
||
async function loadTiledMaterial(url) {
|
||
const texture = await new TextureLoader().loadAsync(url);
|
||
texture.colorSpace = SRGBColorSpace;
|
||
texture.wrapS = RepeatWrapping;
|
||
texture.wrapT = RepeatWrapping;
|
||
texture.repeat.set(1, 1);
|
||
texture.anisotropy = 8;
|
||
return new MeshStandardMaterial({
|
||
map: texture,
|
||
roughness: 0.85,
|
||
metalness: 0.05,
|
||
});
|
||
}
|
||
|
||
// 按世界尺度写入 UV
|
||
function applyWorldTileUV(geometry, origin = { x: 0, y: 0, z: 0 }, tileSize = TILE_SIZE) {
|
||
const flat = geometry.index ? geometry.toNonIndexed() : geometry;
|
||
const pos = flat.getAttribute("position");
|
||
const uv = new Float32BufferAttribute(new Float32Array(pos.count * 2), 2);
|
||
const tile = Math.max(tileSize, 1e-6);
|
||
|
||
for (let t = 0; t < pos.count; t += 3) {
|
||
const ax = pos.getX(t);
|
||
const ay = pos.getY(t);
|
||
const az = pos.getZ(t);
|
||
const bx = pos.getX(t + 1);
|
||
const by = pos.getY(t + 1);
|
||
const bz = pos.getZ(t + 1);
|
||
const cx = pos.getX(t + 2);
|
||
const cy = pos.getY(t + 2);
|
||
const cz = pos.getZ(t + 2);
|
||
|
||
const e1x = bx - ax;
|
||
const e1y = by - ay;
|
||
const e1z = bz - az;
|
||
const e2x = cx - ax;
|
||
const e2y = cy - ay;
|
||
const e2z = cz - az;
|
||
const nx = Math.abs(e1y * e2z - e1z * e2y);
|
||
const ny = Math.abs(e1z * e2x - e1x * e2z);
|
||
const nz = Math.abs(e1x * e2y - e1y * e2x);
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
const idx = t + i;
|
||
const x = pos.getX(idx) + origin.x;
|
||
const y = pos.getY(idx) + origin.y;
|
||
const z = pos.getZ(idx) + origin.z;
|
||
let u = 0;
|
||
let v = 0;
|
||
if (ny >= nx && ny >= nz) {
|
||
u = x;
|
||
v = z;
|
||
} else if (nx >= ny && nx >= nz) {
|
||
u = z;
|
||
v = y;
|
||
} else {
|
||
u = x;
|
||
v = y;
|
||
}
|
||
uv.setXY(idx, u / tile, v / tile);
|
||
}
|
||
}
|
||
|
||
flat.setAttribute("uv", uv);
|
||
flat.computeVertexNormals();
|
||
return flat;
|
||
}
|
||
|
||
// 创建展示场景
|
||
function buildShowcaseWorld(baseMat, layout, options = {}) {
|
||
const root = new Group();
|
||
const { platform, deckY, deckThick, tileSize } = layout;
|
||
|
||
addBox(
|
||
root,
|
||
new Vector3(platform.x, deckY, platform.z),
|
||
new Vector3(platform.sizeX, deckThick, platform.sizeZ),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
createPlatformWalls(root, baseMat, layout, options);
|
||
createClimbGroups(root, baseMat, layout);
|
||
createHalfBuriedObstacles(root, baseMat, layout);
|
||
|
||
return root;
|
||
}
|
||
|
||
// 平台围墙
|
||
function createPlatformWalls(root, baseMat, layout, options = {}) {
|
||
const { platform, wallH, wallT, tileSize } = layout;
|
||
const { x, z, sizeX, sizeZ } = platform;
|
||
const halfX = sizeX * 0.5;
|
||
const halfZ = sizeZ * 0.5;
|
||
const y = wallH * 0.5;
|
||
|
||
// 北 / 南
|
||
addBox(root, new Vector3(x, y, z + halfZ + wallT * 0.5), new Vector3(sizeX + wallT * 2, wallH, wallT), baseMat, tileSize);
|
||
addBox(root, new Vector3(x, y, z - halfZ - wallT * 0.5), new Vector3(sizeX + wallT * 2, wallH, wallT), baseMat, tileSize);
|
||
|
||
// 东墙:可选中部豁口
|
||
if (!options.omitEast) {
|
||
addWallWithGap(root, baseMat, layout, x + halfX + wallT * 0.5, options.eastGap);
|
||
}
|
||
|
||
// 西墙:可选中部豁口
|
||
addWallWithGap(root, baseMat, layout, x - halfX - wallT * 0.5, options.westGap);
|
||
}
|
||
|
||
// 单面侧墙,可在中部留出豁口
|
||
function addWallWithGap(root, baseMat, layout, wallX, gap) {
|
||
const { platform, wallH, wallT, tileSize } = layout;
|
||
const { z, sizeZ } = platform;
|
||
const halfZ = sizeZ * 0.5;
|
||
const y = wallH * 0.5;
|
||
|
||
if (!gap || gap.width <= 0) {
|
||
addBox(root, new Vector3(wallX, y, z), new Vector3(wallT, wallH, sizeZ), baseMat, tileSize);
|
||
return;
|
||
}
|
||
|
||
const gapHalf = gap.width * 0.5;
|
||
const southEnd = z - halfZ;
|
||
const northEnd = z + halfZ;
|
||
|
||
const southLen = gap.zCenter - gapHalf - southEnd;
|
||
if (southLen > 1e-4) {
|
||
addBox(
|
||
root,
|
||
new Vector3(wallX, y, southEnd + southLen * 0.5),
|
||
new Vector3(wallT, wallH, southLen),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
}
|
||
const northLen = northEnd - (gap.zCenter + gapHalf);
|
||
if (northLen > 1e-4) {
|
||
addBox(
|
||
root,
|
||
new Vector3(wallX, y, northEnd - northLen * 0.5),
|
||
new Vector3(wallT, wallH, northLen),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 梯形连接段尺寸(东贴大场景,西贴小场景)
|
||
function createTrapezoidLayout(eastX, eastWidth, westWidth) {
|
||
const wideLen = eastWidth * TRAP_WIDE_LEN_RATIO;
|
||
const slopeLen = eastWidth * TRAP_SLOPE_LEN_RATIO;
|
||
const wideWestX = eastX - wideLen;
|
||
const westX = wideWestX - slopeLen;
|
||
return {
|
||
eastX,
|
||
westX,
|
||
wideWestX,
|
||
halfWide: eastWidth * 0.5,
|
||
halfNarrow: westWidth * 0.5,
|
||
};
|
||
}
|
||
|
||
function getTrapezoidOutline(trap) {
|
||
const { eastX, westX, wideWestX, halfWide, halfNarrow } = trap;
|
||
return [
|
||
{ x: eastX, z: halfWide },
|
||
{ x: wideWestX, z: halfWide },
|
||
{ x: westX, z: halfNarrow },
|
||
{ x: westX, z: -halfNarrow },
|
||
{ x: wideWestX, z: -halfWide },
|
||
{ x: eastX, z: -halfWide },
|
||
];
|
||
}
|
||
|
||
function getWideSectionOutline(trap) {
|
||
const { eastX, wideWestX, halfWide } = trap;
|
||
return [
|
||
{ x: eastX, z: halfWide },
|
||
{ x: wideWestX, z: halfWide },
|
||
{ x: wideWestX, z: -halfWide },
|
||
{ x: eastX, z: -halfWide },
|
||
];
|
||
}
|
||
|
||
function getSlopeSectionOutline(trap) {
|
||
const { wideWestX, westX, halfWide, halfNarrow } = trap;
|
||
return [
|
||
{ x: wideWestX, z: halfWide },
|
||
{ x: westX, z: halfNarrow },
|
||
{ x: westX, z: -halfNarrow },
|
||
{ x: wideWestX, z: -halfWide },
|
||
];
|
||
}
|
||
|
||
function isSlopeSectionEdge(a, b) {
|
||
return Math.abs(b.x - a.x) > 1e-4 && Math.abs(b.z - a.z) > 1e-4;
|
||
}
|
||
|
||
// 宽矩形 + 收腰梯形
|
||
function buildTrapezoidArena(baseMat, layout, trap, westWallH) {
|
||
const root = new Group();
|
||
const trapMat = tintMaterial(baseMat, TRAP_TINT_COLOR);
|
||
addPolygonFloor(root, getWideSectionOutline(trap), layout, baseMat);
|
||
addPolygonFloor(root, getSlopeSectionOutline(trap), layout, trapMat);
|
||
addOutlineWalls(root, getTrapezoidOutline(trap), layout, baseMat, trapMat, trap, westWallH);
|
||
return root;
|
||
}
|
||
|
||
function addPolygonFloor(root, points, layout, material) {
|
||
const { deckY, deckThick, tileSize } = layout;
|
||
const shape = new Shape();
|
||
shape.moveTo(points[0].x, -points[0].z);
|
||
for (let i = 1; i < points.length; i++) {
|
||
shape.lineTo(points[i].x, -points[i].z);
|
||
}
|
||
shape.closePath();
|
||
|
||
const geo = new ExtrudeGeometry(shape, { depth: deckThick, bevelEnabled: false, steps: 1 });
|
||
geo.rotateX(-Math.PI / 2);
|
||
geo.translate(0, deckY - deckThick * 0.5, 0);
|
||
const floor = new Mesh(applyWorldTileUV(geo, { x: 0, y: 0, z: 0 }, tileSize), material);
|
||
floor.name = "TrapezoidFloor";
|
||
return addShadowMesh(root, floor);
|
||
}
|
||
|
||
// 沿多边形外缘砌墙,跳过东西两端开口;收腰段墙高从大场景线性降到小场景
|
||
function addOutlineWalls(root, points, layout, wideMat, slopeMat, trap, westWallH) {
|
||
const n = points.length;
|
||
let cx = 0;
|
||
let cz = 0;
|
||
for (const p of points) {
|
||
cx += p.x;
|
||
cz += p.z;
|
||
}
|
||
cx /= n;
|
||
cz /= n;
|
||
|
||
const heightAt = (p) => (
|
||
Math.abs(p.x - trap.wideWestX) < 1e-4 || p.x > trap.wideWestX
|
||
? layout.wallH
|
||
: westWallH
|
||
);
|
||
|
||
for (let i = 0; i < n; i++) {
|
||
const a = points[i];
|
||
const b = points[(i + 1) % n];
|
||
const isEastGap = Math.abs(a.x - trap.eastX) < 1e-4 && Math.abs(b.x - trap.eastX) < 1e-4;
|
||
const isWestGap = Math.abs(a.x - trap.westX) < 1e-4 && Math.abs(b.x - trap.westX) < 1e-4;
|
||
if (isEastGap || isWestGap) continue;
|
||
|
||
const dx = b.x - a.x;
|
||
const dz = b.z - a.z;
|
||
const len = Math.hypot(dx, dz);
|
||
if (len < 1e-4) continue;
|
||
|
||
const midX = (a.x + b.x) * 0.5;
|
||
const midZ = (a.z + b.z) * 0.5;
|
||
let nx = -dz / len;
|
||
let nz = dx / len;
|
||
if (nx * (cx - midX) + nz * (cz - midZ) > 0) {
|
||
nx = -nx;
|
||
nz = -nz;
|
||
}
|
||
|
||
const hA = heightAt(a);
|
||
const hB = heightAt(b);
|
||
const material = isSlopeSectionEdge(a, b) ? slopeMat : wideMat;
|
||
if (Math.abs(hA - hB) > 1e-4) {
|
||
addTaperedWall(root, layout, material, a, b, hA, hB, nx, nz);
|
||
} else {
|
||
addOrientedWall(root, layout, material, midX, midZ, len, nx, nz, dx, dz);
|
||
}
|
||
}
|
||
}
|
||
|
||
function addTaperedWall(root, layout, material, a, b, hA, hB, nx, nz) {
|
||
const { wallT, tileSize } = layout;
|
||
const len = Math.hypot(b.x - a.x, b.z - a.z);
|
||
const along = new Vector3((b.x - a.x) / len, 0, (b.z - a.z) / len);
|
||
const up = new Vector3(0, 1, 0);
|
||
const out = new Vector3(nx, 0, nz);
|
||
|
||
// 外法线与 along×up 反向时会变成左手系,正面被剔掉;改为从另一端挤出
|
||
let origin = a;
|
||
let startH = hA;
|
||
let endH = hB;
|
||
const xAxis = along.clone();
|
||
if (xAxis.clone().cross(up).dot(out) < 0) {
|
||
xAxis.negate();
|
||
origin = b;
|
||
startH = hB;
|
||
endH = hA;
|
||
}
|
||
|
||
const shape = new Shape();
|
||
shape.moveTo(0, 0);
|
||
shape.lineTo(len, 0);
|
||
shape.lineTo(len, endH);
|
||
shape.lineTo(0, startH);
|
||
shape.closePath();
|
||
|
||
const geo = new ExtrudeGeometry(shape, { depth: wallT, bevelEnabled: false, steps: 1 });
|
||
const m = new Matrix4().makeBasis(xAxis, up, out);
|
||
m.setPosition(origin.x, 0, origin.z);
|
||
geo.applyMatrix4(m);
|
||
|
||
const mesh = new Mesh(applyWorldTileUV(geo, { x: 0, y: 0, z: 0 }, tileSize), material);
|
||
return addShadowMesh(root, mesh);
|
||
}
|
||
|
||
function addOrientedWall(root, layout, material, midX, midZ, length, nx, nz, dx, dz) {
|
||
const { wallH, wallT, tileSize } = layout;
|
||
const pos = new Vector3(midX + nx * wallT * 0.5, wallH * 0.5, midZ + nz * wallT * 0.5);
|
||
const geo = new BoxGeometry(wallT, wallH, length);
|
||
geo.rotateY(Math.atan2(dx, dz));
|
||
const mesh = new Mesh(applyWorldTileUV(geo, pos, tileSize), material);
|
||
mesh.position.copy(pos);
|
||
return addShadowMesh(root, mesh);
|
||
}
|
||
|
||
// 创建豁口半透明发光门帘
|
||
function createGlowGapPortal(layout, westGap, doorX) {
|
||
const group = new Group();
|
||
group.name = "GlowGapPortal";
|
||
|
||
const mat = new ShaderMaterial({
|
||
transparent: true,
|
||
depthWrite: false,
|
||
side: DoubleSide,
|
||
uniforms: {
|
||
uTime: { value: 0 },
|
||
uColor: { value: new Color(0x66e0ff) },
|
||
uIntensity: { value: 1.35 },
|
||
},
|
||
vertexShader: /* glsl */ `
|
||
varying vec2 vUv;
|
||
void main() {
|
||
vUv = uv;
|
||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||
}
|
||
`,
|
||
fragmentShader: /* glsl */ `
|
||
uniform float uTime;
|
||
uniform vec3 uColor;
|
||
uniform float uIntensity;
|
||
varying vec2 vUv;
|
||
|
||
void main() {
|
||
float edgeX = smoothstep(0.0, 0.12, vUv.x) * smoothstep(0.0, 0.12, 1.0 - vUv.x);
|
||
float edgeY = smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.1, 1.0 - vUv.y);
|
||
float veil = edgeX * edgeY;
|
||
float wave = 0.55 + 0.45 * sin(vUv.y * 14.0 - uTime * 2.4);
|
||
float scan = 0.35 + 0.65 * pow(abs(sin(vUv.x * 3.14159 + uTime * 1.6)), 2.0);
|
||
float alpha = veil * (0.22 + 0.38 * wave * scan);
|
||
vec3 col = uColor * (uIntensity + 0.55 * wave);
|
||
gl_FragColor = vec4(col, alpha);
|
||
}
|
||
`,
|
||
});
|
||
|
||
const mesh = new Mesh(
|
||
new PlaneGeometry(westGap.width, layout.wallH),
|
||
mat,
|
||
);
|
||
mesh.name = "GlowGapCurtain";
|
||
mesh.rotation.y = Math.PI * 0.5;
|
||
mesh.position.set(doorX, layout.wallH * 0.5, westGap.zCenter);
|
||
mesh.renderOrder = 2;
|
||
mesh.frustumCulled = false;
|
||
group.add(mesh);
|
||
group.userData.material = mat;
|
||
return group;
|
||
}
|
||
|
||
// 更新发光门帘时间
|
||
function updateGlowPortal(t) {
|
||
for (const portal of glowPortals) {
|
||
const mat = portal?.userData?.material;
|
||
if (mat?.uniforms?.uTime) mat.uniforms.uTime.value = t;
|
||
}
|
||
}
|
||
|
||
// 两数是否视为同一缩放
|
||
function isNearScale(a, b) {
|
||
return Math.abs(a - b) <= Math.max(Math.abs(b) * 1e-4, 1e-8);
|
||
}
|
||
|
||
// 收腰段 X:wideWestX → 1,westX → MINI_SCALE;宽矩形段保持 1
|
||
function zoneScaleFromX(x) {
|
||
if (!trapScaleRange) return 1;
|
||
const { eastX: narrowStartX, westX } = trapScaleRange;
|
||
if (x > narrowStartX) return 1;
|
||
const span = narrowStartX - westX;
|
||
if (span <= 1e-6) return x <= westX ? MINI_SCALE : 1;
|
||
const t = Math.min(1, Math.max(0, (narrowStartX - x) / span));
|
||
return 1 + t * (MINI_SCALE - 1);
|
||
}
|
||
|
||
function playerScaleForZone(z = zoneScale) {
|
||
// updateTrapScale 每帧调用这里,身高系数必须并入,否则会被区域缩放覆盖
|
||
return PLAYER_SCALE_NORMAL * z * currentHeightFactor;
|
||
}
|
||
|
||
function vehicleScaleForZone(z = zoneScale) {
|
||
return VEHICLE_SCALE_NORMAL * z;
|
||
}
|
||
|
||
function isDriving() {
|
||
return player?.controllerMode === 1 && Boolean(player.getActiveVehicle?.());
|
||
}
|
||
|
||
function getScaleGatePosition() {
|
||
if (isDriving()) {
|
||
const v = player.getActiveVehicle();
|
||
if (v?.vehicleGroup) return v.vehicleGroup.position;
|
||
}
|
||
const cap = player?.playerCapsule;
|
||
if (cap) return cap.getWorldPosition(scaleGateWorldPos);
|
||
return player?.getPosition?.() ?? null;
|
||
}
|
||
|
||
function recomputePlayerGroundThresholds() {
|
||
const cap = player?.playerCapsule;
|
||
const info = cap?.capsuleInfo;
|
||
if (!info) return;
|
||
const sy = cap.scale.y || 1;
|
||
const ride = player.rideHeight * player.playerModelConfig.scale;
|
||
player.snapH = (-info.segment.end.y) * sy + info.radius + ride;
|
||
player.maxH = player.snapH + ride;
|
||
}
|
||
|
||
// 应用人物缩放;驾驶中保持胶囊局部缩放,由车身缩放带动
|
||
function applyPlayerScale(targetScale) {
|
||
if (!player?.setPlayerScale || isNearScale(player.playerModelConfig.scale, targetScale)) return;
|
||
if (!isDriving()) {
|
||
player.setPlayerScale(targetScale);
|
||
return;
|
||
}
|
||
const cap = player.playerCapsule;
|
||
const savedScale = cap.scale.clone();
|
||
player.setPlayerScale(targetScale);
|
||
cap.scale.copy(savedScale);
|
||
recomputePlayerGroundThresholds();
|
||
}
|
||
|
||
// 按收腰段 X 连续缩放:越往 -X 越小
|
||
function updateTrapScale() {
|
||
if (!player || !trapScaleRange) return;
|
||
const pos = getScaleGatePosition();
|
||
if (!pos) return;
|
||
|
||
zoneScale = zoneScaleFromX(pos.x);
|
||
applyPlayerScale(playerScaleForZone(zoneScale));
|
||
|
||
const vehicles = player.getAllVehicles?.() ?? [];
|
||
if (!vehicles.length || !player.setVehicleScale) return;
|
||
for (const v of vehicles) {
|
||
const target = vehicleScaleForZone(zoneScaleFromX(v.vehicleGroup.position.x));
|
||
if (!isNearScale(v.scale, target)) player.setVehicleScale(v, target);
|
||
}
|
||
}
|
||
|
||
// 半埋圆柱 + 半埋长方体
|
||
function createHalfBuriedObstacles(root, baseMat, layout) {
|
||
const accentMat = tintMaterial(baseMat, ACCENT_GRAY);
|
||
const {
|
||
obstacleX0,
|
||
obstacleCount,
|
||
obstacleSpacing,
|
||
obstacleZ,
|
||
obstacleCylR,
|
||
obstacleCylLen,
|
||
obstacleBoxSize,
|
||
deckY,
|
||
deckThick,
|
||
tileSize,
|
||
} = layout;
|
||
const deckTop = deckY + deckThick * 0.5;
|
||
|
||
for (let i = 0; i < obstacleCount; i++) {
|
||
const x = obstacleX0 - i * obstacleSpacing;
|
||
|
||
const cylPos = new Vector3(x, deckTop, -obstacleZ);
|
||
const cylGeo = applyWorldTileUV(
|
||
new CylinderGeometry(obstacleCylR, obstacleCylR, obstacleCylLen, 24),
|
||
cylPos,
|
||
tileSize,
|
||
);
|
||
cylGeo.rotateX(Math.PI / 2);
|
||
const cyl = new Mesh(cylGeo, accentMat);
|
||
cyl.position.copy(cylPos);
|
||
addShadowMesh(root, cyl);
|
||
|
||
addBox(root, new Vector3(x, deckTop, obstacleZ), obstacleBoxSize.clone(), accentMat, tileSize);
|
||
}
|
||
}
|
||
|
||
// 加载展示车辆
|
||
async function spawnShowcaseVehicles(gltfLoader) {
|
||
const gltf = await gltfLoader.loadAsync("./glb/suv.glb");
|
||
for (const spawnPos of VEHICLE_SPAWNS) {
|
||
const model = gltf.scene.clone(true);
|
||
await player.loadVehicleModel({
|
||
model,
|
||
scale: VEHICLE_SCALE_NORMAL,
|
||
position: spawnPos.clone(),
|
||
wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"],
|
||
driverSeatPosition: new Vector3(-0.4, 1.0, 0.3),
|
||
driverSeatRotation: -Math.PI / 2,
|
||
modelRotation: -Math.PI / 2,
|
||
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,
|
||
});
|
||
|
||
const vehicle = player.getAllVehicles().at(-1);
|
||
vehicle?.vehicleGroup?.traverse((child) => {
|
||
if (!child.isMesh) return;
|
||
child.castShadow = true;
|
||
child.receiveShadow = true;
|
||
const mats = Array.isArray(child.material) ? child.material : [child.material];
|
||
for (const mat of mats) {
|
||
if (!mat) continue;
|
||
mat.metalness = 0.8;
|
||
mat.roughness = 0.0;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 车辆前方动态刚体
|
||
function spawnVehiclePropBodies(baseMat, layout) {
|
||
const vehicles = player?.getAllVehicles?.() ?? [];
|
||
if (vehicles.length < 2) return;
|
||
const propMat = tintMaterial(baseMat, PROP_ACCENT_COLOR);
|
||
spawnSphereGrid(vehicles[0], propMat, layout);
|
||
spawnBoxWall(vehicles[1], propMat, layout);
|
||
}
|
||
|
||
// 按布局生成动态球阵与箱墙
|
||
function spawnLayoutPropBodies(baseMat, layout, worldOffset) {
|
||
const propMat = tintMaterial(baseMat, PROP_ACCENT_COLOR);
|
||
const s = layout.scale;
|
||
const forward = new Vector3(-1, 0, 0);
|
||
const right = new Vector3(0, 0, 1);
|
||
const xMid = layout.obstacleX0 - (layout.obstacleCount - 1) * layout.obstacleSpacing * 0.5;
|
||
const deckTop = (worldOffset.y ?? 0) + layout.deckY + layout.deckThick * 0.5;
|
||
const ox = worldOffset.x;
|
||
const oz = worldOffset.z ?? 0;
|
||
|
||
const gravity = player.gravity * s;
|
||
spawnSphereGridAt({
|
||
mat: propMat,
|
||
center: new Vector3(ox + xMid, 0, oz + VEHICLE_SPAWNS[0].z * s),
|
||
forward,
|
||
right,
|
||
radius: PROP_SPHERE_R * s,
|
||
deckTop,
|
||
gravity,
|
||
});
|
||
spawnBoxWallAt({
|
||
mat: propMat,
|
||
center: new Vector3(ox + xMid, 0, oz + VEHICLE_SPAWNS[1].z * s),
|
||
right,
|
||
boxSize: PROP_BOX_SIZE * s,
|
||
deckTop,
|
||
gravity,
|
||
});
|
||
}
|
||
|
||
// 车辆前向 / 右向轴
|
||
function getVehicleAxes(vehicle) {
|
||
vehicle.vehicleGroup.updateMatrixWorld(true);
|
||
const forward = vehicle.forwardLocal.clone().transformDirection(vehicle.vehicleGroup.matrixWorld).normalize();
|
||
const right = new Vector3(0, 1, 0).cross(forward).normalize();
|
||
return { origin: vehicle.vehicleGroup.position, forward, right };
|
||
}
|
||
|
||
// 障碍带中线处的放置中心
|
||
function getPropCenter(vehicle, layout) {
|
||
const { origin, forward, right } = getVehicleAxes(vehicle);
|
||
const center = origin.clone();
|
||
center.x = layout.obstacleX0 - (layout.obstacleCount - 1) * layout.obstacleSpacing * 0.5;
|
||
return { forward, right, center };
|
||
}
|
||
|
||
// 4×4 贴地球阵(相对车辆)
|
||
function spawnSphereGrid(vehicle, mat, layout) {
|
||
const { forward, right, center } = getPropCenter(vehicle, layout);
|
||
spawnSphereGridAt({
|
||
mat,
|
||
center,
|
||
forward,
|
||
right,
|
||
radius: PROP_SPHERE_R * layout.scale,
|
||
deckTop: layout.deckY + layout.deckThick * 0.5,
|
||
});
|
||
}
|
||
|
||
// 4×4 贴地球阵
|
||
function spawnSphereGridAt({ mat, center, forward, right, radius, deckTop, gravity = undefined }) {
|
||
const spacing = radius * 2;
|
||
const start = center.clone().addScaledVector(forward, -(PROP_GRID - 1) * spacing * 0.5);
|
||
const geo = new SphereGeometry(1, 24, 16);
|
||
|
||
for (let row = 0; row < PROP_GRID; row++) {
|
||
for (let col = 0; col < PROP_GRID; col++) {
|
||
const pos = start.clone()
|
||
.addScaledVector(forward, row * spacing)
|
||
.addScaledVector(right, (col - (PROP_GRID - 1) * 0.5) * spacing);
|
||
pos.y = deckTop + radius;
|
||
const mesh = new Mesh(geo, mat);
|
||
mesh.scale.setScalar(radius);
|
||
addShadowMesh(scene, mesh);
|
||
player.addCollider({
|
||
motion: "dynamic",
|
||
shape: { kind: "sphere", radius, position: pos },
|
||
mesh,
|
||
restitution: 0.35,
|
||
friction: 0.55,
|
||
...(gravity != null ? { gravity } : null),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4×4 箱墙(相对车辆)
|
||
function spawnBoxWall(vehicle, mat, layout) {
|
||
const { right, center } = getPropCenter(vehicle, layout);
|
||
spawnBoxWallAt({
|
||
mat,
|
||
center,
|
||
right,
|
||
boxSize: PROP_BOX_SIZE * layout.scale,
|
||
deckTop: layout.deckY + layout.deckThick * 0.5,
|
||
});
|
||
}
|
||
|
||
// 4×4 箱墙
|
||
function spawnBoxWallAt({ mat, center, right, boxSize, deckTop, gravity = undefined }) {
|
||
const half = boxSize * 0.5;
|
||
const start = center.clone();
|
||
const geo = new BoxGeometry(1, 1, 1);
|
||
const halfExtents = new Vector3(half, half, half);
|
||
|
||
for (let row = 0; row < PROP_GRID; row++) {
|
||
for (let col = 0; col < PROP_GRID; col++) {
|
||
const pos = start.clone()
|
||
.addScaledVector(right, (col - (PROP_GRID - 1) * 0.5) * boxSize);
|
||
pos.y = deckTop + boxSize * (row + 0.5);
|
||
const mesh = new Mesh(geo, mat);
|
||
mesh.scale.setScalar(boxSize);
|
||
addShadowMesh(scene, mesh);
|
||
player.addCollider({
|
||
motion: "dynamic",
|
||
shape: {
|
||
kind: "box",
|
||
halfExtents: halfExtents.clone(),
|
||
position: pos,
|
||
},
|
||
mesh,
|
||
restitution: 0.12,
|
||
friction: 0.7,
|
||
density: 1.2,
|
||
...(gravity != null ? { gravity } : null),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 六棱柱攀爬尺寸
|
||
function getHexClimbMetrics(layout) {
|
||
const stairWidth = layout.stairWidth;
|
||
const rampWidth = layout.rampWidth;
|
||
const armSpan = stairWidth + rampWidth;
|
||
const platformSides = 6;
|
||
const platformApothem = armSpan / (2 * Math.tan(Math.PI / platformSides));
|
||
const platformCircumRadius = platformApothem / Math.cos(Math.PI / platformSides);
|
||
const sideLength = 2 * platformApothem * Math.tan(Math.PI / platformSides);
|
||
return {
|
||
stairWidth,
|
||
rampWidth,
|
||
armSpan,
|
||
platformSides,
|
||
platformApothem,
|
||
platformCircumRadius,
|
||
sideLength,
|
||
topHeight: layout.l1Top,
|
||
};
|
||
}
|
||
|
||
// 悬空静态条板
|
||
function createStaticStripPlatforms(baseMat, layout, parent, options = {}) {
|
||
const { sideLength, topHeight } = getHexClimbMetrics(layout);
|
||
const capsuleRadius = (player?.playerCapsule?.capsuleInfo?.radius
|
||
?? 30 * PLAYER_SCALE_NORMAL) * layout.scale;
|
||
const gap = capsuleRadius * STATIC_STRIP_GAP_RADIUS_RATIO;
|
||
const stripDepth = capsuleRadius * STATIC_STRIP_DEPTH_RADIUS_RATIO;
|
||
const span = STATIC_STRIP_COUNT * stripDepth
|
||
+ (STATIC_STRIP_COUNT - 1) * gap;
|
||
const centerX = options.centerX ?? 0;
|
||
const stripSize = new Vector3(
|
||
stripDepth,
|
||
layout.kineThickness,
|
||
sideLength,
|
||
);
|
||
const y = topHeight + layout.kineTopOffset - layout.kineThickness * 0.5;
|
||
const firstX = centerX + span * 0.5 - stripDepth * 0.5;
|
||
const root = new Group();
|
||
root.name = "StaticStripPlatforms";
|
||
const mat = tintMaterial(baseMat, ACCENT_GRAY);
|
||
|
||
for (let i = 0; i < STATIC_STRIP_COUNT; i++) {
|
||
const x = firstX - i * (stripDepth + gap);
|
||
const strip = addBox(
|
||
root,
|
||
new Vector3(x, y, 0),
|
||
stripSize,
|
||
mat,
|
||
layout.tileSize,
|
||
);
|
||
strip.name = `StaticStripPlatform${i + 1}`;
|
||
}
|
||
|
||
parent.add(root);
|
||
player.addCollider({
|
||
motion: "static",
|
||
shape: { kind: "mesh", source: root },
|
||
});
|
||
return { centerX, span };
|
||
}
|
||
|
||
/** 取两台运动学平台起点中心的中点。 */
|
||
function getBalancedKinematicMeetCenterX(layout, spinStartBoundaryX) {
|
||
const { sideLength, platformApothem } = getHexClimbMetrics(layout);
|
||
const half = sideLength * 0.5;
|
||
const farEastX = -platformApothem - half;
|
||
const deckWestX = layout.platform.x - layout.platform.sizeX * 0.5;
|
||
const farWestX = deckWestX + half;
|
||
const spinStartX = Number.isFinite(spinStartBoundaryX)
|
||
? spinStartBoundaryX + half
|
||
: farWestX;
|
||
return (farEastX + spinStartX) * 0.5;
|
||
}
|
||
|
||
// 运动学往返平台
|
||
function createKinematicPlatforms(baseMat, layout, parent, options = {}) {
|
||
const { sideLength, topHeight, platformApothem } = getHexClimbMetrics(layout);
|
||
const size = new Vector3(sideLength, layout.kineThickness, sideLength);
|
||
const y = topHeight + layout.kineTopOffset - layout.kineThickness * 0.5;
|
||
const half = sideLength * 0.5;
|
||
const mat = tintMaterial(baseMat, PROP_ACCENT_COLOR);
|
||
const prefix = layout.scale === 1 ? "Kine" : "MiniKine";
|
||
|
||
const farEastX = -platformApothem - half;
|
||
const deckWestX = layout.platform.x - layout.platform.sizeX * 0.5;
|
||
const farWestX = deckWestX + half;
|
||
const slopeMeetCenterX = options.slopeMeetCenterX;
|
||
const slopeWestX = options.slopeWestX;
|
||
const defaultMeetX = getBalancedKinematicMeetCenterX(layout, slopeMeetCenterX);
|
||
const meetCenterX = options.meetCenterX ?? defaultMeetX;
|
||
const middleStripSpan = Math.max(0, options.middleStripSpan ?? 0);
|
||
|
||
// 同一相位 p:p=1 时前两台分别停靠在静态条板两侧;p=0 时后两台在收腰起点衔接。
|
||
const farEast = new Vector3(farEastX, y, 0);
|
||
// 有静态条板时,两台平台分别停靠在条板组东西两侧;否则保持原来的相邻停靠。
|
||
const meetEast = new Vector3(meetCenterX + middleStripSpan * 0.5 + half, y, 0);
|
||
const spinFrom = Number.isFinite(slopeMeetCenterX)
|
||
? new Vector3(slopeMeetCenterX + half, y, 0)
|
||
: new Vector3(farWestX, y, 0);
|
||
const spinTo = new Vector3(meetCenterX - middleStripSpan * 0.5 - half, y, 0);
|
||
|
||
addKinematicShuttle({
|
||
parent,
|
||
name: `${prefix}Shuttle`,
|
||
from: farEast,
|
||
to: meetEast,
|
||
size,
|
||
mat,
|
||
spinSpeed: 0,
|
||
tileSize: layout.tileSize,
|
||
});
|
||
addKinematicShuttle({
|
||
parent,
|
||
name: `${prefix}ShuttleSpin`,
|
||
from: spinFrom,
|
||
to: spinTo,
|
||
size,
|
||
mat,
|
||
spinSpeed: KINE_SPIN_SPEED,
|
||
tileSize: layout.tileSize,
|
||
});
|
||
|
||
if (Number.isFinite(slopeMeetCenterX) && Number.isFinite(slopeWestX)) {
|
||
const slopeScaleFrom = 1;
|
||
const slopeScaleTo = MINI_SCALE;
|
||
addKinematicShuttle({
|
||
parent,
|
||
name: `${prefix}ShuttleSlope`,
|
||
// 起点东边缘贴收腰段,终点西边缘以缩小后的半宽贴小场景边界。
|
||
from: new Vector3(slopeMeetCenterX - half * slopeScaleFrom, y, 0),
|
||
to: new Vector3(
|
||
slopeWestX + half * slopeScaleTo,
|
||
y * slopeScaleTo,
|
||
0,
|
||
),
|
||
size,
|
||
mat,
|
||
spinSpeed: 0,
|
||
tileSize: layout.tileSize,
|
||
zoneScale: true,
|
||
scaleFrom: slopeScaleFrom,
|
||
scaleTo: slopeScaleTo,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 创建单个往返平台
|
||
function addKinematicShuttle({
|
||
parent,
|
||
name,
|
||
from,
|
||
to,
|
||
size,
|
||
mat,
|
||
spinSpeed,
|
||
tileSize,
|
||
zoneScale = false,
|
||
scaleFrom = 1,
|
||
scaleTo = scaleFrom,
|
||
}) {
|
||
const mesh = addBox(parent, from.clone(), size, mat, tileSize);
|
||
mesh.name = name;
|
||
mesh.scale.setScalar(scaleFrom);
|
||
player.addCollider({
|
||
motion: "kinematic",
|
||
shape: { kind: "mesh", source: mesh },
|
||
follow: mesh,
|
||
});
|
||
const entry = {
|
||
mesh,
|
||
from: from.clone(),
|
||
to: to.clone(),
|
||
spinSpeed,
|
||
zoneScale,
|
||
scaleFrom,
|
||
scaleTo,
|
||
lastZoneScale: scaleFrom,
|
||
};
|
||
kinematicPlatforms.push(entry);
|
||
}
|
||
|
||
/** 收腰段运动学平台。 */
|
||
function applyKinematicZoneScale(entry, targetScale) {
|
||
if (!entry?.mesh || !Number.isFinite(targetScale)) return;
|
||
if (isNearScale(entry.lastZoneScale, targetScale)) return;
|
||
entry.mesh.scale.setScalar(targetScale);
|
||
entry.lastZoneScale = targetScale;
|
||
}
|
||
|
||
// 两端缓入缓出
|
||
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 updateKinematicPlatforms(t) {
|
||
const phase = (t * KINE_SHUTTLE_SPEED / Math.PI) % 2;
|
||
const raw = phase <= 1 ? phase : 2 - phase;
|
||
const p = easeEndsLinearMiddle(raw);
|
||
|
||
for (const entry of kinematicPlatforms) {
|
||
entry.mesh.position.lerpVectors(entry.from, entry.to, p);
|
||
if (entry.zoneScale) {
|
||
const targetScale = entry.scaleFrom + (entry.scaleTo - entry.scaleFrom) * p;
|
||
applyKinematicZoneScale(entry, targetScale);
|
||
}
|
||
if (entry.spinSpeed) entry.mesh.rotation.y = t * entry.spinSpeed;
|
||
}
|
||
}
|
||
|
||
// ── 角色与发型切换 ──
|
||
|
||
// glTF 缓存加载:来回切换时不重复下载
|
||
function loadGltfCached(url) {
|
||
if (!gltfCache.has(url)) gltfCache.set(url, gltfLoader.loadAsync(url));
|
||
return gltfCache.get(url);
|
||
}
|
||
|
||
// 取模型下第一个蒙皮网格
|
||
function findSkinnedMesh(root) {
|
||
let found = null;
|
||
root?.traverse((obj) => { if (!found && obj.isSkinnedMesh) found = obj; });
|
||
return found;
|
||
}
|
||
|
||
// 找出两条手臂的骨骼链
|
||
function resolveArmChains(root) {
|
||
const chains = [];
|
||
for (const side of ARM_SIDES) {
|
||
const upper = root?.getObjectByName(side.upper);
|
||
const lower = root?.getObjectByName(side.lower);
|
||
const hand = root?.getObjectByName(side.hand);
|
||
if (upper && lower && hand) chains.push({ upper, lower, hand });
|
||
}
|
||
return chains.length ? chains : null;
|
||
}
|
||
|
||
/**
|
||
* 在骨骼的父空间里叠加一个世界空间旋转。
|
||
* 世界朝向 W = P · L,要得到 W' = R · W,则 L' = (P⁻¹ · R · P) · L。
|
||
*/
|
||
function rotateBoneWorld(bone, axisWorld, angle) {
|
||
if (!bone.parent || Math.abs(angle) < 1e-6 || axisWorld.lengthSq() < 1e-8) return;
|
||
armTmp.qWorld.setFromAxisAngle(axisWorld.normalize(), angle);
|
||
bone.parent.getWorldQuaternion(armTmp.qParent);
|
||
armTmp.qLocal.copy(armTmp.qParent).invert().multiply(armTmp.qWorld).multiply(armTmp.qParent);
|
||
bone.quaternion.premultiply(armTmp.qLocal);
|
||
bone.updateMatrixWorld(true);
|
||
}
|
||
|
||
/**
|
||
* 动画更新之后微调手臂。旋转轴每帧从当前姿态现算,
|
||
* 因此与具体动作无关:手臂已经垂直或已经伸直时叉乘趋零,自动不再作用。
|
||
*/
|
||
function applyArmAdjust() {
|
||
if (!armChains) return;
|
||
const hang = armAdjust.hang * Math.PI / 180;
|
||
const straighten = armAdjust.straighten * Math.PI / 180;
|
||
if (!hang && !straighten) return;
|
||
|
||
for (const { upper, lower, hand } of armChains) {
|
||
if (hang) {
|
||
upper.getWorldPosition(armTmp.a);
|
||
lower.getWorldPosition(armTmp.b);
|
||
armTmp.dir.subVectors(armTmp.b, armTmp.a);
|
||
if (armTmp.dir.lengthSq() > 1e-8) {
|
||
armTmp.axis.crossVectors(armTmp.dir.normalize(), ARM_DOWN);
|
||
rotateBoneWorld(upper, armTmp.axis, hang);
|
||
}
|
||
}
|
||
if (straighten) {
|
||
upper.getWorldPosition(armTmp.a);
|
||
lower.getWorldPosition(armTmp.b);
|
||
hand.getWorldPosition(armTmp.c);
|
||
armTmp.dir.subVectors(armTmp.b, armTmp.a).normalize();
|
||
armTmp.fore.subVectors(armTmp.c, armTmp.b).normalize();
|
||
armTmp.axis.crossVectors(armTmp.dir, armTmp.fore);
|
||
if (armTmp.axis.lengthSq() > 1e-8) rotateBoneWorld(lower, armTmp.axis, -straighten);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 记录骨骼静止姿态(位移 + 旋转);必须在 mixer 跑过之前采集
|
||
function collectRestPose(root) {
|
||
const rest = new Map();
|
||
root?.traverse((obj) => {
|
||
if (obj.isBone) rest.set(obj.name, { position: obj.position.clone(), quaternion: obj.quaternion.clone() });
|
||
});
|
||
return rest;
|
||
}
|
||
|
||
// 轨道是否随时间变化
|
||
function trackVaries(track, stride) {
|
||
const v = track.values;
|
||
for (let i = stride; i < v.length; i++) {
|
||
if (Math.abs(v[i] - v[i % stride]) > 1e-6) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 把 ual.glb 的动画适配到别的体型。
|
||
* Blender 导出器给全部 65 根骨骼都烤了 translation / scale 轨道,
|
||
* 直接播放会把 mannequin 的骨骼位置强加给别的角色,肩宽和四肢比例随之被改写。
|
||
* 这里只保留旋转,丢掉常量位移与全部缩放,唯一真正变化的 pelvis 位移重定到目标静止姿态。
|
||
*/
|
||
function retargetClips(clips, targetRest) {
|
||
if (!sourceRestPose) return clips;
|
||
return clips.map((clip) => {
|
||
const tracks = [];
|
||
for (const track of clip.tracks) {
|
||
const dot = track.name.lastIndexOf(".");
|
||
const bone = track.name.slice(0, dot);
|
||
const prop = track.name.slice(dot + 1);
|
||
|
||
const from = sourceRestPose.get(bone);
|
||
const to = targetRest.get(bone);
|
||
|
||
if (prop === "quaternion") {
|
||
if (!from || !to) { tracks.push(track); continue; }
|
||
// 两套骨架静止朝向不同(锁骨差 9.5°、颈部差 23°),
|
||
// 直接套用绝对旋转会把目标骨架拧歪,故只搬运相对静止姿态的差量
|
||
retargetTmp.corr.copy(to.quaternion).multiply(retargetTmp.inv.copy(from.quaternion).invert());
|
||
if (1 - Math.abs(retargetTmp.corr.w) < 1e-9) { tracks.push(track); continue; }
|
||
const q = Float32Array.from(track.values);
|
||
for (let i = 0; i + 3 < q.length; i += 4) {
|
||
retargetTmp.q.set(q[i], q[i + 1], q[i + 2], q[i + 3]).premultiply(retargetTmp.corr);
|
||
q[i] = retargetTmp.q.x; q[i + 1] = retargetTmp.q.y;
|
||
q[i + 2] = retargetTmp.q.z; q[i + 3] = retargetTmp.q.w;
|
||
}
|
||
tracks.push(new QuaternionKeyframeTrack(track.name, track.times, q));
|
||
continue;
|
||
}
|
||
|
||
if (prop !== "position") continue; // scale 一律丢弃
|
||
if (!trackVaries(track, 3)) continue; // 常量位移即静止姿态,丢弃
|
||
if (!from || !to) { tracks.push(track); continue; }
|
||
|
||
const values = Float32Array.from(track.values);
|
||
for (let i = 0; i + 2 < values.length; i += 3) {
|
||
values[i] += to.position.x - from.position.x;
|
||
values[i + 1] += to.position.y - from.position.y;
|
||
values[i + 2] += to.position.z - from.position.z;
|
||
}
|
||
tracks.push(new VectorKeyframeTrack(track.name, track.times, values));
|
||
}
|
||
return new AnimationClip(clip.name, clip.duration, tracks);
|
||
});
|
||
}
|
||
|
||
// 按角色缓存适配后的动画
|
||
function clipsForModel(name, model) {
|
||
if (!clipCache.has(name)) clipCache.set(name, retargetClips(playerAnimations, collectRestPose(model)));
|
||
return clipCache.get(name);
|
||
}
|
||
|
||
// 量出躯干实际半宽,换算成贴合网格的 capsuleRadiusRatio
|
||
// 模型为 T-Pose,直接取包围盒会把手臂算进去,故按蒙皮权重只取躯干顶点
|
||
function measureTorsoRadiusRatio(root) {
|
||
const mesh = findSkinnedMesh(root);
|
||
const pos = mesh?.geometry?.attributes?.position;
|
||
const skinIndex = mesh?.geometry?.attributes?.skinIndex;
|
||
const skinWeight = mesh?.geometry?.attributes?.skinWeight;
|
||
if (!mesh || !pos || !skinIndex || !skinWeight) return 1;
|
||
|
||
const torso = new Set();
|
||
mesh.skeleton?.bones.forEach((bone, i) => {
|
||
if (HITBOX_TORSO_BONES.includes(bone.name)) torso.add(i);
|
||
});
|
||
if (!torso.size) return 1;
|
||
|
||
let halfWidth = 0;
|
||
for (let v = 0; v < pos.count; v++) {
|
||
let dominant = 0;
|
||
let best = -1;
|
||
for (let k = 0; k < 4; k++) {
|
||
const w = skinWeight.getComponent(v, k);
|
||
if (w > best) { best = w; dominant = skinIndex.getComponent(v, k); }
|
||
}
|
||
if (torso.has(dominant)) halfWidth = Math.max(halfWidth, Math.abs(pos.getX(v)));
|
||
}
|
||
if (halfWidth <= 0) return 1;
|
||
|
||
root.updateMatrixWorld(true);
|
||
const height = new Box3().setFromObject(root).getSize(new Vector3()).y;
|
||
if (!(height > 0)) return 1;
|
||
return CAPSULE_HEIGHT_TO_RADIUS * (halfWidth / height);
|
||
}
|
||
|
||
// 换角色:网格来自角色文件,动画始终来自 ual.glb
|
||
async function applyCharacter(name) {
|
||
const entry = CHARACTERS.find((c) => c.name === name) ?? CHARACTERS[0];
|
||
const gltf = await loadGltfCached(entry.url);
|
||
// 每次都用新副本:控制器会就地缩放模型,复用同一对象会叠加缩放
|
||
const model = cloneSkeleton(gltf.scene);
|
||
const radiusRatio = measureTorsoRadiusRatio(model) * currentHitboxScale;
|
||
currentHeightFactor = characterHeights.get(entry.name) ?? entry.heightFactor ?? 1;
|
||
await player.switchPlayerModel({
|
||
...PLAYER_MODEL_BASE,
|
||
model,
|
||
animations: clipsForModel(entry.name, model),
|
||
scale: playerScaleForZone(),
|
||
capsuleRadiusRatio: radiusRatio,
|
||
});
|
||
currentCharacterName = entry.name;
|
||
Object.assign(armAdjust, characterArms.get(entry.name) ?? { hang: 0, straighten: 0 });
|
||
armChains = resolveArmChains(player.playerModel);
|
||
syncCharacterGui?.();
|
||
enableModelShadows(player.playerModel);
|
||
currentHairMesh = null; // 旧发型随旧模型一并被移除
|
||
await applyHair(currentHairName);
|
||
}
|
||
|
||
// 换发型:与角色共用骨架,骨骼顺序一致且绑定矩阵均为单位阵,可直接绑定
|
||
async function applyHair(name) {
|
||
currentHairName = name;
|
||
if (currentHairMesh) {
|
||
currentHairMesh.removeFromParent();
|
||
currentHairMesh = null;
|
||
}
|
||
if (name === HAIR_NONE) return;
|
||
|
||
const gltf = await loadGltfCached(`${CHARACTER_DIR}${name}.gltf`);
|
||
const hair = findSkinnedMesh(cloneSkeleton(gltf.scene));
|
||
const target = findSkinnedMesh(player.playerModel);
|
||
if (!hair || !target) {
|
||
console.warn("[showcase] 发型或角色缺少蒙皮网格:", name);
|
||
return;
|
||
}
|
||
|
||
hair.name = name;
|
||
hair.position.set(0, 0, 0);
|
||
hair.quaternion.identity();
|
||
hair.scale.set(1, 1, 1);
|
||
target.parent.add(hair);
|
||
hair.bind(target.skeleton, hair.bindMatrix);
|
||
// 重绑骨架后包围盒失效,关闭视锥剔除避免发型被误剔除
|
||
hair.frustumCulled = false;
|
||
enableModelShadows(hair);
|
||
currentHairMesh = hair;
|
||
}
|
||
|
||
// GUI 入口:切换期间加锁,避免并发切换打架
|
||
async function switchCharacter(name, force = false) {
|
||
if (characterBusy || (!force && name === currentCharacterName)) return;
|
||
characterBusy = true;
|
||
try { await applyCharacter(name); }
|
||
catch (e) { console.error("切换角色失败:", e); }
|
||
finally { characterBusy = false; }
|
||
}
|
||
|
||
async function switchHair(name) {
|
||
if (characterBusy || name === currentHairName) return;
|
||
characterBusy = true;
|
||
try { await applyHair(name); }
|
||
catch (e) { console.error("切换发型失败:", e); }
|
||
finally { characterBusy = false; }
|
||
}
|
||
|
||
// 东侧巡逻甲板:栈桥 + 楼梯 / 坡道 + 上层甲板
|
||
function buildPatrolDeck(baseMat, layout) {
|
||
const { platform, wallH, wallT, l1Top, deckY, deckThick, tileSize } = layout;
|
||
const root = new Group();
|
||
root.name = "PatrolDeck";
|
||
const decor = new Group();
|
||
decor.name = "PatrolDeckDecor";
|
||
|
||
const outerX = platform.x + platform.sizeX * 0.5 + wallT; // 东墙外缘
|
||
const bridgeEndX = outerX + PATROL_BRIDGE_LEN;
|
||
const climbRun = l1Top / Math.tan(PATROL_CLIMB_ANGLE * Math.PI / 180);
|
||
const deckStartX = bridgeEndX + climbRun;
|
||
const deckEndX = deckStartX + PATROL_DECK_LEN;
|
||
const deckCenterX = (deckStartX + deckEndX) * 0.5;
|
||
const halfGap = PATROL_GAP_WIDTH * 0.5;
|
||
const halfDeckZ = PATROL_DECK_WIDTH * 0.5;
|
||
|
||
// 豁口外栈桥,与主平台同高
|
||
addBox(
|
||
root,
|
||
new Vector3((outerX + bridgeEndX) * 0.5, deckY, 0),
|
||
new Vector3(PATROL_BRIDGE_LEN, deckThick, PATROL_GAP_WIDTH),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
|
||
// 南半侧楼梯 / 北半侧坡道
|
||
addPatrolStairs(root, baseMat, layout, bridgeEndX, climbRun, halfGap, -halfGap * 0.5);
|
||
addPatrolRamp(root, baseMat, layout, bridgeEndX, climbRun, halfGap, halfGap * 0.5);
|
||
|
||
// 上层甲板:实心块,避免悬空板
|
||
addBox(
|
||
root,
|
||
new Vector3(deckCenterX, l1Top * 0.5, 0),
|
||
new Vector3(PATROL_DECK_LEN, l1Top, PATROL_DECK_WIDTH),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
|
||
// 甲板围墙:北 / 南 / 东,西侧向坡道敞开
|
||
const wallY = l1Top + wallH * 0.5;
|
||
addBox(root, new Vector3(deckCenterX, wallY, halfDeckZ + wallT * 0.5), new Vector3(PATROL_DECK_LEN + wallT * 2, wallH, wallT), baseMat, tileSize);
|
||
addBox(root, new Vector3(deckCenterX, wallY, -halfDeckZ - wallT * 0.5), new Vector3(PATROL_DECK_LEN + wallT * 2, wallH, wallT), baseMat, tileSize);
|
||
addBox(root, new Vector3(deckEndX + wallT * 0.5, wallY, 0), new Vector3(wallT, wallH, PATROL_DECK_WIDTH), baseMat, tileSize);
|
||
|
||
// 巡逻道标线:只做视觉,不参与碰撞
|
||
const fromX = deckStartX + PATROL_END_MARGIN;
|
||
const toX = deckEndX - PATROL_END_MARGIN;
|
||
const markMat = tintMaterial(baseMat, PROP_ACCENT_COLOR);
|
||
const lanes = [];
|
||
for (let i = 0; i < PATROL_NPC_COUNT; i++) {
|
||
const z = (i - (PATROL_NPC_COUNT - 1) * 0.5) * PATROL_LANE_GAP;
|
||
lanes.push(z);
|
||
const mark = addBox(
|
||
decor,
|
||
new Vector3((fromX + toX) * 0.5, l1Top + 0.005, z),
|
||
new Vector3(toX - fromX, 0.01, PATROL_LANE_MARK_WIDTH),
|
||
markMat,
|
||
tileSize,
|
||
);
|
||
mark.castShadow = false;
|
||
}
|
||
|
||
return { root, decor, deckTop: l1Top, fromX, toX, lanes };
|
||
}
|
||
|
||
// 栈桥尽头的上行楼梯
|
||
function addPatrolStairs(root, baseMat, layout, startX, run, width, z) {
|
||
const { l1Top, tileSize } = layout;
|
||
const stepHeight = l1Top / PATROL_STEP_COUNT;
|
||
const stepDepth = run / PATROL_STEP_COUNT;
|
||
|
||
for (let i = 0; i < PATROL_STEP_COUNT; i++) {
|
||
const height = stepHeight * (i + 1);
|
||
addBox(
|
||
root,
|
||
new Vector3(startX + stepDepth * (i + 0.5), height * 0.5, z),
|
||
new Vector3(stepDepth, height, width),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 栈桥尽头的上行坡道
|
||
function addPatrolRamp(root, baseMat, layout, startX, run, width, z) {
|
||
const { l1Top, tileSize } = layout;
|
||
const origin = new Vector3(startX + run * 0.5, 0, z);
|
||
const ramp = new Mesh(createRampGeometry(run, l1Top, width, origin, 0, tileSize), baseMat);
|
||
ramp.position.copy(origin);
|
||
return addShadowMesh(root, ramp);
|
||
}
|
||
|
||
// 巡逻 NPC:玩家 rig 副本,在甲板上来回行走
|
||
function spawnPatrolNPCs(playerGltf, patrol) {
|
||
const walkClip = AnimationClip.findByName(playerGltf.animations, "Walk_Loop");
|
||
if (!walkClip || !player?.playerModel) {
|
||
console.warn("[showcase] 缺少 Walk_Loop 或玩家模型,跳过巡逻 NPC");
|
||
return;
|
||
}
|
||
|
||
// 尺寸与速度全部取自玩家,保证 NPC 与玩家同体型、同步幅
|
||
const modelScale = player.playerModel.scale.x;
|
||
const radius = player.playerCapsule?.capsuleInfo?.radius ?? 30 * PLAYER_SCALE_NORMAL;
|
||
const speed = Number.isFinite(player.playerSpeed)
|
||
? player.playerSpeed
|
||
: PATROL_WALK_SPEED_RAW * PLAYER_SCALE_NORMAL;
|
||
const span = patrol.toX - patrol.fromX;
|
||
const proxyMat = new MeshStandardMaterial();
|
||
const bbox = new Box3();
|
||
|
||
for (let i = 0; i < patrol.lanes.length; i++) {
|
||
const group = new Group();
|
||
group.name = `PatrolNPC${i + 1}`;
|
||
|
||
// SkinnedMesh 必须用 SkeletonUtils.clone,Object3D.clone 会共用骨架
|
||
const model = cloneSkeleton(playerGltf.scene);
|
||
model.scale.setScalar(modelScale);
|
||
model.updateMatrixWorld(true);
|
||
bbox.setFromObject(model);
|
||
const bodyHeight = bbox.max.y - bbox.min.y;
|
||
model.position.y = -bbox.min.y; // 脚底贴甲板
|
||
group.add(model);
|
||
|
||
// 不可见碰撞代理:与模型同级,不被模型缩放带走
|
||
const proxy = new Mesh(new BoxGeometry(radius * 2, bodyHeight, radius * 2), proxyMat);
|
||
proxy.name = `PatrolNPCBody${i + 1}`;
|
||
proxy.position.y = bodyHeight * 0.5;
|
||
proxy.visible = false;
|
||
group.add(proxy);
|
||
|
||
// 相位错开,避免整齐划一
|
||
const phase = i / patrol.lanes.length;
|
||
group.position.set(patrol.fromX + span * phase, patrol.deckTop, patrol.lanes[i]);
|
||
group.rotation.y = Math.PI / 2; // 控制器约定:本地 +Z 指向移动方向
|
||
scene.add(group);
|
||
group.updateMatrixWorld(true);
|
||
enableModelShadows(model);
|
||
|
||
const mixer = new AnimationMixer(model);
|
||
const action = mixer.clipAction(walkClip);
|
||
action.time = walkClip.duration * phase;
|
||
action.play();
|
||
|
||
player.addCollider({
|
||
motion: "kinematic",
|
||
shape: { kind: "mesh", source: proxy },
|
||
follow: proxy,
|
||
});
|
||
|
||
patrolNPCs.push({
|
||
group,
|
||
mixer,
|
||
speed,
|
||
fromX: patrol.fromX,
|
||
toX: patrol.toX,
|
||
dir: 1,
|
||
turnLeft: 0,
|
||
turnFrom: 0,
|
||
turnTo: 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 推进巡逻 NPC:匀速直行,到端点掉头
|
||
function updatePatrolNPCs(delta) {
|
||
if (!(delta > 0)) return;
|
||
|
||
for (const npc of patrolNPCs) {
|
||
npc.mixer.update(delta);
|
||
|
||
if (npc.turnLeft > 0) {
|
||
npc.turnLeft = Math.max(0, npc.turnLeft - delta);
|
||
const p = 1 - npc.turnLeft / PATROL_TURN_TIME;
|
||
npc.group.rotation.y = npc.turnFrom + (npc.turnTo - npc.turnFrom) * easeEndsLinearMiddle(p, 0.4);
|
||
if (npc.turnLeft === 0) npc.group.rotation.y = npc.turnTo % (Math.PI * 2);
|
||
continue;
|
||
}
|
||
|
||
npc.group.position.x += npc.dir * npc.speed * delta;
|
||
if (npc.dir > 0 && npc.group.position.x >= npc.toX) {
|
||
npc.group.position.x = npc.toX;
|
||
startPatrolTurn(npc, -1);
|
||
} else if (npc.dir < 0 && npc.group.position.x <= npc.fromX) {
|
||
npc.group.position.x = npc.fromX;
|
||
startPatrolTurn(npc, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 端点掉头:绕 Y 轴转半圈
|
||
function startPatrolTurn(npc, dir) {
|
||
npc.dir = dir;
|
||
npc.turnLeft = PATROL_TURN_TIME;
|
||
npc.turnFrom = npc.group.rotation.y;
|
||
npc.turnTo = npc.turnFrom + Math.PI;
|
||
}
|
||
|
||
// 创建攀爬组与中心平台
|
||
function createClimbGroups(root, baseMat, layout) {
|
||
const {
|
||
topHeight,
|
||
stairWidth,
|
||
rampWidth,
|
||
platformSides,
|
||
platformApothem,
|
||
platformCircumRadius,
|
||
} = getHexClimbMetrics(layout);
|
||
const stepCount = 7;
|
||
const stepHeight = topHeight / stepCount;
|
||
const tileSize = layout.tileSize;
|
||
|
||
const platformPos = new Vector3(0, topHeight * 0.5, 0);
|
||
const platformGeo = applyWorldTileUV(
|
||
new CylinderGeometry(platformCircumRadius, platformCircumRadius, topHeight, platformSides),
|
||
platformPos,
|
||
tileSize,
|
||
);
|
||
const platform = new Mesh(platformGeo, baseMat);
|
||
platform.position.copy(platformPos);
|
||
addShadowMesh(root, platform);
|
||
|
||
for (let index = 0; index < 3; index++) {
|
||
const arm = new Group();
|
||
arm.rotation.y = index * (Math.PI * 2 / 3);
|
||
root.add(arm);
|
||
|
||
const angleDeg = SLOPE_ANGLES[index];
|
||
const stairZ = -stairWidth * 0.5;
|
||
const rampZ = rampWidth * 0.5;
|
||
const runLength = topHeight / Math.tan(angleDeg * Math.PI / 180);
|
||
const stepDepth = runLength / stepCount;
|
||
|
||
createStairsToPlatform(arm, {
|
||
platformRadius: platformApothem,
|
||
stepHeight,
|
||
stepCount,
|
||
stepDepth,
|
||
width: stairWidth,
|
||
z: stairZ,
|
||
baseMat,
|
||
tileSize,
|
||
});
|
||
|
||
createRampToPlatform(arm, {
|
||
platformRadius: platformApothem,
|
||
angleDeg,
|
||
length: runLength,
|
||
width: rampWidth,
|
||
z: rampZ,
|
||
baseMat,
|
||
tileSize,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 创建一组楼梯
|
||
function createStairsToPlatform(root, { platformRadius, stepHeight, stepCount, stepDepth, width, z, baseMat, tileSize }) {
|
||
const stairRun = stepCount * stepDepth;
|
||
const outer = platformRadius + stairRun;
|
||
|
||
for (let i = 0; i < stepCount; i++) {
|
||
const height = stepHeight * (i + 1);
|
||
const x = outer - (i + 0.5) * stepDepth;
|
||
addBox(
|
||
root,
|
||
new Vector3(x, height / 2, z),
|
||
new Vector3(stepDepth, height, width),
|
||
baseMat,
|
||
tileSize,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 创建一个斜坡
|
||
function createRampToPlatform(root, { platformRadius, angleDeg, length, width, z, baseMat, tileSize }) {
|
||
const height = Math.tan(angleDeg * Math.PI / 180) * length;
|
||
const origin = new Vector3(platformRadius + length * 0.5, 0, z);
|
||
const rotY = Math.PI;
|
||
const ramp = new Mesh(createRampGeometry(length, height, width, origin, rotY, tileSize), baseMat);
|
||
ramp.position.copy(origin);
|
||
ramp.rotation.y = rotY;
|
||
return addShadowMesh(root, ramp);
|
||
}
|
||
|
||
// 创建斜坡几何体。
|
||
function createRampGeometry(length, height, width, origin = { x: 0, y: 0, z: 0 }, rotY = 0, tileSize = TILE_SIZE) {
|
||
const halfLength = length / 2;
|
||
const halfWidth = width / 2;
|
||
const cosY = Math.cos(rotY);
|
||
const sinY = Math.sin(rotY);
|
||
const tile = Math.max(tileSize, 1e-6);
|
||
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 flat = geometry.toNonIndexed();
|
||
const pos = flat.getAttribute("position");
|
||
const uv = new Float32BufferAttribute(new Float32Array(pos.count * 2), 2);
|
||
|
||
for (let t = 0; t < pos.count; t += 3) {
|
||
const ax = pos.getX(t);
|
||
const ay = pos.getY(t);
|
||
const az = pos.getZ(t);
|
||
const bx = pos.getX(t + 1);
|
||
const by = pos.getY(t + 1);
|
||
const bz = pos.getZ(t + 1);
|
||
const cx = pos.getX(t + 2);
|
||
const cy = pos.getY(t + 2);
|
||
const cz = pos.getZ(t + 2);
|
||
|
||
const e1x = bx - ax;
|
||
const e1y = by - ay;
|
||
const e1z = bz - az;
|
||
const e2x = cx - ax;
|
||
const e2y = cy - ay;
|
||
const e2z = cz - az;
|
||
const nx = e1y * e2z - e1z * e2y;
|
||
const ny = e1z * e2x - e1x * e2z;
|
||
const nz = e1x * e2y - e1y * e2x;
|
||
const nLen = Math.hypot(nx, ny, nz) || 1;
|
||
const nnx = nx / nLen;
|
||
const nny = ny / nLen;
|
||
const nnz = nz / nLen;
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
const idx = t + i;
|
||
const lx = pos.getX(idx);
|
||
const ly = pos.getY(idx);
|
||
const lz = pos.getZ(idx);
|
||
const x = origin.x + lx * cosY + lz * sinY;
|
||
const y = origin.y + ly;
|
||
const z = origin.z - lx * sinY + lz * cosY;
|
||
let u = 0;
|
||
let v = 0;
|
||
|
||
if (Math.abs(nnz) > 0.7) {
|
||
u = Math.hypot(lx + halfLength, ly);
|
||
v = y;
|
||
} else if (Math.abs(nnx) > 0.7) {
|
||
u = z;
|
||
v = y;
|
||
} else if (Math.abs(nny) > 0.7 && nny < 0) {
|
||
u = x;
|
||
v = z;
|
||
} else {
|
||
u = Math.hypot(lx + halfLength, ly);
|
||
v = z;
|
||
}
|
||
uv.setXY(idx, u / tile, v / tile);
|
||
}
|
||
}
|
||
|
||
flat.setAttribute("uv", uv);
|
||
flat.computeVertexNormals();
|
||
return flat;
|
||
}
|
||
|
||
// 添加盒子
|
||
function addBox(root, position, size, material, tileSize = TILE_SIZE) {
|
||
const geo = applyWorldTileUV(new BoxGeometry(size.x, size.y, size.z), position, tileSize);
|
||
const mesh = new Mesh(geo, material);
|
||
mesh.position.copy(position);
|
||
return addShadowMesh(root, mesh);
|
||
}
|
||
|
||
// 统一启用网格阴影并挂到场景节点
|
||
function addShadowMesh(root, mesh) {
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
root.add(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
// 启用模型阴影
|
||
function enableModelShadows(root) {
|
||
root?.traverse((obj) => {
|
||
if (obj.isMesh) {
|
||
obj.castShadow = true;
|
||
obj.receiveShadow = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 将调参写回场上全部车辆
|
||
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 n = v.vehicleController.numWheels();
|
||
for (let i = 0; i < n; 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 createDebugPanel() {
|
||
const options = footIK.getOptions();
|
||
const playerScale = player.playerModelConfig.scale;
|
||
const toBasePlayerValue = (value) => value / playerScale;
|
||
const params = {
|
||
colliderDebug: false,
|
||
playerCapsuleDebug: false,
|
||
character: currentCharacterName,
|
||
hair: currentHairName,
|
||
height: currentHeightFactor,
|
||
hitboxScale: currentHitboxScale,
|
||
armHang: armAdjust.hang,
|
||
armStraighten: armAdjust.straighten,
|
||
dynamicBodyDebug: 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,
|
||
predictivePlacement: options.predictivePlacement,
|
||
straightPoleEnabled: options.straightPoleEnabled,
|
||
pelvisOffset: 0,
|
||
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,
|
||
maxSteerDeg: VEHICLE_TUNING.steering.maxSteerAngle * 180 / Math.PI,
|
||
};
|
||
footIKDebugParams = params;
|
||
|
||
const applyFootIKOptions = (patch) => {
|
||
footIK.configure(patch);
|
||
};
|
||
|
||
footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled);
|
||
|
||
const 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", (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, "dynamicBodyDebug").name("Dynamic Bodies").onChange((value) => {
|
||
player?.setDynamicBodyDebug?.(value);
|
||
});
|
||
collisionFolder.open();
|
||
|
||
const characterFolder = gui.addFolder("Character");
|
||
const modelFolder = characterFolder.addFolder("Model");
|
||
modelFolder.add(params, "character", CHARACTERS.map((c) => c.name)).name("Character")
|
||
.onChange((value) => { void switchCharacter(value); });
|
||
modelFolder.add(params, "hair", [HAIR_NONE, ...HAIRSTYLES]).name("Hair")
|
||
.onChange((value) => { void switchHair(value); });
|
||
const heightCtrl = modelFolder.add(params, "height", 0.7, 1.3, 0.01).name("Height")
|
||
.onChange((value) => {
|
||
characterHeights.set(currentCharacterName, value);
|
||
currentHeightFactor = value;
|
||
applyPlayerScale(playerScaleForZone());
|
||
});
|
||
// 半径只在加载模型时读取,故松手后再重载当前角色
|
||
modelFolder.add(params, "hitboxScale", 0.5, 2, 0.05).name("Hitbox Width")
|
||
.onFinishChange((value) => {
|
||
currentHitboxScale = value;
|
||
void switchCharacter(currentCharacterName, true);
|
||
});
|
||
modelFolder.add(params, "playerCapsuleDebug").name("Show Hitbox").onChange((value) => {
|
||
player?.setPlayerCapsuleDebug?.(value);
|
||
});
|
||
const armHangCtrl = modelFolder.add(params, "armHang", -20, 20, 0.5).name("Arm Hang")
|
||
.onChange((value) => {
|
||
armAdjust.hang = value;
|
||
if (!characterArms.has(currentCharacterName)) characterArms.set(currentCharacterName, { hang: 0, straighten: 0 });
|
||
characterArms.get(currentCharacterName).hang = value;
|
||
});
|
||
const armStraightenCtrl = modelFolder.add(params, "armStraighten", -20, 30, 0.5).name("Arm Straighten")
|
||
.onChange((value) => {
|
||
armAdjust.straighten = value;
|
||
if (!characterArms.has(currentCharacterName)) characterArms.set(currentCharacterName, { hang: 0, straighten: 0 });
|
||
characterArms.get(currentCharacterName).straighten = value;
|
||
});
|
||
|
||
// 换角色后把记住的数值写回滑杆
|
||
syncCharacterGui = () => {
|
||
params.height = currentHeightFactor;
|
||
params.armHang = armAdjust.hang;
|
||
params.armStraighten = armAdjust.straighten;
|
||
heightCtrl.updateDisplay();
|
||
armHangCtrl.updateDisplay();
|
||
armStraightenCtrl.updateDisplay();
|
||
};
|
||
|
||
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, "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 });
|
||
});
|
||
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, 80, 1).name("Max Clearance").decimals(0).onChange((value) => {
|
||
applyFootIKOptions({ maxPredictionClearance: value });
|
||
});
|
||
predictionFolder.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((deg) => {
|
||
t.steering.maxSteerAngle = deg * 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 onResize() {
|
||
const container = document.querySelector("#container");
|
||
if (!container || !camera || !renderer) return;
|
||
camera.aspect = container.clientWidth / container.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
}
|
||
|
||
// 每帧调用
|
||
function animate(timestamp) {
|
||
animTimer.update(timestamp);
|
||
const rawDelta = Math.min(animTimer.getDelta(), 1 / 40);
|
||
if (player) {
|
||
scaledAnimTime += rawDelta * player.timeScale;
|
||
updateGlowPortal(scaledAnimTime);
|
||
updateKinematicPlatforms(scaledAnimTime);
|
||
updatePatrolNPCs(rawDelta * player.timeScale);
|
||
updateTrapScale();
|
||
player.update(rawDelta);
|
||
applyArmAdjust();
|
||
} else {
|
||
updateGlowPortal(animTimer.getElapsed());
|
||
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) : "--";
|
||
}
|