Refactor runtime target look input to use screen-space projection for candidate selection

This commit is contained in:
2026-04-25 16:26:47 +02:00
parent 4be1e05251
commit ff11b16f42

View File

@@ -5618,138 +5618,168 @@ export class RuntimeHost {
this.setActiveRuntimeTargetReference(null);
}
private handleRuntimeTargetLookInput(horizontalIntent: -1 | 0 | 1): boolean {
private createRuntimeTargetLookInputResult(
result: Partial<RuntimeTargetLookInputResult> = {}
): RuntimeTargetLookInputResult {
return {
activeTargetLocked: result.activeTargetLocked ?? false,
switchedTarget: result.switchedTarget ?? false,
switchInputHeld: result.switchInputHeld ?? false
};
}
private handleRuntimeTargetLookInput(
input: RuntimeTargetLookInput
): RuntimeTargetLookInputResult {
const activeTarget = this.resolveActiveRuntimeTarget();
if (activeTarget === null) {
if (this.activeRuntimeTargetReference !== null) {
this.setActiveRuntimeTargetReference(null);
}
this.runtimeTargetLookInputHeldDirection = null;
return false;
this.runtimeTargetSwitchInputHeld = false;
return this.createRuntimeTargetLookInputResult();
}
if (horizontalIntent === 0) {
this.runtimeTargetLookInputHeldDirection = null;
return true;
const inputMagnitude = Math.hypot(input.horizontal, input.vertical);
if (inputMagnitude <= Number.EPSILON) {
this.runtimeTargetSwitchInputHeld = false;
return this.createRuntimeTargetLookInputResult({
activeTargetLocked: true
});
}
if (this.runtimeTargetLookInputHeldDirection === horizontalIntent) {
return true;
if (
this.runtimeTargetSwitchInputHeld ||
inputMagnitude < TARGETING_DIRECTION_SWITCH_INPUT_THRESHOLD
) {
if (inputMagnitude < TARGETING_DIRECTION_SWITCH_INPUT_THRESHOLD) {
this.runtimeTargetSwitchInputHeld = false;
}
return this.createRuntimeTargetLookInputResult({
activeTargetLocked: true,
switchInputHeld: this.runtimeTargetSwitchInputHeld
});
}
this.runtimeTargetLookInputHeldDirection = horizontalIntent;
const origin = this.currentPlayerControllerTelemetry?.eyePosition ?? null;
if (origin === null) {
return true;
}
this.camera.getWorldDirection(this.cameraForward);
const cameraLength = Math.hypot(this.cameraForward.x, this.cameraForward.z);
const fallbackDirection = {
x: activeTarget.center.x - origin.x,
z: activeTarget.center.z - origin.z
};
const fallbackLength = Math.hypot(fallbackDirection.x, fallbackDirection.z);
const cameraDirection =
cameraLength <= Number.EPSILON && fallbackLength > Number.EPSILON
? {
x: fallbackDirection.x / fallbackLength,
z: fallbackDirection.z / fallbackLength
}
: {
x: this.cameraForward.x / Math.max(cameraLength, Number.EPSILON),
z: this.cameraForward.z / Math.max(cameraLength, Number.EPSILON)
};
const sideTarget = this.resolveRuntimeTargetCandidateOnLookSide(
const directionalTarget = this.resolveRuntimeTargetCandidateInLookDirection(
activeTarget,
-horizontalIntent as -1 | 1,
cameraDirection
input
);
if (sideTarget !== null) {
if (directionalTarget !== null) {
this.setActiveRuntimeTargetReference({
kind: sideTarget.kind,
entityId: sideTarget.entityId
kind: directionalTarget.kind,
entityId: directionalTarget.entityId
});
this.runtimeTargetSwitchInputHeld = true;
this.proposedRuntimeTarget = directionalTarget;
return this.createRuntimeTargetLookInputResult({
activeTargetLocked: true,
switchedTarget: true,
switchInputHeld: true
});
this.runtimeTargetLookInputHeldDirection = horizontalIntent;
this.proposedRuntimeTarget = sideTarget;
}
return true;
return this.createRuntimeTargetLookInputResult({
activeTargetLocked: true
});
}
private resolveRuntimeTargetCandidateOnLookSide(
activeTarget: RuntimeResolvedTarget,
lookSide: -1 | 1,
cameraDirection: { x: number; z: number }
): RuntimeTargetCandidate | null {
const origin = this.currentPlayerControllerTelemetry?.eyePosition ?? null;
private resolveRuntimeTargetScreenPoint(point: {
x: number;
y: number;
z: number;
}) {
const projected = new Vector3(point.x, point.y, point.z).project(this.camera);
if (origin === null) {
if (
!Number.isFinite(projected.x) ||
!Number.isFinite(projected.y) ||
!Number.isFinite(projected.z) ||
projected.z < -1 ||
projected.z > 1
) {
return null;
}
const activeDirection = {
x: activeTarget.center.x - origin.x,
z: activeTarget.center.z - origin.z
return {
x: projected.x,
y: projected.y
};
const activeLength = Math.hypot(activeDirection.x, activeDirection.z);
}
if (activeLength <= Number.EPSILON) {
private resolveRuntimeTargetCandidateInLookDirection(
activeTarget: RuntimeResolvedTarget,
input: RuntimeTargetLookInput
): RuntimeTargetCandidate | null {
const inputLength = Math.hypot(input.horizontal, input.vertical);
if (inputLength <= Number.EPSILON) {
return null;
}
const activeScreenPoint = this.resolveRuntimeTargetScreenPoint(
activeTarget.center
);
if (activeScreenPoint === null) {
return null;
}
let bestCandidate: RuntimeTargetCandidate | null = null;
let bestCameraAngle = Number.POSITIVE_INFINITY;
const activeX = activeDirection.x / activeLength;
const activeZ = activeDirection.z / activeLength;
let bestAlignment = TARGETING_SCREEN_SWITCH_MIN_ALIGNMENT;
let bestScreenDistance = 0;
const inputX = input.horizontal / inputLength;
const inputY = input.vertical / inputLength;
for (const candidate of this.runtimeTargetCandidates) {
if (candidate.entityId === activeTarget.entityId) {
continue;
}
const candidateDirection = {
x: candidate.center.x - origin.x,
z: candidate.center.z - origin.z
};
const candidateLength = Math.hypot(
candidateDirection.x,
candidateDirection.z
);
if (candidateLength <= Number.EPSILON) {
continue;
}
const candidateX = candidateDirection.x / candidateLength;
const candidateZ = candidateDirection.z / candidateLength;
const signedAngle = Math.atan2(
activeZ * candidateX - activeX * candidateZ,
activeX * candidateX + activeZ * candidateZ
);
if (signedAngle * lookSide <= TARGETING_SIDE_SWITCH_EPSILON_RADIANS) {
continue;
}
const cameraAngle = Math.acos(
clampScalar(
candidateX * cameraDirection.x + candidateZ * cameraDirection.z,
-1,
1
)
const candidateScreenPoint = this.resolveRuntimeTargetScreenPoint(
candidate.center
);
if (
candidateScreenPoint === null ||
Math.abs(candidateScreenPoint.x) > TARGETING_SCREEN_SWITCH_MAX_ABS_X ||
Math.abs(candidateScreenPoint.y) > TARGETING_SCREEN_SWITCH_MAX_ABS_Y
) {
continue;
}
const screenDeltaX = candidateScreenPoint.x - activeScreenPoint.x;
const screenDeltaY = candidateScreenPoint.y - activeScreenPoint.y;
const screenDistance = Math.hypot(screenDeltaX, screenDeltaY);
if (screenDistance < TARGETING_SCREEN_SWITCH_MIN_DISTANCE) {
continue;
}
const alignment =
(screenDeltaX / screenDistance) * inputX +
(screenDeltaY / screenDistance) * inputY;
if (alignment < TARGETING_SCREEN_SWITCH_MIN_ALIGNMENT) {
continue;
}
if (
bestCandidate === null ||
cameraAngle < bestCameraAngle ||
(cameraAngle === bestCameraAngle && candidate.score > bestCandidate.score)
alignment > bestAlignment ||
(alignment === bestAlignment && screenDistance > bestScreenDistance) ||
(alignment === bestAlignment &&
screenDistance === bestScreenDistance &&
candidate.score > bestCandidate.score)
) {
bestCandidate = candidate;
bestCameraAngle = cameraAngle;
bestAlignment = alignment;
bestScreenDistance = screenDistance;
}
}