Add NPC dialogue support in scene document and runtime interaction system
This commit is contained in:
@@ -152,6 +152,7 @@ import {
|
||||
PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
||||
SCHEDULER_ACTOR_ROUTINE_EFFECTS_SCENE_DOCUMENT_VERSION,
|
||||
EXPANDED_CONTROL_SURFACE_SCENE_DOCUMENT_VERSION,
|
||||
NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION,
|
||||
PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION,
|
||||
RUNNER_V1_SCENE_DOCUMENT_VERSION,
|
||||
SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION,
|
||||
@@ -277,6 +278,18 @@ function readOptionalSceneLoadingText(
|
||||
return normalizedValue.length === 0 ? null : normalizedValue;
|
||||
}
|
||||
|
||||
function readOptionalDialogueResourceId(
|
||||
value: unknown,
|
||||
label: string
|
||||
): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dialogueId = expectString(value, label).trim();
|
||||
return dialogueId.length === 0 ? null : dialogueId;
|
||||
}
|
||||
|
||||
function readSceneLoadingScreen(
|
||||
value: unknown,
|
||||
label: string,
|
||||
@@ -2758,6 +2771,10 @@ function readNpcEntity(value: unknown, label: string): EntityInstance {
|
||||
value.modelAssetId === undefined || value.modelAssetId === null
|
||||
? undefined
|
||||
: expectString(value.modelAssetId, `${label}.modelAssetId`),
|
||||
dialogueId: readOptionalDialogueResourceId(
|
||||
value.dialogueId,
|
||||
`${label}.dialogueId`
|
||||
),
|
||||
collider: readNpcColliderSettings(value.collider, `${label}.collider`)
|
||||
});
|
||||
|
||||
@@ -4241,6 +4258,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
|
||||
source.version !== PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== EXPANDED_CONTROL_SURFACE_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== SCHEDULER_ACTOR_ROUTINE_EFFECTS_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION &&
|
||||
source.version !== PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -2814,6 +2814,19 @@ function validateNpcEntity(
|
||||
|
||||
validateNpcPresence(entity.presence, `${path}.presence`, diagnostics);
|
||||
validateNpcModelAssetId(entity, path, document, diagnostics);
|
||||
if (
|
||||
entity.dialogueId !== null &&
|
||||
document.dialogues.dialogues[entity.dialogueId] === undefined
|
||||
) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"error",
|
||||
"missing-dialogue-resource",
|
||||
`Dialogue ${entity.dialogueId} does not exist in the project dialogue library.`,
|
||||
`${path}.dialogueId`
|
||||
)
|
||||
);
|
||||
}
|
||||
validateCharacterColliderSettings(entity.collider, path, diagnostics, {
|
||||
codePrefix: "npc",
|
||||
label: "NPC",
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
type ProjectDialogueLibrary
|
||||
} from "../dialogues/project-dialogues";
|
||||
|
||||
export const SCENE_DOCUMENT_VERSION = 50 as const;
|
||||
export const SCENE_DOCUMENT_VERSION = 51 as const;
|
||||
export const NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION = 51 as const;
|
||||
export const PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION = 50 as const;
|
||||
export const SCHEDULER_ACTOR_ROUTINE_EFFECTS_SCENE_DOCUMENT_VERSION = 49 as const;
|
||||
export const SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION = 48 as const;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import type {
|
||||
RuntimeInteractable,
|
||||
RuntimeNpc,
|
||||
RuntimeSceneDefinition,
|
||||
RuntimeSceneExit,
|
||||
RuntimeTeleportTarget,
|
||||
@@ -14,6 +15,13 @@ import type {
|
||||
} from "./runtime-scene-build";
|
||||
|
||||
const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75;
|
||||
const DEFAULT_NPC_DIALOGUE_TARGET_RADIUS = 1.5;
|
||||
|
||||
export interface RuntimeDialogueStartSource {
|
||||
kind: "interactionLink" | "npc" | "direct";
|
||||
sourceEntityId: string | null;
|
||||
linkId: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeInteractionDispatcher {
|
||||
teleportPlayer(target: RuntimeTeleportTarget, link: InteractionLink): void;
|
||||
@@ -32,7 +40,7 @@ export interface RuntimeInteractionDispatcher {
|
||||
stopAnimation(instanceId: string, link: InteractionLink): void;
|
||||
playSound(soundEmitterId: string, link: InteractionLink): void;
|
||||
stopSound(soundEmitterId: string, link: InteractionLink): void;
|
||||
startDialogue(dialogueId: string, link: InteractionLink): void;
|
||||
startDialogue(dialogueId: string, source?: RuntimeDialogueStartSource): void;
|
||||
dispatchControlEffect?(effect: ControlEffect, link: InteractionLink): void;
|
||||
}
|
||||
|
||||
@@ -154,6 +162,40 @@ function getInteractableTargetRadius(
|
||||
return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius);
|
||||
}
|
||||
|
||||
function getNpcDialogueTargetRadius(npc: RuntimeNpc): number {
|
||||
switch (npc.collider.mode) {
|
||||
case "capsule":
|
||||
return Math.max(
|
||||
DEFAULT_NPC_DIALOGUE_TARGET_RADIUS,
|
||||
npc.collider.radius * 2
|
||||
);
|
||||
case "box":
|
||||
return Math.max(
|
||||
DEFAULT_NPC_DIALOGUE_TARGET_RADIUS,
|
||||
Math.max(npc.collider.size.x, npc.collider.size.z) * 0.75
|
||||
);
|
||||
case "none":
|
||||
return DEFAULT_NPC_DIALOGUE_TARGET_RADIUS;
|
||||
}
|
||||
}
|
||||
|
||||
function getNpcDialoguePrompt(
|
||||
npc: RuntimeNpc,
|
||||
hasClickLinks: boolean
|
||||
): string {
|
||||
const trimmedName = npc.name?.trim() ?? "";
|
||||
|
||||
if (npc.dialogueId !== null) {
|
||||
return trimmedName.length > 0 ? `Talk to ${trimmedName}` : "Talk";
|
||||
}
|
||||
|
||||
return trimmedName.length > 0
|
||||
? `Interact with ${trimmedName}`
|
||||
: hasClickLinks
|
||||
? "Interact"
|
||||
: "Talk";
|
||||
}
|
||||
|
||||
function updateBestPrompt(
|
||||
currentBestPrompt: RuntimeInteractionPrompt | null,
|
||||
currentBestHitDistance: number,
|
||||
@@ -337,6 +379,48 @@ export class RuntimeInteractionSystem {
|
||||
bestHitDistance = next.hitDistance;
|
||||
}
|
||||
|
||||
for (const npc of runtimeScene.entities.npcs) {
|
||||
if (!npc.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasClickLinks = hasTriggerLinks(runtimeScene, npc.entityId, "click");
|
||||
|
||||
if (!hasClickLinks && npc.dialogueId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const radius = getNpcDialogueTargetRadius(npc);
|
||||
const distance = distanceBetweenVec3(interactionOrigin, npc.position);
|
||||
|
||||
if (distance > radius) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hitDistance = raySphereHitDistance(
|
||||
rayOrigin,
|
||||
normalizedViewDirection,
|
||||
npc.position,
|
||||
radius
|
||||
);
|
||||
|
||||
if (hitDistance === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = updateBestPrompt(
|
||||
bestPrompt,
|
||||
bestHitDistance,
|
||||
npc.entityId,
|
||||
getNpcDialoguePrompt(npc, hasClickLinks),
|
||||
distance,
|
||||
radius,
|
||||
hitDistance
|
||||
);
|
||||
bestPrompt = next.prompt;
|
||||
bestHitDistance = next.hitDistance;
|
||||
}
|
||||
|
||||
return bestPrompt;
|
||||
}
|
||||
|
||||
@@ -355,6 +439,27 @@ export class RuntimeInteractionSystem {
|
||||
return;
|
||||
}
|
||||
|
||||
const npc =
|
||||
runtimeScene.entities.npcs.find(
|
||||
(candidate) => candidate.entityId === sourceEntityId
|
||||
) ?? null;
|
||||
|
||||
if (npc !== null) {
|
||||
if (hasTriggerLinks(runtimeScene, npc.entityId, "click")) {
|
||||
this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher);
|
||||
return;
|
||||
}
|
||||
|
||||
if (npc.dialogueId !== null) {
|
||||
dispatcher.startDialogue(npc.dialogueId, {
|
||||
kind: "npc",
|
||||
sourceEntityId: npc.entityId,
|
||||
linkId: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher);
|
||||
}
|
||||
|
||||
@@ -416,7 +521,11 @@ export class RuntimeInteractionSystem {
|
||||
dispatcher.stopSound(link.action.targetSoundEmitterId, link);
|
||||
break;
|
||||
case "startDialogue":
|
||||
dispatcher.startDialogue(link.action.dialogueId, link);
|
||||
dispatcher.startDialogue(link.action.dialogueId, {
|
||||
kind: "interactionLink",
|
||||
sourceEntityId: link.sourceEntityId,
|
||||
linkId: link.id
|
||||
});
|
||||
break;
|
||||
case "control":
|
||||
throw new Error(
|
||||
|
||||
@@ -226,6 +226,7 @@ export interface RuntimeNpc {
|
||||
position: Vec3;
|
||||
yawDegrees: number;
|
||||
modelAssetId: string | null;
|
||||
dialogueId: string | null;
|
||||
collider: FirstPersonPlayerShape;
|
||||
activeRoutineTitle: string | null;
|
||||
animationClipName: string | null;
|
||||
@@ -509,6 +510,7 @@ export function createRuntimeNpcFromDefinition(
|
||||
position: cloneVec3(npc.position),
|
||||
yawDegrees: npc.yawDegrees,
|
||||
modelAssetId: npc.modelAssetId,
|
||||
dialogueId: npc.dialogueId,
|
||||
collider: cloneRuntimeCharacterShape(npc.collider),
|
||||
activeRoutineTitle: npc.activeRoutineTitle,
|
||||
animationClipName: npc.animationClipName,
|
||||
@@ -1356,6 +1358,7 @@ function buildRuntimeSceneCollections(
|
||||
yawDegrees: entity.yawDegrees,
|
||||
authoredYawDegrees: entity.yawDegrees,
|
||||
modelAssetId: entity.modelAssetId,
|
||||
dialogueId: entity.dialogueId,
|
||||
collider: createRuntimeCharacterShape(entity.collider),
|
||||
animationClipName: null,
|
||||
animationLoop: undefined,
|
||||
|
||||
Reference in New Issue
Block a user