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) : "--"; }