ggabcd-meshwalk
Version:
MeshWalk.js is a JS library which helps your TPS game development with three.js.
466 lines (402 loc) • 15 kB
JavaScript
import { THREE, onInstallHandlers } from "../install.js";
import EventDispatcher from "./EventDispatcher.js";
import {
testSegmentTriangle,
isIntersectionSphereTriangle,
} from "../math/collision.js";
const PI_HALF = Math.PI * 0.5;
const PI_ONE_HALF = Math.PI * 1.5;
let direction2D;
let wallNormal2D;
let groundingHead;
let groundingTo;
let point1;
let point2;
let direction;
let translateScoped;
let translate;
onInstallHandlers.push(() => {
direction2D = new THREE.Vector2();
wallNormal2D = new THREE.Vector2();
groundingHead = new THREE.Vector3();
groundingTo = new THREE.Vector3();
point1 = new THREE.Vector3();
point2 = new THREE.Vector3();
direction = new THREE.Vector3();
translateScoped = new THREE.Vector3();
translate = new THREE.Vector3();
});
export class CharacterController extends EventDispatcher {
constructor(object3d, radius, head, height) {
super();
this.isCharacterController = true;
this.object = object3d;
this.headObject = head;
this.height = height;
this.fallVelocity = -20; // 下落速度
this.jumpDuration = 1000; // 跳跃过程时间
this.headHeight = 5;
this.center = this.object.position.clone();
this.radius = radius;
this.groundPadding = 0.5;
this.maxSlopeGradient = Math.cos(50 * THREE.Math.DEG2RAD);
this.isGrounded = false;
this.isOnSlope = false;
this.isIdling = false;
this.isRunning = false;
this.isWalking = false;
this.isJumping = false;
this.direction = 0; // 0 to 2PI(=360deg) in rad
this.movementSpeed = 10; // Meters Per Second
this.walkSpeed = 10; // Meters Per Second
this.runSpeed = 30; // Meters Per Second
this.moveAction = "run";
this.velocity = new THREE.Vector3(0, -10, 0);
this.currentJumpPower = 0;
this.jumpStartTime = 0;
this.groundHeight = 0;
this.groundNormal = new THREE.Vector3();
this.collisionCandidate;
this.contactInfo = [];
let isFirstUpdate = true;
let wasGrounded;
let wasOnSlope;
// let wasIdling;
let wasRunning;
let wasJumping;
let lastMoveAction;
this._events = () => {
// 首次执行内容
if (isFirstUpdate) {
isFirstUpdate = false;
wasGrounded = this.isGrounded;
wasOnSlope = this.isOnSlope;
// wasIdling = this.isIdling;
wasRunning = this.isRunning;
wasJumping = this.isJumping;
return;
}
const moveName = this.isWalking ? "walk" : "run";
if (!wasRunning && !this.isRunning && this.isGrounded && !this.isIdling) {
this.isIdling = true;
this.dispatchEvent({ type: "startIdling" });
} else if (
(!wasRunning &&
this.isRunning &&
!this.isJumping &&
this.isGrounded) ||
(!wasGrounded && this.isGrounded && this.isRunning) ||
(wasOnSlope && !this.isOnSlope && this.isRunning && this.isGrounded)
) {
this.isIdling = false;
if (this.isWalking) {
this.dispatchEvent({ type: "startWalking" });
} else {
this.dispatchEvent({ type: "startRunning" });
}
} else if (!wasJumping && this.isJumping) {
this.isIdling = false;
this.dispatchEvent({ type: "startJumping" });
} else if (!wasOnSlope && this.isOnSlope) {
this.dispatchEvent({ type: "startSliding" });
} else if (wasGrounded && !this.isGrounded && !this.isJumping) {
this.dispatchEvent({ type: "startFalling" });
} else if (wasRunning && this.isRunning && lastMoveAction !== moveName) {
lastMoveAction = moveName;
if (this.isWalking) {
this.dispatchEvent({ type: "startWalking" });
} else {
this.dispatchEvent({ type: "startRunning" });
}
}
if (!wasGrounded && this.isGrounded) {
// 先出现startIdling有问题
// TODO 更改以在此事件后 n 秒开始 startIdling
// this.dispatchEvent ({type:'endJumping'});
}
wasGrounded = this.isGrounded;
wasOnSlope = this.isOnSlope;
// wasIdling = this.isIdling;
wasRunning = this.isRunning;
wasJumping = this.isJumping;
};
}
update(dt) {
// 重置状态
this.isGrounded = false;
this.isOnSlope = false;
this.groundHeight = -Infinity;
this.groundNormal.set(0, 1, 0);
this._updateGrounding();
this._updateJumping();
this._updatePosition(dt);
this._collisionDetection();
this._solvePosition();
this._updateVelocity();
this._events();
}
_updateVelocity() {
const frontDirection = -Math.cos(this.direction);
const rightDirection = -Math.sin(this.direction);
let isHittingCeiling = false;
const moveSpeed = this.isWalking ? this.walkSpeed : this.runSpeed;
this.velocity.set(
rightDirection * moveSpeed * this.isRunning,
this.fallVelocity,
frontDirection * moveSpeed * this.isRunning
);
// 处理自动应用的速度,例如陡坡和自由落体
if (this.contactInfo.length === 0 && !this.isJumping) {
// 没有碰撞,所以自由落体
return;
} else if (this.isGrounded && !this.isOnSlope && !this.isJumping) {
// 如果你在正常的地面上,除了在跳跃开始时
this.velocity.y = 0;
} else if (this.isOnSlope) {
// TODO 0.2 是一个神奇的数字,所以想想如何从几何上找到它。
const slidingDownVelocity = this.fallVelocity;
const horizontalSpeed =
(-slidingDownVelocity / (1 - this.groundNormal.y)) * 0.2;
this.velocity.x = this.groundNormal.x * horizontalSpeed;
this.velocity.y = this.fallVelocity;
this.velocity.z = this.groundNormal.z * horizontalSpeed;
} else if (!this.isGrounded && !this.isOnSlope && this.isJumping) {
// 跳跃处理
this.velocity.y = this.currentJumpPower * -this.fallVelocity;
}
// 面向墙壁时将向墙壁的速度设置为0的处理
// vs 墙壁和在墙上滑动
direction2D.set(rightDirection, frontDirection);
// const frontAngle = Math.atan2( direction2D.y, direction2D.x );
const negativeFrontAngle = Math.atan2(-direction2D.y, -direction2D.x);
for (let i = 0, l = this.contactInfo.length; i < l; i++) {
const normal = this.contactInfo[i].face.normal;
// var distance = this.contactInfo[ i ].distance;
if (this.maxSlopeGradient < normal.y || this.isOnSlope) {
// 由于人脸是在地面上,所以没有像墙一样碰撞的可能性。
// 不衰减速度
continue;
}
if (!isHittingCeiling && normal.y < 0) {
isHittingCeiling = true;
}
wallNormal2D.set(normal.x, normal.z).normalize();
const wallAngle = Math.atan2(wallNormal2D.y, wallNormal2D.x);
if (
Math.abs(negativeFrontAngle - wallAngle) >= PI_HALF && // 90deg
Math.abs(negativeFrontAngle - wallAngle) <= PI_ONE_HALF // 270deg
) {
// 面与行进方向相反,点为背面的墙
// 不衰减速度
continue;
}
// 如果不满足以上条件,人脸就是墙
// 找到墙壁法线并将指向相反方向的速度向量设置为0
wallNormal2D.set(
direction2D.dot(wallNormal2D) * wallNormal2D.x,
direction2D.dot(wallNormal2D) * wallNormal2D.y
);
direction2D.sub(wallNormal2D);
const moveSpeed = this.isWalking ? this.walkSpeed : this.runSpeed;
this.velocity.x = direction2D.x * moveSpeed * this.isRunning;
this.velocity.z = direction2D.y * moveSpeed * this.isRunning;
}
// 如果在跳跃时撞到天花板,中断跳跃
if (isHittingCeiling) {
this.velocity.y = Math.min(0, this.velocity.y);
this.isJumping = false;
}
}
_updateGrounding() {
// “从头顶到几乎无限向下的分段 (segment)”与“三角形(triangle)”
// 进行交叉判断
// 如果与面部的交点在“头顶”和“地下填充(groundPadding)”之间
// isGrounded认定在地面上
//
// ___
// / | \
// | | | player sphere 玩家
// \_|_/
// |
//---[+]---- ground 地面
// |
// |
// | segment (玩家头部到无限)
let groundContactInfo;
let groundContactInfoTmp;
const faces = this.collisionCandidate;
groundingHead.set(
this.center.x,
this.center.y + this.radius,
this.center.z
);
groundingTo.set(this.center.x, this.center.y - 1e10, this.center.z);
for (let i = 0, l = faces.length; i < l; i++) {
groundContactInfoTmp = testSegmentTriangle(
groundingHead,
groundingTo,
faces[i].a,
faces[i].b,
faces[i].c
);
if (groundContactInfoTmp && !groundContactInfo) {
groundContactInfo = groundContactInfoTmp;
groundContactInfo.face = faces[i];
} else if (
groundContactInfoTmp &&
groundContactInfoTmp.contactPoint.y > groundContactInfo.contactPoint.y
) {
groundContactInfo = groundContactInfoTmp;
groundContactInfo.face = faces[i];
}
}
if (!groundContactInfo) {
return;
}
this.groundHeight = groundContactInfo.contactPoint.y;
// this.groundNormal.copy(groundContactInfo.face.normal);
const top = groundingHead.y;
const bottom = this.center.y - this.radius - this.groundPadding;
// 跳跃和向上移动时不要强迫地面
if (this.isJumping && 0 < this.currentJumpPower) {
this.isOnSlope = false;
this.isGrounded = false;
return;
}
this.isGrounded = bottom <= this.groundHeight && this.groundHeight <= top;
this.isOnSlope = this.groundNormal.y <= this.maxSlopeGradient;
if (this.isGrounded) {
this.isJumping = false;
}
}
_updatePosition(dt) {
// 暂时忽略墙壁等(速度*时间)
// 推进中心坐标
// 与墙壁的碰撞检测会在下一步进行,这里就不做了
// 如果是isGrounded,强制将y的值调整到地面
const groundedY = this.groundHeight + this.radius;
const x = this.center.x + this.velocity.x * dt;
const y = this.center.y + this.velocity.y * dt;
const z = this.center.z + this.velocity.z * dt;
this.center.set(x, this.isGrounded ? groundedY : y, z);
}
_collisionDetection() {
// 从可能相交的人脸列表中(collisionCandidate)
// 提取实际相交的墙面
// 添加到 this.contactInfo
const faces = this.collisionCandidate;
this.contactInfo.length = 0;
for (let i = 0, l = faces.length; i < l; i++) {
const contactInfo = isIntersectionSphereTriangle(
this,
faces[i].a,
faces[i].b,
faces[i].c,
faces[i].normal
);
if (!contactInfo) continue;
contactInfo.face = faces[i];
this.contactInfo.push(contactInfo);
}
}
_calHeadPosition(position) {
const center = {
x: position.x,
y: position.y + 1 + this.height,
z: position.z,
};
return center;
}
_solvePosition() {
// 用 updatePosition() 运行中心后
// 如果它与墙壁碰撞并咬入它
// 这里从墙中挤出
let face;
let normal;
// let distance;
if (this.contactInfo.length === 0) {
// 没有冲突
// 使用 center 的值退出
const newPosition = {
x: this.center.x,
y: this.center.y - this.radius,
z: this.center.z,
};
this.object.position.copy(newPosition);
this.headObject.position.copy(this._calHeadPosition(newPosition));
return;
}
//
// vs 墙壁和在墙上滑动
translate.set(0, 0, 0);
for (let i = 0, l = this.contactInfo.length; i < l; i++) {
face = this.contactInfo[i].face;
normal = this.contactInfo[i].face.normal;
// distance = this.contactInfo[ i ].distance;
// if ( 0 <= distance ) {
// // 交差点までの距離が 0 以上ならこのフェイスとは衝突していない
// // 無視する
// continue;
// }
if (this.maxSlopeGradient < normal.y) {
// 这个三角形是地面或斜坡,而不是墙壁或天花板
// 面是非陡坡,即地面。
// 忽略接地过程,因为它在 updatePosition () 中解决
continue;
}
// 面是否为陡坡
const isSlopeFace =
this.maxSlopeGradient <= face.normal.y && face.normal.y < 1;
// 如果您在跳跃下降过程中遇到陡坡,则跳跃结束
if (this.isJumping && 0 >= this.currentJumpPower && isSlopeFace) {
this.isJumping = false;
this.isGrounded = true;
// console.log( 'jump end' );
}
if (this.isGrounded || this.isOnSlope) {
// 如果您在地面上,y(垂直)方向保持不变
// 通过仅更改 x、z(水平)方向进行拉伸
// http://gamedev.stackexchange.com/questions/80293/how-do-i-resolve-a-sphere-triangle-collision-in-a-given-direction
point1.copy(normal).multiplyScalar(-this.radius).add(this.center);
direction.set(normal.x, 0, normal.z).normalize();
const plainD = face.a.dot(normal);
const t =
(plainD -
(normal.x * point1.x + normal.y * point1.y + normal.z * point1.z)) /
(normal.x * direction.x +
normal.y * direction.y +
normal.z * direction.z);
point2.copy(direction).multiplyScalar(t).add(point1);
translateScoped.subVectors(point2, point1);
if (Math.abs(translate.x) > Math.abs(translateScoped.x)) {
translate.x += translateScoped.x;
}
if (Math.abs(translate.z) > Math.abs(translateScoped.z)) {
translate.z += translateScoped.z;
}
// break;
continue;
}
}
const newPosition = {
x: this.center.x,
y: this.center.y - this.radius,
z: this.center.z,
};
this.object.position.copy(newPosition);
this.headObject.position.copy(this._calHeadPosition(newPosition));
}
setDirection() {}
jump() {
if (this.isJumping || !this.isGrounded || this.isOnSlope) return;
this.jumpStartTime = performance.now();
this.currentJumpPower = 1;
this.isJumping = true;
}
_updateJumping() {
if (!this.isJumping) return;
const elapsed = performance.now() - this.jumpStartTime;
const progress = elapsed / this.jumpDuration;
this.currentJumpPower = Math.cos(Math.min(progress, 1) * Math.PI);
}
}