module es { class CharacterRaycastOrigins { public topLeft: Vector2; public bottomRight: Vector2; public bottomLeft: Vector2; public constructor() { this.topLeft = Vector2.zero; this.bottomRight = Vector2.zero; this.bottomLeft = Vector2.zero; } } class CharacterCollisionState2D { public right: boolean = false; public left: boolean = false; public above: boolean = false; public below: boolean = false; public becameGroundedThisFrame: boolean = false; public wasGroundedLastFrame: boolean = false; public movingDownSlope: boolean = false; public slopeAngle: number = 0; public hasCollision(): boolean { return this.below || this.right || this.left || this.above; } public reset(): void { this.right = this.left = false; this.above = this.below = false; this.becameGroundedThisFrame = this.movingDownSlope = false; this.slopeAngle = 0; } public toString(): string { return `[CharacterCollisionState2D] r: ${this.right}, l: ${this.left}, a: ${this.above}, b: ${this.below}, movingDownSlope: ${this.movingDownSlope}, angle: ${this.slopeAngle}, wasGroundedLastFrame: ${this.wasGroundedLastFrame}, becameGroundedThisFrame: ${this.becameGroundedThisFrame}`; } } export class CharacterController implements ITriggerListener { public onControllerCollidedEvent: ObservableT; public onTriggerEnterEvent: ObservableT; public onTriggerExitEvent: ObservableT; /** * 如果为 true,则在垂直移动单帧时将忽略平台的一种方式 */ public ignoreOneWayPlatformsTime: number; public supportSlopedOneWayPlatforms: boolean; public ignoredColliders: Set = new Set(); /** * 定义距离碰撞射线的边缘有多远。 * 如果使用 0 范围进行投射,则通常会导致不需要的光线击中(例如,直接从表面水平投射的足部碰撞器可能会导致击中) */ public get skinWidth() { return this._skinWidth; } public set skinWidth(value: number) { this._skinWidth = value; this.recalculateDistanceBetweenRays(); } /** * CC2D 可以爬升的最大坡度角 */ public slopeLimit: number = 30; /** * 构成跳跃的帧之间垂直运动变化的阈值 */ public jumpingThreshold: number = -7; /** * 基于斜率乘以速度的曲线(负 = 下坡和正 = 上坡) */ public slopeSpeedMultiplier: AnimCurve; public totalHorizontalRays: number = 5; public totalVerticalRays: number = 3; public collisionState: CharacterCollisionState2D = new CharacterCollisionState2D(); public velocity: Vector2 = new Vector2(0, 0); public get isGrounded(): boolean { return this.collisionState.below; } public get raycastHitsThisFrame(): RaycastHit[] { return this._raycastHitsThisFrame; } public constructor( player: Entity, skinWidth?: number, platformMask: number = -1, onewayPlatformMask: number = -1, triggerMask: number = -1 ) { this.onTriggerEnterEvent = new ObservableT(); this.onTriggerExitEvent = new ObservableT(); this.onControllerCollidedEvent = new ObservableT(); this.platformMask = platformMask; this.oneWayPlatformMask = onewayPlatformMask; this.triggerMask = triggerMask; // 将我们的单向平台添加到我们的普通平台掩码中,以便我们可以从上方降落 this.platformMask |= this.oneWayPlatformMask; this._player = player; let collider = null; for (let i = 0; i < this._player.components.buffer.length; i++) { let component = this._player.components.buffer[i]; if (component instanceof Collider) { collider = component; break; } } collider.isTrigger = false; if (collider instanceof BoxCollider) { this._collider = collider as BoxCollider; } else { throw new Error('player collider must be box'); } // 在这里,我们触发了具有主体的 setter 的属性 this.skinWidth = skinWidth || collider.width * 0.05; this._slopeLimitTangent = Math.tan(75 * MathHelper.Deg2Rad); this._triggerHelper = new ColliderTriggerHelper(this._player); // 我们想设置我们的 CC2D 忽略所有碰撞层,除了我们的 triggerMask for (let i = 0; i < 32; i++) { // 查看我们的 triggerMask 是否包含此层,如果不包含则忽略它 if ((this.triggerMask & (1 << i)) === 0) { Flags.unsetFlag(this._collider.collidesWithLayers, i); } } } public onTriggerEnter(other: Collider, local: Collider): void { this.onTriggerEnterEvent.notify(other); } public onTriggerExit(other: Collider, local: Collider): void { this.onTriggerExitEvent.notify(other); } /** * 尝试将角色移动到位置 + deltaMovement。 任何挡路的碰撞器都会在遇到时导致运动停止 * @param deltaMovement * @param deltaTime */ public move(deltaMovement: Vector2, deltaTime: number): void { this.collisionState.wasGroundedLastFrame = this.collisionState.below; this.collisionState.reset(); this._raycastHitsThisFrame = []; this._isGoingUpSlope = false; this.primeRaycastOrigins(); if (deltaMovement.y > 0 && this.collisionState.wasGroundedLastFrame) { deltaMovement = this.handleVerticalSlope(deltaMovement); } if (deltaMovement.x !== 0) { deltaMovement = this.moveHorizontally(deltaMovement); } if (deltaMovement.y !== 0) { deltaMovement = this.moveVertically(deltaMovement); } this._player.setPosition( this._player.position.x + deltaMovement.x, this._player.position.y + deltaMovement.y ); if (deltaTime > 0) { this.velocity.x = deltaMovement.x / deltaTime; this.velocity.y = deltaMovement.y / deltaTime; } if ( !this.collisionState.wasGroundedLastFrame && this.collisionState.below ) { this.collisionState.becameGroundedThisFrame = true; } if (this._isGoingUpSlope) { this.velocity.y = 0; } if (!this._isWarpingToGround) { this._triggerHelper.update(); } for (let i = 0; i < this._raycastHitsThisFrame.length; i++) { this.onControllerCollidedEvent.notify(this._raycastHitsThisFrame[i]); } if (this.ignoreOneWayPlatformsTime > 0) { this.ignoreOneWayPlatformsTime -= deltaTime; } } /** * 直接向下移动直到接地 * @param maxDistance */ public warpToGrounded(maxDistance: number = 1000): void { this.ignoreOneWayPlatformsTime = 0; this._isWarpingToGround = true; let delta = 0; do { delta += 1; this.move(new Vector2(0, 1), 0.02); if (delta > maxDistance) { break; } } while (!this.isGrounded); this._isWarpingToGround = false; } /** * 这应该在您必须在运行时修改 BoxCollider2D 的任何时候调用。 * 它将重新计算用于碰撞检测的光线之间的距离。 * 它也用于 skinWidth setter,以防在运行时更改。 */ public recalculateDistanceBetweenRays(): void { const colliderUsableHeight = this._collider.height * Math.abs(this._player.scale.y) - 2 * this._skinWidth; this._verticalDistanceBetweenRays = colliderUsableHeight / (this.totalHorizontalRays - 1); const colliderUsableWidth = this._collider.width * Math.abs(this._player.scale.x) - 2 * this._skinWidth; this._horizontalDistanceBetweenRays = colliderUsableWidth / (this.totalVerticalRays - 1); } /** * 将 raycastOrigins 重置为由 skinWidth 插入的框碰撞器的当前范围。 * 插入它是为了避免从直接接触另一个碰撞器的位置投射光线,从而导致不稳定的法线数据。 */ private primeRaycastOrigins(): void { const rect = this._collider.bounds; this._raycastOrigins.topLeft = new Vector2( rect.x + this._skinWidth, rect.y + this._skinWidth ); this._raycastOrigins.bottomRight = new Vector2( rect.right - this._skinWidth, rect.bottom - this._skinWidth ); this._raycastOrigins.bottomLeft = new Vector2( rect.x + this._skinWidth, rect.bottom - this._skinWidth ); } /** * 我们必须在这方面使用一些技巧。 * 光线必须从我们的碰撞器(skinWidth)内部的一小段距离投射,以避免零距离光线会得到错误的法线。 * 由于这个小偏移,我们必须增加光线距离 skinWidth 然后记住在实际移动玩家之前从 deltaMovement 中删除 skinWidth * @param deltaMovement * @returns */ private moveHorizontally(deltaMovement: Vector2): Vector2 { const isGoingRight = deltaMovement.x > 0; let rayDistance = Math.abs(deltaMovement.x) + this._skinWidth * this.rayOriginSkinMutiplier; const rayDirection: Vector2 = isGoingRight ? Vector2.right : Vector2.left; const initialRayOriginY = this._raycastOrigins.bottomLeft.y; const initialRayOriginX = isGoingRight ? this._raycastOrigins.bottomRight.x - this._skinWidth * (this.rayOriginSkinMutiplier - 1) : this._raycastOrigins.bottomLeft.x + this._skinWidth * (this.rayOriginSkinMutiplier - 1); for (let i = 0; i < this.totalHorizontalRays; i++) { const ray = new Vector2( initialRayOriginX, initialRayOriginY - i * this._verticalDistanceBetweenRays ); // 如果我们接地,我们将只在第一条射线(底部)上包含 oneWayPlatforms。 // 允许我们走上倾斜的 oneWayPlatforms if ( i === 0 && this.supportSlopedOneWayPlatforms && this.collisionState.wasGroundedLastFrame ) { this._raycastHit = Physics.linecast( ray, ray.add(rayDirection.scaleEqual(rayDistance)), this.platformMask, this.ignoredColliders ); } else { this._raycastHit = Physics.linecast( ray, ray.add(rayDirection.scaleEqual(rayDistance)), this.platformMask & ~this.oneWayPlatformMask, this.ignoredColliders ); } if (this._raycastHit.collider) { if ( i === 0 && this.handleHorizontalSlope( deltaMovement, Vector2.unsignedAngle(this._raycastHit.normal, Vector2.up) ) ) { this._raycastHitsThisFrame.push(this._raycastHit); break; } deltaMovement.x = this._raycastHit.point.x - ray.x; rayDistance = Math.abs(deltaMovement.x); if (isGoingRight) { deltaMovement.x -= this._skinWidth * this.rayOriginSkinMutiplier; this.collisionState.right = true; } else { deltaMovement.x += this._skinWidth * this.rayOriginSkinMutiplier; this.collisionState.left = true; } this._raycastHitsThisFrame.push(this._raycastHit); if ( rayDistance < this._skinWidth * this.rayOriginSkinMutiplier + this.kSkinWidthFloatFudgeFactor ) { break; } } } return deltaMovement; } private moveVertically(deltaMovement: Vector2): Vector2 { const isGoingUp = deltaMovement.y < 0; let rayDistance = Math.abs(deltaMovement.y) + this._skinWidth * this.rayOriginSkinMutiplier; const rayDirection = isGoingUp ? Vector2.up : Vector2.down; let initialRayOriginX = this._raycastOrigins.topLeft.x; const initialRayOriginY = isGoingUp ? this._raycastOrigins.topLeft.y + this._skinWidth * (this.rayOriginSkinMutiplier - 1) : this._raycastOrigins.bottomLeft.y - this._skinWidth * (this.rayOriginSkinMutiplier - 1); initialRayOriginX += deltaMovement.x; let mask = this.platformMask; if (isGoingUp || this.ignoreOneWayPlatformsTime > 0) { mask &= ~this.oneWayPlatformMask; } for (let i = 0; i < this.totalVerticalRays; i++) { const rayStart = new Vector2( initialRayOriginX + i * this._horizontalDistanceBetweenRays, initialRayOriginY ); this._raycastHit = Physics.linecast( rayStart, rayStart.add(rayDirection.scaleEqual(rayDistance)), mask, this.ignoredColliders ); if (this._raycastHit.collider) { deltaMovement.y = this._raycastHit.point.y - rayStart.y; rayDistance = Math.abs(deltaMovement.y); if (isGoingUp) { deltaMovement.y += this._skinWidth * this.rayOriginSkinMutiplier; this.collisionState.above = true; } else { deltaMovement.y -= this._skinWidth * this.rayOriginSkinMutiplier; this.collisionState.below = true; } this._raycastHitsThisFrame.push(this._raycastHit); if (!isGoingUp && deltaMovement.y < -0.00001) { this._isGoingUpSlope = true; } if ( rayDistance < this._skinWidth * this.rayOriginSkinMutiplier + this.kSkinWidthFloatFudgeFactor ) { break; } } } return deltaMovement; } /** * 检查 BoxCollider2D 下的中心点是否存在坡度。 * 如果找到一个,则调整 deltaMovement 以便玩家保持接地,并考虑slopeSpeedModifier 以加快移动速度。 * @param deltaMovement * @returns */ private handleVerticalSlope(deltaMovement: Vector2): Vector2 { const centerOfCollider = (this._raycastOrigins.bottomLeft.x + this._raycastOrigins.bottomRight.x) * 0.5; const rayDirection = Vector2.down; const slopeCheckRayDistance = this._slopeLimitTangent * (this._raycastOrigins.bottomRight.x - centerOfCollider); const slopeRay = new Vector2( centerOfCollider, this._raycastOrigins.bottomLeft.y ); this._raycastHit = Physics.linecast( slopeRay, slopeRay.add(rayDirection.scaleEqual(slopeCheckRayDistance)), this.platformMask, this.ignoredColliders ); if (this._raycastHit.collider) { const angle = Vector2.unsignedAngle(this._raycastHit.normal, Vector2.up); if (angle === 0) { return deltaMovement; } const isMovingDownSlope = Math.sign(this._raycastHit.normal.x) === Math.sign(deltaMovement.x); if (isMovingDownSlope) { const slopeModifier = this.slopeSpeedMultiplier ? this.slopeSpeedMultiplier.lerp(-angle) : 1; deltaMovement.y += this._raycastHit.point.y - slopeRay.y - this.skinWidth; deltaMovement.x *= slopeModifier; this.collisionState.movingDownSlope = true; this.collisionState.slopeAngle = angle; } } return deltaMovement; } /** * 如果我们要上坡,则处理调整 deltaMovement * @param deltaMovement * @param angle * @returns */ private handleHorizontalSlope( deltaMovement: Vector2, angle: number ): boolean { if (Math.round(angle) === 90) { return false; } if (angle < this.slopeLimit) { if (deltaMovement.y > this.jumpingThreshold) { const slopeModifier = this.slopeSpeedMultiplier ? this.slopeSpeedMultiplier.lerp(angle) : 1; deltaMovement.x *= slopeModifier; deltaMovement.y = Math.abs( Math.tan(angle * MathHelper.Deg2Rad) * deltaMovement.x ); const isGoingRight = deltaMovement.x > 0; const ray = isGoingRight ? this._raycastOrigins.bottomRight : this._raycastOrigins.bottomLeft; let raycastHit = null; if ( this.supportSlopedOneWayPlatforms && this.collisionState.wasGroundedLastFrame ) { raycastHit = Physics.linecast( ray, ray.add(deltaMovement), this.platformMask, this.ignoredColliders ); } else { raycastHit = Physics.linecast( ray, ray.add(deltaMovement), this.platformMask & ~this.oneWayPlatformMask, this.ignoredColliders ); } if (raycastHit.collider) { deltaMovement.x = raycastHit.point.x - ray.x; deltaMovement.y = raycastHit.point.y - ray.y; if (isGoingRight) { deltaMovement.x -= this._skinWidth; } else { deltaMovement.x += this._skinWidth; } } this._isGoingUpSlope = true; this.collisionState.below = true; } } else { deltaMovement.x = 0; } return true; } private _player: Entity; private _collider: BoxCollider; private _skinWidth: number = 0.02; private _triggerHelper: ColliderTriggerHelper; /** * 这用于计算为检查坡度而投射的向下光线。 * 我们使用有点随意的值 75 度来计算检查斜率的射线的长度。 */ private _slopeLimitTangent: number; private readonly kSkinWidthFloatFudgeFactor: number = 0.001; /** * 我们的光线投射原点角的支架(TR、TL、BR、BL) */ private _raycastOrigins: CharacterRaycastOrigins = new CharacterRaycastOrigins(); /** * 存储我们在移动过程中命中的光线投射 */ private _raycastHit: RaycastHit = new RaycastHit(); /** * 存储此帧发生的任何光线投射命中。 * 我们必须存储它们,以防我们遇到水平和垂直移动的碰撞,以便我们可以在设置所有碰撞状态后发送事件 */ private _raycastHitsThisFrame: RaycastHit[]; // 水平/垂直移动数据 private _verticalDistanceBetweenRays: number; private _horizontalDistanceBetweenRays: number; /** * 我们使用这个标志来标记我们正在爬坡的情况,我们修改了 delta.y 以允许爬升。 * 原因是,如果我们到达斜坡的尽头,我们可以进行调整以保持接地 */ private _isGoingUpSlope: boolean = false; private _isWarpingToGround: boolean = true; private platformMask: number = -1; private triggerMask: number = -1; private oneWayPlatformMask: number = -1; private readonly rayOriginSkinMutiplier = 4; } }