Files
gta6/three-player-controller/example/showcase.js
T
knsoandClaude Opus 5 fb38652e7f chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur
lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json.
Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser
Stand als normaler Ordner im Repo, ohne Verweis auf GitHub.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:27:03 +02:00

2463 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) : "--";
}