Spaces:
Sleeping
Sleeping
| import * as THREE from "three"; | |
| import { Pilot, InputFrame, CameraMode, BombSightInfo, WeaponType } from "../../types"; | |
| import { AIRCRAFT_DEFINITIONS } from "../content/aircraft/registry"; | |
| import { getTerrainHeight } from "../terrainModel"; | |
| import { getProjectileReleaseState } from "../projectileSystem"; | |
| import { MapDefinition } from "../content/maps/mapTypes"; | |
| import { cockpitPanelState } from "../cockpitPanelState"; | |
| export class CameraManager { | |
| public camera: THREE.PerspectiveCamera; | |
| public cockpitLight: THREE.PointLight | null; | |
| private mapDef: MapDefinition; | |
| public cameraMode: CameraMode = "third-person"; | |
| public cameraModeTransitionPending = true; | |
| public freeLookYaw = 0; | |
| public freeLookPitch = 0; | |
| public cameraShakeTime = 0; | |
| public reticleTurbulenceX = 0; | |
| public reticleTurbulenceY = 0; | |
| public bombSightInfo: BombSightInfo | null = null; | |
| private targetCameraOffset = new THREE.Vector3(0, 5.5, 17); | |
| private cameraLookAtTarget = new THREE.Vector3(); | |
| // Scratch objects reused every frame to avoid GC pressure. | |
| private _scratchV0 = new THREE.Vector3(); | |
| private _scratchV1 = new THREE.Vector3(); | |
| private _scratchV2 = new THREE.Vector3(); | |
| private _scratchV3 = new THREE.Vector3(); | |
| private _scratchQ0 = new THREE.Quaternion(); | |
| private _scratchQ1 = new THREE.Quaternion(); | |
| private _scratchE0 = new THREE.Euler(); | |
| private _scratchM4 = new THREE.Matrix4(); | |
| constructor( | |
| camera: THREE.PerspectiveCamera, | |
| cockpitLight: THREE.PointLight | null, | |
| mapDef: MapDefinition | |
| ) { | |
| this.camera = camera; | |
| this.cockpitLight = cockpitLight; | |
| this.mapDef = mapDef; | |
| } | |
| public setCameraMode(mode: CameraMode) { | |
| if (this.cameraMode === mode) return; | |
| const willBeFirstPerson = mode === "first-person"; | |
| this.cameraMode = mode; | |
| this.reticleTurbulenceX = 0; | |
| this.reticleTurbulenceY = 0; | |
| this.cameraModeTransitionPending = true; | |
| // Preserve free-look direction, clamping to the limits of the new mode | |
| if (willBeFirstPerson) { | |
| this.freeLookYaw = THREE.MathUtils.clamp(this.freeLookYaw, -Math.PI * 0.72, Math.PI * 0.72); | |
| } | |
| this.freeLookPitch = THREE.MathUtils.clamp(this.freeLookPitch, -Math.PI / 2.2, Math.PI / 2.2); | |
| } | |
| public update( | |
| pilots: Pilot[], | |
| playerPilotId: string, | |
| inputFrame: InputFrame | undefined, | |
| dt: number, | |
| aircraftGroupMap: Map<string, THREE.Group>, | |
| cockpitStateMap: Map<string, any> | |
| ) { | |
| const playerPilot = pilots.find((p) => p.id === playerPilotId); | |
| if (!playerPilot) return; | |
| const pGroup = aircraftGroupMap.get(playerPilotId); | |
| if (!pGroup) return; | |
| const aircraftDef = AIRCRAFT_DEFINITIONS.find((definition) => definition.specs.id === playerPilot.aircraftId); | |
| const cameraDef = aircraftDef?.render.camera; | |
| const hiddenBlockIds = new Set(cameraDef?.hiddenBlockIds ?? ["canopy"]); | |
| const speedKmph = this._scratchV0.set(playerPilot.vx, playerPilot.vy, playerPilot.vz).length() * 3.6; | |
| const firstPersonBaseFov = cameraDef?.firstPersonFov ?? 74; | |
| const firstPersonSpeedFov = THREE.MathUtils.clamp(((speedKmph - 250) / 250) * 4, 0, 4); | |
| const targetFov = | |
| this.cameraMode === "first-person" | |
| ? THREE.MathUtils.clamp(firstPersonBaseFov + firstPersonSpeedFov, 68, 80) | |
| : this.cameraMode === "bombsight" | |
| ? 52 | |
| : THREE.MathUtils.clamp(65 + speedKmph / 28, 62, 92); | |
| this.camera.fov += (targetFov - this.camera.fov) * (1 - Math.exp(-3 * dt)); | |
| // Snap camera near plane instantly to avoid weird slicing/sweeping effects through cockpit geometry | |
| this.camera.near = | |
| this.cameraMode === "third-person" ? 1 : this.cameraMode === "first-person" ? 0.04 : 0.25; | |
| const isFreeLookActive = !!(inputFrame && inputFrame.rightMouse); | |
| if (isFreeLookActive && inputFrame) { | |
| this.freeLookYaw += inputFrame.mouseDelta.x * 2.8; | |
| this.freeLookPitch -= inputFrame.mouseDelta.y * 2.1; | |
| this.freeLookPitch = THREE.MathUtils.clamp(this.freeLookPitch, -Math.PI / 2.2, Math.PI / 2.2); | |
| if (this.cameraMode === "first-person") { | |
| this.freeLookYaw = THREE.MathUtils.clamp(this.freeLookYaw, -Math.PI * 0.72, Math.PI * 0.72); | |
| } | |
| } else { | |
| const freeLookDecay = 1 - Math.exp(-8 * dt); | |
| this.freeLookYaw += (0 - this.freeLookYaw) * freeLookDecay; | |
| this.freeLookPitch += (0 - this.freeLookPitch) * freeLookDecay; | |
| } | |
| if (this.cameraMode === "bombsight") { | |
| cockpitPanelState.active = false; | |
| if (this.cockpitLight) this.cockpitLight.intensity = 0; | |
| const ckEntry3 = cockpitStateMap.get(playerPilotId); | |
| if (ckEntry3) ckEntry3.group.visible = false; | |
| pGroup.visible = false; | |
| this.setFirstPersonBlockVisibility(pGroup, playerPilot, hiddenBlockIds, false); | |
| this._scratchM4.makeBasis( | |
| this._scratchV0.set(-1, 0, 0), | |
| this._scratchV1.set(0, 0, 1), | |
| this._scratchV2.set(0, 1, 0) | |
| ); | |
| const mountQuaternion = this._scratchQ0.setFromRotationMatrix(this._scratchM4); | |
| const cameraPosition = this._scratchV3 | |
| .set(0, -1.45, 0.65) | |
| .applyQuaternion(pGroup.quaternion) | |
| .add(pGroup.position); | |
| this.camera.position.copy(cameraPosition); | |
| this.camera.quaternion.copy(pGroup.quaternion).multiply(mountQuaternion).normalize(); | |
| } else if (this.cameraMode === "first-person") { | |
| pGroup.visible = true; | |
| this.setFirstPersonBlockVisibility(pGroup, playerPilot, hiddenBlockIds, true); | |
| if (this.cockpitLight) this.cockpitLight.intensity = 4.5; | |
| const ckEntry = cockpitStateMap.get(playerPilotId); | |
| const eyeSrc = ckEntry?.eyeLocal ?? (cameraDef?.cockpitEye ? this._scratchV0.set(...cameraDef.cockpitEye) : this._scratchV0.set(0, 1.15, 1.6)); | |
| const cockpitPosition = this._scratchV1 | |
| .copy(eyeSrc) | |
| .applyQuaternion(pGroup.quaternion) | |
| .add(pGroup.position); | |
| this.camera.position.copy(cockpitPosition); | |
| const lightPosition = this._scratchV2 | |
| .copy(eyeSrc) | |
| .add(this._scratchV3.set(0, 0.12, 0.16)) | |
| .applyQuaternion(pGroup.quaternion) | |
| .add(pGroup.position); | |
| this.cockpitLight.position.copy(lightPosition); | |
| if (ckEntry) { | |
| ckEntry.group.visible = true; | |
| const speed = Math.sqrt(playerPilot.vx ** 2 + playerPilot.vy ** 2 + playerPilot.vz ** 2); | |
| const alt01 = Math.min(playerPilot.y / 14000, 1.0); | |
| const euler = this._scratchE0.setFromQuaternion(pGroup.quaternion, "YXZ"); | |
| const heading01 = (((euler.y / (Math.PI * 2)) % 1) + 1) % 1; | |
| const wasActive = cockpitPanelState.active; | |
| const prevAlt = wasActive ? cockpitPanelState.alt01 : alt01; | |
| cockpitPanelState.active = true; | |
| cockpitPanelState.speed01 = Math.min(speed / 600, 1.0); | |
| cockpitPanelState.vsi01 = THREE.MathUtils.clamp((alt01 - prevAlt) * 8, -1, 1); | |
| cockpitPanelState.alt01 = alt01; | |
| cockpitPanelState.heading01 = heading01; | |
| cockpitPanelState.throttle01 = playerPilot.throttle; | |
| cockpitPanelState.pitch_rad = -euler.x; | |
| cockpitPanelState.roll_rad = euler.z; | |
| cockpitPanelState.gearDown = playerPilot.gearDeployed; | |
| cockpitPanelState.flapsOut = playerPilot.flaps !== "up"; | |
| cockpitPanelState.airbrakeOn = playerPilot.airbrakeDeployed; | |
| cockpitPanelState.engineDamaged = playerPilot.damage.engine < 0.5; | |
| } | |
| const freeLookRot = this._scratchQ0.setFromEuler( | |
| this._scratchE0.set(this.freeLookPitch, this.freeLookYaw, 0, "YXZ") | |
| ); | |
| const combinedRot = this._scratchQ1.copy(pGroup.quaternion).multiply(freeLookRot); | |
| const lookDir = this._scratchV0.set(0, 0, 1).applyQuaternion(combinedRot).normalize(); | |
| const lookTarget = this._scratchV2.copy(cockpitPosition).addScaledVector(lookDir, 250); | |
| const rotatedUp = this._scratchV3.set(0, 1, 0).applyQuaternion(combinedRot).normalize(); | |
| this.camera.up.copy(rotatedUp); | |
| this.camera.lookAt(lookTarget); | |
| } else { | |
| cockpitPanelState.active = false; | |
| if (this.cockpitLight) this.cockpitLight.intensity = 0; | |
| const ckEntry2 = cockpitStateMap.get(playerPilotId); | |
| if (ckEntry2) ckEntry2.group.visible = false; | |
| pGroup.visible = true; | |
| this.setFirstPersonBlockVisibility(pGroup, playerPilot, hiddenBlockIds, false); | |
| const freeLookRot = this._scratchQ0.setFromEuler( | |
| this._scratchE0.set(this.freeLookPitch, this.freeLookYaw, 0, "YXZ") | |
| ); | |
| const rotatedOffset = this._scratchV0 | |
| .set(0, this.targetCameraOffset.y, -this.targetCameraOffset.z) | |
| .applyQuaternion(freeLookRot); | |
| // Stable roll-free quaternion: align forward with aircraft nose, keep X horizontal. | |
| const forwardVec = this._scratchV1.set(0, 0, 1).applyQuaternion(pGroup.quaternion).normalize(); | |
| const rightVec = this._scratchV2.crossVectors(this._scratchV3.set(0, 1, 0), forwardVec).normalize(); | |
| if (rightVec.lengthSq() < 1e-4) { | |
| rightVec.set(1, 0, 0).applyQuaternion(pGroup.quaternion); | |
| rightVec.y = 0; | |
| rightVec.normalize(); | |
| if (rightVec.lengthSq() < 1e-4) rightVec.set(1, 0, 0); | |
| } | |
| const upVec = this._scratchV3.crossVectors(forwardVec, rightVec).normalize(); | |
| const rollFreeQuat = this._scratchQ1.setFromRotationMatrix( | |
| this._scratchM4.makeBasis(rightVec, upVec, forwardVec) | |
| ); | |
| const targetCamPos = this._scratchV3 | |
| .copy(rotatedOffset) | |
| .applyQuaternion(rollFreeQuat) | |
| .add(pGroup.position); | |
| if (this.cameraModeTransitionPending) { | |
| this.camera.position.copy(targetCamPos); | |
| } else { | |
| this.camera.position.lerp(targetCamPos, 1 - Math.exp(-7.5 * dt)); | |
| } | |
| const worldLookDir = this._scratchV0.set(0, 0, 1).applyQuaternion(freeLookRot).applyQuaternion(rollFreeQuat).normalize(); | |
| const lookTarget = this._scratchV2.copy(pGroup.position).addScaledVector(worldLookDir, 150); | |
| if (this.cameraModeTransitionPending) { | |
| this.cameraLookAtTarget.copy(lookTarget); | |
| } else { | |
| this.cameraLookAtTarget.lerp(lookTarget, 1 - Math.exp(-9 * dt)); | |
| } | |
| const rotatedUp = this._scratchV1.set(0, 1, 0) | |
| .applyQuaternion(freeLookRot) | |
| .applyQuaternion(rollFreeQuat) | |
| .normalize(); | |
| this.camera.up.copy(rotatedUp); | |
| this.camera.lookAt(this.cameraLookAtTarget); | |
| } | |
| this.cameraModeTransitionPending = false; | |
| this.cameraShakeTime += dt; | |
| const shakeKmph = this._scratchV0.set(playerPilot.vx, playerPilot.vy, playerPilot.vz).length() * 3.6; | |
| const speedBuffet = THREE.MathUtils.clamp((shakeKmph - 500) / 250, 0, 1); | |
| const stallBuffet = THREE.MathUtils.clamp(playerPilot.stallSeverity ?? 0, 0, 1); | |
| const t = this.cameraShakeTime; | |
| const shakeX = Math.sin(t * 18.7) * Math.sin(t * 6.3); | |
| const shakeY = Math.sin(t * 24.1 + 0.9) * Math.sin(t * 4.9); | |
| if (this.cameraMode === "first-person") { | |
| // Reticle jitter is the sole expression of stall buffet in FPV — the cockpit interior | |
| // stays visually stable, matching the physical reality that you ride with the airframe. | |
| const sightBuffet = Math.min(1.0, speedBuffet * 0.35 + stallBuffet * 0.45); | |
| this.reticleTurbulenceX = shakeX * sightBuffet * 0.28; | |
| this.reticleTurbulenceY = shakeY * sightBuffet * 0.22; | |
| } else { | |
| this.reticleTurbulenceX = 0; | |
| this.reticleTurbulenceY = 0; | |
| const shakeStrength = speedBuffet * 0.1 + stallBuffet * 0.08; | |
| const dx = shakeX * shakeStrength; | |
| const dy = shakeY * shakeStrength; | |
| const shakeLocal = this._scratchV0.set(dx, dy, 0).applyQuaternion(pGroup.quaternion); | |
| this.camera.position.add(shakeLocal); | |
| } | |
| this.camera.updateProjectionMatrix(); | |
| this.updateBombSightPrediction(playerPilot); | |
| } | |
| private setFirstPersonBlockVisibility( | |
| group: THREE.Group, | |
| pilot: Pilot, | |
| hiddenBlockIds: Set<string>, | |
| isFirstPerson: boolean | |
| ) { | |
| group.traverse((child) => { | |
| const isPropeller = child.userData.role === "propeller" || (child.userData.tags && child.userData.tags.includes("spinZ")); | |
| const isHidden = hiddenBlockIds.has(child.userData.blockId) || isPropeller; | |
| if (!isHidden) return; | |
| if (isFirstPerson) { | |
| child.visible = false; | |
| return; | |
| } | |
| const damageComponent = child.userData.damageComponent as keyof Pilot["damage"] | undefined; | |
| const damageValue = damageComponent ? pilot.damage[damageComponent] : undefined; | |
| child.visible = typeof damageValue === "number" ? damageValue > 0.05 : true; | |
| }); | |
| } | |
| private updateBombSightPrediction(player: Pilot) { | |
| if (this.cameraMode !== "bombsight") { | |
| this.bombSightInfo = null; | |
| return; | |
| } | |
| const release = getProjectileReleaseState(player, WeaponType.BOMB); | |
| const position = release.position.clone(); | |
| const velocity = release.velocity.clone(); | |
| const step = 0.04; | |
| let time = 0; | |
| let valid = false; | |
| while (time < 7) { | |
| velocity.y -= 9.8 * step; | |
| position.addScaledVector(velocity, step); | |
| time += step; | |
| const terrainHeight = getTerrainHeight(position.x, position.z, this.mapDef.id).height; | |
| if (position.y <= Math.max(12, terrainHeight)) { | |
| position.y = Math.max(12, terrainHeight); | |
| valid = true; | |
| break; | |
| } | |
| } | |
| const projected = position.clone().project(this.camera); | |
| this.bombSightInfo = { | |
| x: (projected.x * 0.5 + 0.5) * 100, | |
| y: (-projected.y * 0.5 + 0.5) * 100, | |
| timeToImpact: time, | |
| impactX: position.x, | |
| impactZ: position.z, | |
| valid: | |
| valid && | |
| projected.z >= -1 && | |
| projected.z <= 1 && | |
| projected.x >= -1.25 && | |
| projected.x <= 1.25 && | |
| projected.y >= -1.25 && | |
| projected.y <= 1.25 | |
| }; | |
| } | |
| } | |